This is page 13 of 17. Use http://codebase.md/oraios/serena?lines=false&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
│ ├── docs.yaml
│ ├── junie.yml
│ ├── publish.yml
│ └── pytest.yml
├── .gitignore
├── .serena
│ ├── .gitignore
│ ├── 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
│ ├── _config.yml
│ ├── _static
│ │ └── images
│ │ └── jetbrains-marketplace-button.png
│ ├── .gitignore
│ ├── 01-about
│ │ ├── 000_intro.md
│ │ ├── 010_llm-integration.md
│ │ ├── 020_programming-languages.md
│ │ ├── 030_serena-in-action.md
│ │ ├── 035_tools.md
│ │ ├── 040_comparison-to-other-agents.md
│ │ └── 050_acknowledgements.md
│ ├── 02-usage
│ │ ├── 000_intro.md
│ │ ├── 010_prerequisites.md
│ │ ├── 020_running.md
│ │ ├── 025_jetbrains_plugin.md
│ │ ├── 030_clients.md
│ │ ├── 040_workflow.md
│ │ ├── 050_configuration.md
│ │ ├── 060_dashboard.md
│ │ ├── 070_security.md
│ │ └── 999_additional-usage.md
│ ├── 03-special-guides
│ │ ├── 000_intro.md
│ │ ├── custom_agent.md
│ │ ├── groovy_setup_guide_for_serena.md
│ │ ├── scala_setup_guide_for_serena.md
│ │ └── serena_on_chatgpt.md
│ ├── autogen_rst.py
│ ├── create_toc.py
│ └── index.md
├── flake.lock
├── flake.nix
├── lessons_learned.md
├── LICENSE
├── llms-install.md
├── pyproject.toml
├── README.md
├── repo_dir_sync.py
├── resources
│ ├── jetbrains-marketplace-button.cdr
│ ├── 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
│ └── profile_tool_call.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
│ │ ├── ls_manager.py
│ │ ├── mcp.py
│ │ ├── project.py
│ │ ├── prompt_factory.py
│ │ ├── resources
│ │ │ ├── config
│ │ │ │ ├── contexts
│ │ │ │ │ ├── agent.yml
│ │ │ │ │ ├── chatgpt.yml
│ │ │ │ │ ├── claude-code.yml
│ │ │ │ │ ├── codex.yml
│ │ │ │ │ ├── context.template.yml
│ │ │ │ │ ├── desktop-app.yml
│ │ │ │ │ ├── ide.yml
│ │ │ │ │ └── oaicompat-agent.yml
│ │ │ │ ├── internal_modes
│ │ │ │ │ └── jetbrains.yml
│ │ │ │ ├── modes
│ │ │ │ │ ├── editing.yml
│ │ │ │ │ ├── interactive.yml
│ │ │ │ │ ├── mode.template.yml
│ │ │ │ │ ├── no-memories.yml
│ │ │ │ │ ├── no-onboarding.yml
│ │ │ │ │ ├── onboarding.yml
│ │ │ │ │ ├── one-shot.yml
│ │ │ │ │ └── planning.yml
│ │ │ │ └── prompt_templates
│ │ │ │ ├── simple_tool_outputs.yml
│ │ │ │ └── system_prompt.yml
│ │ │ ├── dashboard
│ │ │ │ ├── dashboard.css
│ │ │ │ ├── dashboard.js
│ │ │ │ ├── index.html
│ │ │ │ ├── jquery.min.js
│ │ │ │ ├── serena-icon-16.png
│ │ │ │ ├── serena-icon-32.png
│ │ │ │ ├── serena-icon-48.png
│ │ │ │ ├── serena-logo-dark-mode.svg
│ │ │ │ ├── serena-logo.svg
│ │ │ │ ├── serena-logs-dark-mode.png
│ │ │ │ └── serena-logs.png
│ │ │ ├── project.template.yml
│ │ │ └── serena_config.template.yml
│ │ ├── symbol.py
│ │ ├── task_executor.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
│ │ ├── cli_util.py
│ │ ├── exception.py
│ │ ├── file_system.py
│ │ ├── general.py
│ │ ├── git.py
│ │ ├── gui.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
│ │ ├── fortran_language_server.py
│ │ ├── fsharp_language_server.py
│ │ ├── gopls.py
│ │ ├── groovy_language_server.py
│ │ ├── haskell_language_server.py
│ │ ├── intelephense.py
│ │ ├── jedi_server.py
│ │ ├── julia_server.py
│ │ ├── kotlin_language_server.py
│ │ ├── lua_ls.py
│ │ ├── marksman.py
│ │ ├── matlab_language_server.py
│ │ ├── nixd_ls.py
│ │ ├── omnisharp
│ │ │ ├── initialize_params.json
│ │ │ ├── runtime_dependencies.json
│ │ │ └── workspace_did_change_configuration.json
│ │ ├── omnisharp.py
│ │ ├── pascal_server.py
│ │ ├── perl_language_server.py
│ │ ├── powershell_language_server.py
│ │ ├── pyright_server.py
│ │ ├── r_language_server.py
│ │ ├── regal_server.py
│ │ ├── ruby_lsp.py
│ │ ├── rust_analyzer.py
│ │ ├── scala_language_server.py
│ │ ├── solargraph.py
│ │ ├── sourcekit_lsp.py
│ │ ├── taplo_server.py
│ │ ├── terraform_ls.py
│ │ ├── typescript_language_server.py
│ │ ├── vts_language_server.py
│ │ ├── vue_language_server.py
│ │ ├── yaml_language_server.py
│ │ └── zls.py
│ ├── ls_config.py
│ ├── ls_exceptions.py
│ ├── ls_handler.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
│ ├── cache.py
│ ├── subprocess_util.py
│ └── zip.py
├── sync.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
│ │ ├── fortran
│ │ │ └── test_repo
│ │ │ ├── main.f90
│ │ │ └── modules
│ │ │ ├── geometry.f90
│ │ │ └── math_utils.f90
│ │ ├── fsharp
│ │ │ └── test_repo
│ │ │ ├── .gitignore
│ │ │ ├── Calculator.fs
│ │ │ ├── Models
│ │ │ │ └── Person.fs
│ │ │ ├── Program.fs
│ │ │ ├── README.md
│ │ │ └── TestProject.fsproj
│ │ ├── go
│ │ │ └── test_repo
│ │ │ └── main.go
│ │ ├── groovy
│ │ │ └── test_repo
│ │ │ ├── .gitignore
│ │ │ ├── build.gradle
│ │ │ └── src
│ │ │ └── main
│ │ │ └── groovy
│ │ │ └── com
│ │ │ └── example
│ │ │ ├── Main.groovy
│ │ │ ├── Model.groovy
│ │ │ ├── ModelUser.groovy
│ │ │ └── Utils.groovy
│ │ ├── haskell
│ │ │ └── test_repo
│ │ │ ├── app
│ │ │ │ └── Main.hs
│ │ │ ├── haskell-test-repo.cabal
│ │ │ ├── package.yaml
│ │ │ ├── src
│ │ │ │ ├── Calculator.hs
│ │ │ │ └── Helper.hs
│ │ │ └── stack.yaml
│ │ ├── java
│ │ │ └── test_repo
│ │ │ ├── pom.xml
│ │ │ └── src
│ │ │ └── main
│ │ │ └── java
│ │ │ └── test_repo
│ │ │ ├── Main.java
│ │ │ ├── Model.java
│ │ │ ├── ModelUser.java
│ │ │ └── Utils.java
│ │ ├── julia
│ │ │ └── test_repo
│ │ │ ├── lib
│ │ │ │ └── helper.jl
│ │ │ └── main.jl
│ │ ├── 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
│ │ ├── matlab
│ │ │ └── test_repo
│ │ │ ├── Calculator.m
│ │ │ └── main.m
│ │ ├── nix
│ │ │ └── test_repo
│ │ │ ├── .gitignore
│ │ │ ├── default.nix
│ │ │ ├── flake.nix
│ │ │ ├── lib
│ │ │ │ └── utils.nix
│ │ │ ├── modules
│ │ │ │ └── example.nix
│ │ │ └── scripts
│ │ │ └── hello.sh
│ │ ├── pascal
│ │ │ └── test_repo
│ │ │ ├── .gitignore
│ │ │ ├── lib
│ │ │ │ └── helper.pas
│ │ │ └── main.pas
│ │ ├── perl
│ │ │ └── test_repo
│ │ │ ├── helper.pl
│ │ │ └── main.pl
│ │ ├── php
│ │ │ └── test_repo
│ │ │ ├── helper.php
│ │ │ ├── index.php
│ │ │ └── simple_var.php
│ │ ├── powershell
│ │ │ └── test_repo
│ │ │ ├── main.ps1
│ │ │ ├── PowerShellEditorServices.json
│ │ │ └── utils.ps1
│ │ ├── 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
│ │ ├── rego
│ │ │ └── test_repo
│ │ │ ├── policies
│ │ │ │ ├── authz.rego
│ │ │ │ └── validation.rego
│ │ │ └── utils
│ │ │ └── helpers.rego
│ │ ├── 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
│ │ ├── scala
│ │ │ ├── build.sbt
│ │ │ ├── project
│ │ │ │ ├── build.properties
│ │ │ │ ├── metals.sbt
│ │ │ │ └── plugins.sbt
│ │ │ └── src
│ │ │ └── main
│ │ │ └── scala
│ │ │ └── com
│ │ │ └── example
│ │ │ ├── Main.scala
│ │ │ └── Utils.scala
│ │ ├── swift
│ │ │ └── test_repo
│ │ │ ├── Package.swift
│ │ │ └── src
│ │ │ ├── main.swift
│ │ │ └── utils.swift
│ │ ├── terraform
│ │ │ └── test_repo
│ │ │ ├── data.tf
│ │ │ ├── main.tf
│ │ │ ├── outputs.tf
│ │ │ └── variables.tf
│ │ ├── toml
│ │ │ └── test_repo
│ │ │ ├── Cargo.toml
│ │ │ ├── config.toml
│ │ │ └── pyproject.toml
│ │ ├── typescript
│ │ │ └── test_repo
│ │ │ ├── .serena
│ │ │ │ └── project.yml
│ │ │ ├── index.ts
│ │ │ ├── tsconfig.json
│ │ │ ├── use_helper.ts
│ │ │ └── ws_manager.js
│ │ ├── vue
│ │ │ └── test_repo
│ │ │ ├── .gitignore
│ │ │ ├── index.html
│ │ │ ├── package.json
│ │ │ ├── src
│ │ │ │ ├── App.vue
│ │ │ │ ├── components
│ │ │ │ │ ├── CalculatorButton.vue
│ │ │ │ │ ├── CalculatorDisplay.vue
│ │ │ │ │ └── CalculatorInput.vue
│ │ │ │ ├── composables
│ │ │ │ │ ├── useFormatter.ts
│ │ │ │ │ └── useTheme.ts
│ │ │ │ ├── main.ts
│ │ │ │ ├── stores
│ │ │ │ │ └── calculator.ts
│ │ │ │ └── types
│ │ │ │ └── index.ts
│ │ │ ├── tsconfig.json
│ │ │ ├── tsconfig.node.json
│ │ │ └── vite.config.ts
│ │ ├── yaml
│ │ │ └── test_repo
│ │ │ ├── config.yaml
│ │ │ ├── data.yaml
│ │ │ └── services.yml
│ │ └── 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_cli_project_commands.py
│ │ ├── test_edit_marker.py
│ │ ├── test_mcp.py
│ │ ├── test_serena_agent.py
│ │ ├── test_symbol_editing.py
│ │ ├── test_symbol.py
│ │ ├── test_task_executor.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
│ ├── fortran
│ │ ├── __init__.py
│ │ └── test_fortran_basic.py
│ ├── fsharp
│ │ └── test_fsharp_basic.py
│ ├── go
│ │ └── test_go_basic.py
│ ├── groovy
│ │ └── test_groovy_basic.py
│ ├── haskell
│ │ ├── __init__.py
│ │ └── test_haskell_basic.py
│ ├── java
│ │ └── test_java_basic.py
│ ├── julia
│ │ └── test_julia_basic.py
│ ├── kotlin
│ │ └── test_kotlin_basic.py
│ ├── lua
│ │ └── test_lua_basic.py
│ ├── markdown
│ │ ├── __init__.py
│ │ └── test_markdown_basic.py
│ ├── matlab
│ │ ├── __init__.py
│ │ └── test_matlab_basic.py
│ ├── nix
│ │ └── test_nix_basic.py
│ ├── pascal
│ │ ├── __init__.py
│ │ └── test_pascal_basic.py
│ ├── perl
│ │ └── test_perl_basic.py
│ ├── php
│ │ └── test_php_basic.py
│ ├── powershell
│ │ ├── __init__.py
│ │ └── test_powershell_basic.py
│ ├── python
│ │ ├── test_python_basic.py
│ │ ├── test_retrieval_with_ignored_dirs.py
│ │ └── test_symbol_retrieval.py
│ ├── r
│ │ ├── __init__.py
│ │ └── test_r_basic.py
│ ├── rego
│ │ └── test_rego_basic.py
│ ├── ruby
│ │ ├── test_ruby_basic.py
│ │ └── test_ruby_symbol_retrieval.py
│ ├── rust
│ │ ├── test_rust_2024_edition.py
│ │ ├── test_rust_analyzer_detection.py
│ │ └── test_rust_basic.py
│ ├── scala
│ │ └── test_scala_language_server.py
│ ├── swift
│ │ └── test_swift_basic.py
│ ├── terraform
│ │ └── test_terraform_basic.py
│ ├── test_lsp_protocol_handler_server.py
│ ├── toml
│ │ ├── __init__.py
│ │ ├── test_toml_basic.py
│ │ ├── test_toml_edge_cases.py
│ │ ├── test_toml_ignored_dirs.py
│ │ └── test_toml_symbol_retrieval.py
│ ├── typescript
│ │ └── test_typescript_basic.py
│ ├── util
│ │ └── test_zip.py
│ ├── vue
│ │ ├── __init__.py
│ │ ├── test_vue_basic.py
│ │ ├── test_vue_error_cases.py
│ │ ├── test_vue_rename.py
│ │ └── test_vue_symbol_retrieval.py
│ ├── yaml_ls
│ │ ├── __init__.py
│ │ └── test_yaml_basic.py
│ └── zig
│ └── test_zig_basic.py
└── uv.lock
```
# Files
--------------------------------------------------------------------------------
/src/serena/agent.py:
--------------------------------------------------------------------------------
```python
"""
The Serena Model Context Protocol (MCP) Server
"""
import os
import platform
import subprocess
import sys
from collections.abc import Callable
from logging import Logger
from typing import TYPE_CHECKING, Optional, TypeVar
from sensai.util import logging
from sensai.util.logging import LogTime
from interprompt.jinja_template import JinjaTemplate
from serena import serena_version
from serena.analytics import RegisteredTokenCountEstimator, ToolUsageStats
from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode
from serena.config.serena_config import LanguageBackend, SerenaConfig, ToolInclusionDefinition
from serena.dashboard import SerenaDashboardAPI
from serena.ls_manager import LanguageServerManager
from serena.project import Project
from serena.prompt_factory import SerenaPromptFactory
from serena.task_executor import TaskExecutor
from serena.tools import ActivateProjectTool, GetCurrentConfigTool, OpenDashboardTool, ReplaceContentTool, Tool, ToolMarker, ToolRegistry
from serena.util.gui import system_has_usable_display
from serena.util.inspection import iter_subclasses
from serena.util.logging import MemoryLogHandler
from solidlsp.ls_config import Language
if TYPE_CHECKING:
from serena.gui_log_viewer import GuiLogViewer
log = logging.getLogger(__name__)
TTool = TypeVar("TTool", bound="Tool")
T = TypeVar("T")
SUCCESS_RESULT = "OK"
class ProjectNotFoundError(Exception):
pass
class AvailableTools:
"""
Represents the set of available/exposed tools of a SerenaAgent.
"""
def __init__(self, tools: list[Tool]):
"""
:param tools: the list of available tools
"""
self.tools = tools
self.tool_names = [tool.get_name_from_cls() for tool in tools]
self.tool_marker_names = set()
for marker_class in iter_subclasses(ToolMarker):
for tool in tools:
if isinstance(tool, marker_class):
self.tool_marker_names.add(marker_class.__name__)
def __len__(self) -> int:
return len(self.tools)
class ToolSet:
"""
Represents a set of tools by their names.
"""
LEGACY_TOOL_NAME_MAPPING = {"replace_regex": ReplaceContentTool.get_name_from_cls()}
"""
maps legacy tool names to their new names for backward compatibility
"""
def __init__(self, tool_names: set[str]) -> None:
self._tool_names = tool_names
@classmethod
def default(cls) -> "ToolSet":
"""
:return: the default tool set, which contains all tools that are enabled by default
"""
from serena.tools import ToolRegistry
return cls(set(ToolRegistry().get_tool_names_default_enabled()))
def apply(self, *tool_inclusion_definitions: "ToolInclusionDefinition") -> "ToolSet":
"""
Applies one or more tool inclusion definitions to this tool set,
resulting in a new tool set.
:param tool_inclusion_definitions: the definitions to apply
:return: a new tool set with the definitions applied
"""
from serena.tools import ToolRegistry
def get_updated_tool_name(tool_name: str) -> str:
"""Retrieves the updated tool name if the provided tool name is deprecated, logging a warning."""
if tool_name in self.LEGACY_TOOL_NAME_MAPPING:
new_tool_name = self.LEGACY_TOOL_NAME_MAPPING[tool_name]
log.warning("Tool name '%s' is deprecated, please use '%s' instead", tool_name, new_tool_name)
return new_tool_name
return tool_name
registry = ToolRegistry()
tool_names = set(self._tool_names)
for definition in tool_inclusion_definitions:
if definition.is_fixed_tool_set():
tool_names = set()
for fixed_tool in definition.fixed_tools:
fixed_tool = get_updated_tool_name(fixed_tool)
if not registry.is_valid_tool_name(fixed_tool):
raise ValueError(f"Invalid tool name '{fixed_tool}' provided for fixed tool set")
tool_names.add(fixed_tool)
log.info(f"{definition} defined a fixed tool set with {len(tool_names)} tools: {', '.join(tool_names)}")
else:
included_tools = []
excluded_tools = []
for included_tool in definition.included_optional_tools:
included_tool = get_updated_tool_name(included_tool)
if not registry.is_valid_tool_name(included_tool):
raise ValueError(f"Invalid tool name '{included_tool}' provided for inclusion")
if included_tool not in tool_names:
tool_names.add(included_tool)
included_tools.append(included_tool)
for excluded_tool in definition.excluded_tools:
excluded_tool = get_updated_tool_name(excluded_tool)
if not registry.is_valid_tool_name(excluded_tool):
raise ValueError(f"Invalid tool name '{excluded_tool}' provided for exclusion")
if excluded_tool in tool_names:
tool_names.remove(excluded_tool)
excluded_tools.append(excluded_tool)
if included_tools:
log.info(f"{definition} included {len(included_tools)} tools: {', '.join(included_tools)}")
if excluded_tools:
log.info(f"{definition} excluded {len(excluded_tools)} tools: {', '.join(excluded_tools)}")
return ToolSet(tool_names)
def without_editing_tools(self) -> "ToolSet":
"""
:return: a new tool set that excludes all tools that can edit
"""
from serena.tools import ToolRegistry
registry = ToolRegistry()
tool_names = set(self._tool_names)
for tool_name in self._tool_names:
if registry.get_tool_class_by_name(tool_name).can_edit():
tool_names.remove(tool_name)
return ToolSet(tool_names)
def get_tool_names(self) -> set[str]:
"""
Returns the names of the tools that are currently included in the tool set.
"""
return self._tool_names
def includes_name(self, tool_name: str) -> bool:
return tool_name in self._tool_names
class SerenaAgent:
def __init__(
self,
project: str | None = None,
project_activation_callback: Callable[[], None] | None = None,
serena_config: SerenaConfig | None = None,
context: SerenaAgentContext | None = None,
modes: list[SerenaAgentMode] | None = None,
memory_log_handler: MemoryLogHandler | None = None,
):
"""
:param project: the project to load immediately or None to not load any project; may be a path to the project or a name of
an already registered project;
:param project_activation_callback: a callback function to be called when a project is activated.
:param serena_config: the Serena configuration or None to read the configuration from the default location.
:param context: the context in which the agent is operating, None for default context.
The context may adjust prompts, tool availability, and tool descriptions.
:param modes: list of modes in which the agent is operating (they will be combined), None for default modes.
The modes may adjust prompts, tool availability, and tool descriptions.
:param memory_log_handler: a MemoryLogHandler instance from which to read log messages; if None, a new one will be created
if necessary.
"""
# obtain serena configuration using the decoupled factory function
self.serena_config = serena_config or SerenaConfig.from_config_file()
# project-specific instances, which will be initialized upon project activation
self._active_project: Project | None = None
# dashboard URL (set when dashboard is started)
self._dashboard_url: str | None = None
# adjust log level
serena_log_level = self.serena_config.log_level
if Logger.root.level != serena_log_level:
log.info(f"Changing the root logger level to {serena_log_level}")
Logger.root.setLevel(serena_log_level)
def get_memory_log_handler() -> MemoryLogHandler:
nonlocal memory_log_handler
if memory_log_handler is None:
memory_log_handler = MemoryLogHandler(level=serena_log_level)
Logger.root.addHandler(memory_log_handler)
return memory_log_handler
# open GUI log window if enabled
self._gui_log_viewer: Optional["GuiLogViewer"] = None
if self.serena_config.gui_log_window_enabled:
log.info("Opening GUI window")
if platform.system() == "Darwin":
log.warning("GUI log window is not supported on macOS")
else:
# even importing on macOS may fail if tkinter dependencies are unavailable (depends on Python interpreter installation
# which uv used as a base, unfortunately)
from serena.gui_log_viewer import GuiLogViewer
self._gui_log_viewer = GuiLogViewer("dashboard", title="Serena Logs", memory_log_handler=get_memory_log_handler())
self._gui_log_viewer.start()
else:
log.debug("GUI window is disabled")
# set the agent context
if context is None:
context = SerenaAgentContext.load_default()
self._context = context
# instantiate all tool classes
self._all_tools: dict[type[Tool], Tool] = {tool_class: tool_class(self) for tool_class in ToolRegistry().get_all_tool_classes()}
tool_names = [tool.get_name_from_cls() for tool in self._all_tools.values()]
# If GUI log window is enabled, set the tool names for highlighting
if self._gui_log_viewer is not None:
self._gui_log_viewer.set_tool_names(tool_names)
token_count_estimator = RegisteredTokenCountEstimator[self.serena_config.token_count_estimator]
log.info(f"Will record tool usage statistics with token count estimator: {token_count_estimator.name}.")
self._tool_usage_stats = ToolUsageStats(token_count_estimator)
# log fundamental information
log.info(
f"Starting Serena server (version={serena_version()}, process id={os.getpid()}, parent process id={os.getppid()}; "
f"language backend={self.serena_config.language_backend.name})"
)
log.info("Configuration file: %s", self.serena_config.config_file_path)
log.info("Available projects: {}".format(", ".join(self.serena_config.project_names)))
log.info(f"Loaded tools ({len(self._all_tools)}): {', '.join([tool.get_name_from_cls() for tool in self._all_tools.values()])}")
self._check_shell_settings()
# determine the base toolset defining the set of exposed tools (which e.g. the MCP shall see),
# determined by the
# * dashboard availability/opening on launch
# * Serena config,
# * the context (which is fixed for the session)
# * single-project mode reductions (if applicable)
# * JetBrains mode
tool_inclusion_definitions: list[ToolInclusionDefinition] = []
if (
self.serena_config.web_dashboard
and not self.serena_config.web_dashboard_open_on_launch
and not self.serena_config.gui_log_window_enabled
):
tool_inclusion_definitions.append(ToolInclusionDefinition(included_optional_tools=[OpenDashboardTool.get_name_from_cls()]))
tool_inclusion_definitions.append(self.serena_config)
tool_inclusion_definitions.append(self._context)
if self._context.single_project:
tool_inclusion_definitions.extend(self._single_project_context_tool_inclusion_definitions(project))
if self.serena_config.language_backend == LanguageBackend.JETBRAINS:
tool_inclusion_definitions.append(SerenaAgentMode.from_name_internal("jetbrains"))
self._base_tool_set = ToolSet.default().apply(*tool_inclusion_definitions)
self._exposed_tools = AvailableTools([t for t in self._all_tools.values() if self._base_tool_set.includes_name(t.get_name())])
log.info(f"Number of exposed tools: {len(self._exposed_tools)}")
# create executor for starting the language server and running tools in another thread
# This executor is used to achieve linear task execution
self._task_executor = TaskExecutor("SerenaAgentTaskExecutor")
# Initialize the prompt factory
self.prompt_factory = SerenaPromptFactory()
self._project_activation_callback = project_activation_callback
# set the active modes
if modes is None:
modes = SerenaAgentMode.load_default_modes()
self._modes = modes
self._active_tools: dict[type[Tool], Tool] = {}
self._update_active_tools()
# activate a project configuration (if provided or if there is only a single project available)
if project is not None:
try:
self.activate_project_from_path_or_name(project)
except Exception as e:
log.error(f"Error activating project '{project}' at startup: {e}", exc_info=e)
# start the dashboard (web frontend), registering its log handler
# should be the last thing to happen in the initialization since the dashboard
# may access various parts of the agent
if self.serena_config.web_dashboard:
self._dashboard_thread, port = SerenaDashboardAPI(
get_memory_log_handler(), tool_names, agent=self, tool_usage_stats=self._tool_usage_stats
).run_in_thread(host=self.serena_config.web_dashboard_listen_address)
dashboard_host = self.serena_config.web_dashboard_listen_address
if dashboard_host == "0.0.0.0":
dashboard_host = "localhost"
dashboard_url = f"http://{dashboard_host}:{port}/dashboard/index.html"
self._dashboard_url = dashboard_url
log.info("Serena web dashboard started at %s", dashboard_url)
if self.serena_config.web_dashboard_open_on_launch:
self.open_dashboard()
# inform the GUI window (if any)
if self._gui_log_viewer is not None:
self._gui_log_viewer.set_dashboard_url(dashboard_url)
def get_current_tasks(self) -> list[TaskExecutor.TaskInfo]:
"""
Gets the list of tasks currently running or queued for execution.
The function returns a list of thread-safe TaskInfo objects (specifically created for the caller).
:return: the list of tasks in the execution order (running task first)
"""
return self._task_executor.get_current_tasks()
def get_last_executed_task(self) -> TaskExecutor.TaskInfo | None:
"""
Gets the last executed task.
:return: the last executed task info or None if no task has been executed yet
"""
return self._task_executor.get_last_executed_task()
def get_language_server_manager(self) -> LanguageServerManager | None:
if self._active_project is not None:
return self._active_project.language_server_manager
return None
def get_language_server_manager_or_raise(self) -> LanguageServerManager:
language_server_manager = self.get_language_server_manager()
if language_server_manager is None:
raise Exception(
"The language server manager is not initialized, indicating a problem during project activation. "
"Inform the user, telling them to inspect Serena's logs in order to determine the issue. "
"IMPORTANT: Wait for further instructions before you continue!"
)
return language_server_manager
def get_context(self) -> SerenaAgentContext:
return self._context
def get_tool_description_override(self, tool_name: str) -> str | None:
return self._context.tool_description_overrides.get(tool_name, None)
def _check_shell_settings(self) -> None:
# On Windows, Claude Code sets COMSPEC to Git-Bash (often even with a path containing spaces),
# which causes all sorts of trouble, preventing language servers from being launched correctly.
# So we make sure that COMSPEC is unset if it has been set to bash specifically.
if platform.system() == "Windows":
comspec = os.environ.get("COMSPEC", "")
if "bash" in comspec:
os.environ["COMSPEC"] = "" # force use of default shell
log.info("Adjusting COMSPEC environment variable to use the default shell instead of '%s'", comspec)
def _single_project_context_tool_inclusion_definitions(self, project_root_or_name: str | None) -> list[ToolInclusionDefinition]:
"""
In the IDE assistant context, the agent is assumed to work on a single project, and we thus
want to apply that project's tool exclusions/inclusions from the get-go, limiting the set
of tools that will be exposed to the client.
Furthermore, we disable tools that are only relevant for project activation.
So if the project exists, we apply all the aforementioned exclusions.
:param project_root_or_name: the project root path or project name
:return:
"""
tool_inclusion_definitions = []
if project_root_or_name is not None:
# Note: Auto-generation is disabled, because the result must be returned instantaneously
# (project generation could take too much time), so as not to delay MCP server startup
# and provide responses to the client immediately.
project = self.load_project_from_path_or_name(project_root_or_name, autogenerate=False)
if project is not None:
log.info(
"Applying tool inclusion/exclusion definitions for single-project context based on project '%s'", project.project_name
)
tool_inclusion_definitions.append(
ToolInclusionDefinition(
excluded_tools=[ActivateProjectTool.get_name_from_cls(), GetCurrentConfigTool.get_name_from_cls()]
)
)
tool_inclusion_definitions.append(project.project_config)
return tool_inclusion_definitions
def record_tool_usage(self, input_kwargs: dict, tool_result: str | dict, tool: Tool) -> None:
"""
Record the usage of a tool with the given input and output strings if tool usage statistics recording is enabled.
"""
tool_name = tool.get_name()
input_str = str(input_kwargs)
output_str = str(tool_result)
log.debug(f"Recording tool usage for tool '{tool_name}'")
self._tool_usage_stats.record_tool_usage(tool_name, input_str, output_str)
def get_dashboard_url(self) -> str | None:
"""
:return: the URL of the web dashboard, or None if the dashboard is not running
"""
return self._dashboard_url
def open_dashboard(self) -> bool:
"""
Opens the Serena web dashboard in the default web browser.
:return: a message indicating success or failure
"""
if self._dashboard_url is None:
raise Exception("Dashboard is not running.")
if not system_has_usable_display():
log.warning("Not opening the Serena web dashboard because no usable display was detected.")
return False
# Use a subprocess to avoid any output from webbrowser.open being written to stdout
subprocess.Popen(
[sys.executable, "-c", f"import webbrowser; webbrowser.open({self._dashboard_url!r})"],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True, # Detach from parent process
)
return True
def get_project_root(self) -> str:
"""
:return: the root directory of the active project (if any); raises a ValueError if there is no active project
"""
project = self.get_active_project()
if project is None:
raise ValueError("Cannot get project root if no project is active.")
return project.project_root
def get_exposed_tool_instances(self) -> list["Tool"]:
"""
:return: the tool instances which are exposed (e.g. to the MCP client).
Note that the set of exposed tools is fixed for the session, as
clients don't react to changes in the set of tools, so this is the superset
of tools that can be offered during the session.
If a client should attempt to use a tool that is dynamically disabled
(e.g. because a project is activated that disables it), it will receive an error.
"""
return list(self._exposed_tools.tools)
def get_active_project(self) -> Project | None:
"""
:return: the active project or None if no project is active
"""
return self._active_project
def get_active_project_or_raise(self) -> Project:
"""
:return: the active project or raises an exception if no project is active
"""
project = self.get_active_project()
if project is None:
raise ValueError("No active project. Please activate a project first.")
return project
def set_modes(self, modes: list[SerenaAgentMode]) -> None:
"""
Set the current mode configurations.
:param modes: List of mode names or paths to use
"""
self._modes = modes
self._update_active_tools()
log.info(f"Set modes to {[mode.name for mode in modes]}")
def get_active_modes(self) -> list[SerenaAgentMode]:
"""
:return: the list of active modes
"""
return list(self._modes)
def _format_prompt(self, prompt_template: str) -> str:
template = JinjaTemplate(prompt_template)
return template.render(available_tools=self._exposed_tools.tool_names, available_markers=self._exposed_tools.tool_marker_names)
def create_system_prompt(self) -> str:
available_markers = self._exposed_tools.tool_marker_names
log.info("Generating system prompt with available_tools=(see exposed tools), available_markers=%s", available_markers)
system_prompt = self.prompt_factory.create_system_prompt(
context_system_prompt=self._format_prompt(self._context.prompt),
mode_system_prompts=[self._format_prompt(mode.prompt) for mode in self._modes],
available_tools=self._exposed_tools.tool_names,
available_markers=available_markers,
)
# If a project is active at startup, append its activation message
if self._active_project is not None:
system_prompt += "\n\n" + self._active_project.get_activation_message()
log.info("System prompt:\n%s", system_prompt)
return system_prompt
def _update_active_tools(self) -> None:
"""
Update the active tools based on enabled modes and the active project.
The base tool set already takes the Serena configuration and the context into account
(as well as any internal modes that are not handled dynamically, such as JetBrains mode).
"""
tool_set = self._base_tool_set.apply(*self._modes)
if self._active_project is not None:
tool_set = tool_set.apply(self._active_project.project_config)
if self._active_project.project_config.read_only:
tool_set = tool_set.without_editing_tools()
self._active_tools = {
tool_class: tool_instance
for tool_class, tool_instance in self._all_tools.items()
if tool_set.includes_name(tool_instance.get_name())
}
log.info(f"Active tools ({len(self._active_tools)}): {', '.join(self.get_active_tool_names())}")
def issue_task(
self, task: Callable[[], T], name: str | None = None, logged: bool = True, timeout: float | None = None
) -> TaskExecutor.Task[T]:
"""
Issue a task to the executor for asynchronous execution.
It is ensured that tasks are executed in the order they are issued, one after another.
:param task: the task to execute
:param name: the name of the task for logging purposes; if None, use the task function's name
:param logged: whether to log management of the task; if False, only errors will be logged
:param timeout: the maximum time to wait for task completion in seconds, or None to wait indefinitely
:return: the task object, through which the task's future result can be accessed
"""
return self._task_executor.issue_task(task, name=name, logged=logged, timeout=timeout)
def execute_task(self, task: Callable[[], T], name: str | None = None, logged: bool = True, timeout: float | None = None) -> T:
"""
Executes the given task synchronously via the agent's task executor.
This is useful for tasks that need to be executed immediately and whose results are needed right away.
:param task: the task to execute
:param name: the name of the task for logging purposes; if None, use the task function's name
:param logged: whether to log management of the task; if False, only errors will be logged
:param timeout: the maximum time to wait for task completion in seconds, or None to wait indefinitely
:return: the result of the task execution
"""
return self._task_executor.execute_task(task, name=name, logged=logged, timeout=timeout)
def is_using_language_server(self) -> bool:
"""
:return: whether this agent uses language server-based code analysis
"""
return self.serena_config.language_backend == LanguageBackend.LSP
def _activate_project(self, project: Project) -> None:
log.info(f"Activating {project.project_name} at {project.project_root}")
self._active_project = project
self._update_active_tools()
def init_language_server_manager() -> None:
# start the language server
with LogTime("Language server initialization", logger=log):
self.reset_language_server_manager()
# initialize the language server in the background (if in language server mode)
if self.is_using_language_server():
self.issue_task(init_language_server_manager)
if self._project_activation_callback is not None:
self._project_activation_callback()
def load_project_from_path_or_name(self, project_root_or_name: str, autogenerate: bool) -> Project | None:
"""
Get a project instance from a path or a name.
:param project_root_or_name: the path to the project root or the name of the project
:param autogenerate: whether to autogenerate the project for the case where first argument is a directory
which does not yet contain a Serena project configuration file
:return: the project instance if it was found/could be created, None otherwise
"""
project_instance: Project | None = self.serena_config.get_project(project_root_or_name)
if project_instance is not None:
log.info(f"Found registered project '{project_instance.project_name}' at path {project_instance.project_root}")
elif autogenerate and os.path.isdir(project_root_or_name):
project_instance = self.serena_config.add_project_from_path(project_root_or_name)
log.info(f"Added new project {project_instance.project_name} for path {project_instance.project_root}")
return project_instance
def activate_project_from_path_or_name(self, project_root_or_name: str) -> Project:
"""
Activate a project from a path or a name.
If the project was already registered, it will just be activated.
If the argument is a path at which no Serena project previously existed, the project will be created beforehand.
Raises ProjectNotFoundError if the project could neither be found nor created.
:return: a tuple of the project instance and a Boolean indicating whether the project was newly
created
"""
project_instance: Project | None = self.load_project_from_path_or_name(project_root_or_name, autogenerate=True)
if project_instance is None:
raise ProjectNotFoundError(
f"Project '{project_root_or_name}' not found: Not a valid project name or directory. "
f"Existing project names: {self.serena_config.project_names}"
)
self._activate_project(project_instance)
return project_instance
def get_active_tool_classes(self) -> list[type["Tool"]]:
"""
:return: the list of active tool classes for the current project
"""
return list(self._active_tools.keys())
def get_active_tool_names(self) -> list[str]:
"""
:return: the list of names of the active tools for the current project
"""
return sorted([tool.get_name_from_cls() for tool in self.get_active_tool_classes()])
def tool_is_active(self, tool_class: type["Tool"] | str) -> bool:
"""
:param tool_class: the class or name of the tool to check
:return: True if the tool is active, False otherwise
"""
if isinstance(tool_class, str):
return tool_class in self.get_active_tool_names()
else:
return tool_class in self.get_active_tool_classes()
def get_current_config_overview(self) -> str:
"""
:return: a string overview of the current configuration, including the active and available configuration options
"""
result_str = "Current configuration:\n"
result_str += f"Serena version: {serena_version()}\n"
result_str += f"Loglevel: {self.serena_config.log_level}, trace_lsp_communication={self.serena_config.trace_lsp_communication}\n"
if self._active_project is not None:
result_str += f"Active project: {self._active_project.project_name}\n"
else:
result_str += "No active project\n"
result_str += "Available projects:\n" + "\n".join(list(self.serena_config.project_names)) + "\n"
result_str += f"Active context: {self._context.name}\n"
# Active modes
active_mode_names = [mode.name for mode in self.get_active_modes()]
result_str += "Active modes: {}\n".format(", ".join(active_mode_names)) + "\n"
# Available but not active modes
all_available_modes = SerenaAgentMode.list_registered_mode_names()
inactive_modes = [mode for mode in all_available_modes if mode not in active_mode_names]
if inactive_modes:
result_str += "Available but not active modes: {}\n".format(", ".join(inactive_modes)) + "\n"
# Active tools
result_str += "Active tools (after all exclusions from the project, context, and modes):\n"
active_tool_names = self.get_active_tool_names()
# print the tool names in chunks
chunk_size = 4
for i in range(0, len(active_tool_names), chunk_size):
chunk = active_tool_names[i : i + chunk_size]
result_str += " " + ", ".join(chunk) + "\n"
# Available but not active tools
all_tool_names = sorted([tool.get_name_from_cls() for tool in self._all_tools.values()])
inactive_tool_names = [tool for tool in all_tool_names if tool not in active_tool_names]
if inactive_tool_names:
result_str += "Available but not active tools:\n"
for i in range(0, len(inactive_tool_names), chunk_size):
chunk = inactive_tool_names[i : i + chunk_size]
result_str += " " + ", ".join(chunk) + "\n"
return result_str
def reset_language_server_manager(self) -> None:
"""
Starts/resets the language server manager for the current project
"""
tool_timeout = self.serena_config.tool_timeout
if tool_timeout is None or tool_timeout < 0:
ls_timeout = None
else:
if tool_timeout < 10:
raise ValueError(f"Tool timeout must be at least 10 seconds, but is {tool_timeout} seconds")
ls_timeout = tool_timeout - 5 # the LS timeout is for a single call, it should be smaller than the tool timeout
# instantiate and start the necessary language servers
self.get_active_project_or_raise().create_language_server_manager(
log_level=self.serena_config.log_level,
ls_timeout=ls_timeout,
trace_lsp_communication=self.serena_config.trace_lsp_communication,
ls_specific_settings=self.serena_config.ls_specific_settings,
)
def add_language(self, language: Language) -> None:
"""
Adds a new language to the active project, spawning the respective language server and updating the project configuration.
The addition is scheduled via the agent's task executor and executed synchronously, i.e. the method returns
when the addition is complete.
:param language: the language to add
"""
self.execute_task(lambda: self.get_active_project_or_raise().add_language(language), name=f"AddLanguage:{language.value}")
def remove_language(self, language: Language) -> None:
"""
Removes a language from the active project, shutting down the respective language server and updating the project configuration.
The removal is scheduled via the agent's task executor and executed asynchronously.
:param language: the language to remove
"""
self.issue_task(lambda: self.get_active_project_or_raise().remove_language(language), name=f"RemoveLanguage:{language.value}")
def get_tool(self, tool_class: type[TTool]) -> TTool:
return self._all_tools[tool_class] # type: ignore
def print_tool_overview(self) -> None:
ToolRegistry().print_tool_overview(self._active_tools.values())
def __del__(self) -> None:
self.shutdown()
def shutdown(self, timeout: float = 2.0) -> None:
"""
Shuts down the agent, freeing resources and stopping background tasks.
"""
if not hasattr(self, "_is_initialized"):
return
log.info("SerenaAgent is shutting down ...")
if self._active_project is not None:
self._active_project.shutdown(timeout=timeout)
self._active_project = None
if self._gui_log_viewer:
log.info("Stopping the GUI log window ...")
self._gui_log_viewer.stop()
self._gui_log_viewer = None
def get_tool_by_name(self, tool_name: str) -> Tool:
tool_class = ToolRegistry().get_tool_class_by_name(tool_name)
return self.get_tool(tool_class)
```
--------------------------------------------------------------------------------
/src/solidlsp/language_servers/vue_language_server.py:
--------------------------------------------------------------------------------
```python
"""
Vue Language Server implementation using @vue/language-server (Volar) with companion TypeScript LS.
Operates in hybrid mode: Vue LS handles .vue files, TypeScript LS handles .ts/.js files.
"""
import logging
import os
import pathlib
import shutil
import threading
from pathlib import Path
from time import sleep
from typing import Any
from overrides import override
from solidlsp import ls_types
from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection
from solidlsp.language_servers.typescript_language_server import (
TypeScriptLanguageServer,
prefer_non_node_modules_definition,
)
from solidlsp.ls import LSPFileBuffer, SolidLanguageServer
from solidlsp.ls_config import Language, LanguageServerConfig
from solidlsp.ls_exceptions import SolidLSPException
from solidlsp.ls_types import Location
from solidlsp.ls_utils import PathUtils
from solidlsp.lsp_protocol_handler import lsp_types
from solidlsp.lsp_protocol_handler.lsp_types import DocumentSymbol, ExecuteCommandParams, InitializeParams, SymbolInformation
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings
log = logging.getLogger(__name__)
class VueTypeScriptServer(TypeScriptLanguageServer):
"""TypeScript LS configured with @vue/typescript-plugin for Vue file support."""
_pending_ts_ls_executable: list[str] | None = None
@classmethod
@override
def get_language_enum_instance(cls) -> Language:
"""Return TYPESCRIPT since this is a TypeScript language server variant.
Note: VueTypeScriptServer is a companion server that uses TypeScript's language server
with the Vue TypeScript plugin. It reports as TYPESCRIPT to maintain compatibility
with the TypeScript language server infrastructure.
"""
return Language.TYPESCRIPT
@classmethod
@override
def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> list[str]:
if cls._pending_ts_ls_executable is not None:
return cls._pending_ts_ls_executable
return ["typescript-language-server", "--stdio"]
@override
def _get_language_id_for_file(self, relative_file_path: str) -> str:
"""Return the correct language ID for files.
Vue files must be opened with language ID "vue" for the @vue/typescript-plugin
to process them correctly. The plugin is configured with "languages": ["vue"]
in the initialization options.
"""
ext = os.path.splitext(relative_file_path)[1].lower()
if ext == ".vue":
return "vue"
elif ext in (".ts", ".tsx", ".mts", ".cts"):
return "typescript"
elif ext in (".js", ".jsx", ".mjs", ".cjs"):
return "javascript"
else:
return "typescript"
def __init__(
self,
config: LanguageServerConfig,
repository_root_path: str,
solidlsp_settings: SolidLSPSettings,
vue_plugin_path: str,
tsdk_path: str,
ts_ls_executable_path: list[str],
):
self._vue_plugin_path = vue_plugin_path
self._custom_tsdk_path = tsdk_path
VueTypeScriptServer._pending_ts_ls_executable = ts_ls_executable_path
super().__init__(config, repository_root_path, solidlsp_settings)
VueTypeScriptServer._pending_ts_ls_executable = None
@override
def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams:
params = super()._get_initialize_params(repository_absolute_path)
params["initializationOptions"] = {
"plugins": [
{
"name": "@vue/typescript-plugin",
"location": self._vue_plugin_path,
"languages": ["vue"],
}
],
"tsserver": {
"path": self._custom_tsdk_path,
},
}
if "workspace" in params["capabilities"]:
params["capabilities"]["workspace"]["executeCommand"] = {"dynamicRegistration": True}
return params
@override
def _start_server(self) -> None:
def workspace_configuration_handler(params: dict) -> list:
items = params.get("items", [])
return [{} for _ in items]
self.server.on_request("workspace/configuration", workspace_configuration_handler)
super()._start_server()
class VueLanguageServer(SolidLanguageServer):
"""
Language server for Vue Single File Components using @vue/language-server (Volar) with companion TypeScript LS.
You can pass the following entries in ls_specific_settings["vue"]:
- vue_language_server_version: Version of @vue/language-server to install (default: "3.1.5")
Note: TypeScript versions are configured via ls_specific_settings["typescript"]:
- typescript_version: Version of TypeScript to install (default: "5.9.3")
- typescript_language_server_version: Version of typescript-language-server to install (default: "5.1.3")
"""
TS_SERVER_READY_TIMEOUT = 5.0
VUE_SERVER_READY_TIMEOUT = 3.0
# Windows requires more time due to slower I/O and process operations.
VUE_INDEXING_WAIT_TIME = 4.0 if os.name == "nt" else 2.0
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
vue_lsp_executable_path, self.tsdk_path, self._ts_ls_cmd = self._setup_runtime_dependencies(config, solidlsp_settings)
self._vue_ls_dir = os.path.join(self.ls_resources_dir(solidlsp_settings), "vue-lsp")
super().__init__(
config,
repository_root_path,
ProcessLaunchInfo(cmd=vue_lsp_executable_path, cwd=repository_root_path),
"vue",
solidlsp_settings,
)
self.server_ready = threading.Event()
self.initialize_searcher_command_available = threading.Event()
self._ts_server: VueTypeScriptServer | None = None
self._ts_server_started = False
self._vue_files_indexed = False
self._indexed_vue_file_uris: list[str] = []
@override
def is_ignored_dirname(self, dirname: str) -> bool:
return super().is_ignored_dirname(dirname) or dirname in [
"node_modules",
"dist",
"build",
"coverage",
".nuxt",
".output",
]
@override
def _get_language_id_for_file(self, relative_file_path: str) -> str:
ext = os.path.splitext(relative_file_path)[1].lower()
if ext == ".vue":
return "vue"
elif ext in (".ts", ".tsx", ".mts", ".cts"):
return "typescript"
elif ext in (".js", ".jsx", ".mjs", ".cjs"):
return "javascript"
else:
return "vue"
def _is_typescript_file(self, file_path: str) -> bool:
ext = os.path.splitext(file_path)[1].lower()
return ext in (".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs")
def _find_all_vue_files(self) -> list[str]:
vue_files = []
repo_path = Path(self.repository_root_path)
for vue_file in repo_path.rglob("*.vue"):
try:
relative_path = str(vue_file.relative_to(repo_path))
if "node_modules" not in relative_path and not relative_path.startswith("."):
vue_files.append(relative_path)
except Exception as e:
log.debug(f"Error processing Vue file {vue_file}: {e}")
return vue_files
def _ensure_vue_files_indexed_on_ts_server(self) -> None:
if self._vue_files_indexed:
return
assert self._ts_server is not None
log.info("Indexing .vue files on TypeScript server for cross-file references")
vue_files = self._find_all_vue_files()
log.debug(f"Found {len(vue_files)} .vue files to index")
for vue_file in vue_files:
try:
with self._ts_server.open_file(vue_file) as file_buffer:
file_buffer.ref_count += 1
self._indexed_vue_file_uris.append(file_buffer.uri)
except Exception as e:
log.debug(f"Failed to open {vue_file} on TS server: {e}")
self._vue_files_indexed = True
log.info("Vue file indexing on TypeScript server complete")
sleep(self._get_vue_indexing_wait_time())
log.debug("Wait period after Vue file indexing complete")
def _get_vue_indexing_wait_time(self) -> float:
return self.VUE_INDEXING_WAIT_TIME
def _send_references_request(self, relative_file_path: str, line: int, column: int) -> list[lsp_types.Location] | None:
uri = PathUtils.path_to_uri(os.path.join(self.repository_root_path, relative_file_path))
request_params = {
"textDocument": {"uri": uri},
"position": {"line": line, "character": column},
"context": {"includeDeclaration": False},
}
return self.server.send.references(request_params) # type: ignore[arg-type]
def _send_ts_references_request(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]:
assert self._ts_server is not None
uri = PathUtils.path_to_uri(os.path.join(self.repository_root_path, relative_file_path))
request_params = {
"textDocument": {"uri": uri},
"position": {"line": line, "character": column},
"context": {"includeDeclaration": True},
}
with self._ts_server.open_file(relative_file_path):
response = self._ts_server.handler.send.references(request_params) # type: ignore[arg-type]
result: list[ls_types.Location] = []
if response is not None:
for item in response:
abs_path = PathUtils.uri_to_path(item["uri"])
if not Path(abs_path).is_relative_to(self.repository_root_path):
log.debug(f"Found reference outside repository: {abs_path}, skipping")
continue
rel_path = Path(abs_path).relative_to(self.repository_root_path)
if self.is_ignored_path(str(rel_path)):
log.debug(f"Ignoring reference in {rel_path}")
continue
new_item: dict = {}
new_item.update(item) # type: ignore[arg-type]
new_item["absolutePath"] = str(abs_path)
new_item["relativePath"] = str(rel_path)
result.append(ls_types.Location(**new_item)) # type: ignore
return result
def request_file_references(self, relative_file_path: str) -> list:
if not self.server_started:
log.error("request_file_references called before Language Server started")
raise SolidLSPException("Language Server not started")
absolute_file_path = os.path.join(self.repository_root_path, relative_file_path)
uri = PathUtils.path_to_uri(absolute_file_path)
request_params = {"textDocument": {"uri": uri}}
log.info(f"Sending volar/client/findFileReference request for {relative_file_path}")
log.info(f"Request URI: {uri}")
log.info(f"Request params: {request_params}")
try:
with self.open_file(relative_file_path):
log.debug(f"Sending volar/client/findFileReference for {relative_file_path}")
log.debug(f"Request params: {request_params}")
response = self.server.send_request("volar/client/findFileReference", request_params)
log.debug(f"Received response type: {type(response)}")
log.info(f"Received file references response: {response}")
log.info(f"Response type: {type(response)}")
if response is None:
log.debug(f"No file references found for {relative_file_path}")
return []
# Response should be an array of Location objects
if not isinstance(response, list):
log.warning(f"Unexpected response format from volar/client/findFileReference: {type(response)}")
return []
ret: list[Location] = []
for item in response:
if not isinstance(item, dict) or "uri" not in item:
log.debug(f"Skipping invalid location item: {item}")
continue
abs_path = PathUtils.uri_to_path(item["uri"]) # type: ignore[arg-type]
if not Path(abs_path).is_relative_to(self.repository_root_path):
log.warning(f"Found file reference outside repository: {abs_path}, skipping")
continue
rel_path = Path(abs_path).relative_to(self.repository_root_path)
if self.is_ignored_path(str(rel_path)):
log.debug(f"Ignoring file reference in {rel_path}")
continue
new_item: dict = {}
new_item.update(item) # type: ignore[arg-type]
new_item["absolutePath"] = str(abs_path)
new_item["relativePath"] = str(rel_path)
ret.append(Location(**new_item)) # type: ignore
log.debug(f"Found {len(ret)} file references for {relative_file_path}")
return ret
except Exception as e:
log.warning(f"Error requesting file references for {relative_file_path}: {e}")
return []
@override
def request_references(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]:
if not self.server_started:
log.error("request_references called before Language Server started")
raise SolidLSPException("Language Server not started")
if not self._has_waited_for_cross_file_references:
sleep(self._get_wait_time_for_cross_file_referencing())
self._has_waited_for_cross_file_references = True
self._ensure_vue_files_indexed_on_ts_server()
symbol_refs = self._send_ts_references_request(relative_file_path, line=line, column=column)
if relative_file_path.endswith(".vue"):
log.info(f"Attempting to find file-level references for Vue component {relative_file_path}")
file_refs = self.request_file_references(relative_file_path)
log.info(f"file_refs result: {len(file_refs)} references found")
seen = set()
for ref in symbol_refs:
key = (ref["uri"], ref["range"]["start"]["line"], ref["range"]["start"]["character"])
seen.add(key)
for file_ref in file_refs:
key = (file_ref["uri"], file_ref["range"]["start"]["line"], file_ref["range"]["start"]["character"])
if key not in seen:
symbol_refs.append(file_ref)
seen.add(key)
log.info(f"Total references for {relative_file_path}: {len(symbol_refs)} (symbol refs + file refs, deduplicated)")
return symbol_refs
@override
def request_definition(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]:
if not self.server_started:
log.error("request_definition called before Language Server started")
raise SolidLSPException("Language Server not started")
assert self._ts_server is not None
with self._ts_server.open_file(relative_file_path):
return self._ts_server.request_definition(relative_file_path, line, column)
@override
def request_rename_symbol_edit(self, relative_file_path: str, line: int, column: int, new_name: str) -> ls_types.WorkspaceEdit | None:
if not self.server_started:
log.error("request_rename_symbol_edit called before Language Server started")
raise SolidLSPException("Language Server not started")
assert self._ts_server is not None
with self._ts_server.open_file(relative_file_path):
return self._ts_server.request_rename_symbol_edit(relative_file_path, line, column, new_name)
@classmethod
def _setup_runtime_dependencies(
cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings
) -> tuple[list[str], str, list[str]]:
is_node_installed = shutil.which("node") is not None
assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again."
is_npm_installed = shutil.which("npm") is not None
assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again."
# Get TypeScript version settings from TypeScript language server settings
typescript_config = solidlsp_settings.get_ls_specific_settings(Language.TYPESCRIPT)
typescript_version = typescript_config.get("typescript_version", "5.9.3")
typescript_language_server_version = typescript_config.get("typescript_language_server_version", "5.1.3")
vue_config = solidlsp_settings.get_ls_specific_settings(Language.VUE)
vue_language_server_version = vue_config.get("vue_language_server_version", "3.1.5")
deps = RuntimeDependencyCollection(
[
RuntimeDependency(
id="vue-language-server",
description="Vue language server package (Volar)",
command=["npm", "install", "--prefix", "./", f"@vue/language-server@{vue_language_server_version}"],
platform_id="any",
),
RuntimeDependency(
id="typescript",
description="TypeScript (required for tsdk)",
command=["npm", "install", "--prefix", "./", f"typescript@{typescript_version}"],
platform_id="any",
),
RuntimeDependency(
id="typescript-language-server",
description="TypeScript language server (for Vue LS 3.x tsserver forwarding)",
command=[
"npm",
"install",
"--prefix",
"./",
f"typescript-language-server@{typescript_language_server_version}",
],
platform_id="any",
),
]
)
vue_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "vue-lsp")
vue_executable_path = os.path.join(vue_ls_dir, "node_modules", ".bin", "vue-language-server")
ts_ls_executable_path = os.path.join(vue_ls_dir, "node_modules", ".bin", "typescript-language-server")
if os.name == "nt":
vue_executable_path += ".cmd"
ts_ls_executable_path += ".cmd"
tsdk_path = os.path.join(vue_ls_dir, "node_modules", "typescript", "lib")
# Check if installation is needed based on executables AND version
version_file = os.path.join(vue_ls_dir, ".installed_version")
expected_version = f"{vue_language_server_version}_{typescript_version}_{typescript_language_server_version}"
needs_install = False
if not os.path.exists(vue_executable_path) or not os.path.exists(ts_ls_executable_path):
log.info("Vue/TypeScript Language Server executables not found.")
needs_install = True
elif os.path.exists(version_file):
with open(version_file) as f:
installed_version = f.read().strip()
if installed_version != expected_version:
log.info(
f"Vue Language Server version mismatch: installed={installed_version}, expected={expected_version}. Reinstalling..."
)
needs_install = True
else:
# No version file exists, assume old installation needs refresh
log.info("Vue Language Server version file not found. Reinstalling to ensure correct version...")
needs_install = True
if needs_install:
log.info("Installing Vue/TypeScript Language Server dependencies...")
deps.install(vue_ls_dir)
# Write version marker file
with open(version_file, "w") as f:
f.write(expected_version)
log.info("Vue language server dependencies installed successfully")
if not os.path.exists(vue_executable_path):
raise FileNotFoundError(
f"vue-language-server executable not found at {vue_executable_path}, something went wrong with the installation."
)
if not os.path.exists(ts_ls_executable_path):
raise FileNotFoundError(
f"typescript-language-server executable not found at {ts_ls_executable_path}, something went wrong with the installation."
)
return [vue_executable_path, "--stdio"], tsdk_path, [ts_ls_executable_path, "--stdio"]
def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams:
root_uri = pathlib.Path(repository_absolute_path).as_uri()
initialize_params = {
"locale": "en",
"capabilities": {
"textDocument": {
"synchronization": {"didSave": True, "dynamicRegistration": True},
"completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}},
"definition": {"dynamicRegistration": True, "linkSupport": True},
"references": {"dynamicRegistration": True},
"documentSymbol": {
"dynamicRegistration": True,
"hierarchicalDocumentSymbolSupport": True,
"symbolKind": {"valueSet": list(range(1, 27))},
},
"hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]},
"signatureHelp": {"dynamicRegistration": True},
"codeAction": {"dynamicRegistration": True},
"rename": {"dynamicRegistration": True, "prepareSupport": True},
},
"workspace": {
"workspaceFolders": True,
"didChangeConfiguration": {"dynamicRegistration": True},
"symbol": {"dynamicRegistration": True},
},
},
"processId": os.getpid(),
"rootPath": repository_absolute_path,
"rootUri": root_uri,
"workspaceFolders": [
{
"uri": root_uri,
"name": os.path.basename(repository_absolute_path),
}
],
"initializationOptions": {
"vue": {
"hybridMode": True,
},
"typescript": {
"tsdk": self.tsdk_path,
},
},
}
return initialize_params # type: ignore
def _start_typescript_server(self) -> None:
try:
vue_ts_plugin_path = os.path.join(self._vue_ls_dir, "node_modules", "@vue", "typescript-plugin")
ts_config = LanguageServerConfig(
code_language=Language.TYPESCRIPT,
trace_lsp_communication=False,
)
log.info("Creating companion VueTypeScriptServer")
self._ts_server = VueTypeScriptServer(
config=ts_config,
repository_root_path=self.repository_root_path,
solidlsp_settings=self._solidlsp_settings,
vue_plugin_path=vue_ts_plugin_path,
tsdk_path=self.tsdk_path,
ts_ls_executable_path=self._ts_ls_cmd,
)
log.info("Starting companion TypeScript server")
self._ts_server.start()
log.info("Waiting for companion TypeScript server to be ready...")
if not self._ts_server.server_ready.wait(timeout=self.TS_SERVER_READY_TIMEOUT):
log.warning(
f"Timeout waiting for companion TypeScript server to be ready after {self.TS_SERVER_READY_TIMEOUT} seconds, proceeding anyway"
)
self._ts_server.server_ready.set()
self._ts_server_started = True
log.info("Companion TypeScript server ready")
except Exception as e:
log.error(f"Error starting TypeScript server: {e}")
self._ts_server = None
self._ts_server_started = False
raise
def _forward_tsserver_request(self, method: str, params: dict) -> Any:
if self._ts_server is None:
log.error("Cannot forward tsserver request - TypeScript server not started")
return None
try:
execute_params: ExecuteCommandParams = {
"command": "typescript.tsserverRequest",
"arguments": [method, params, {"isAsync": True, "lowPriority": True}],
}
result = self._ts_server.handler.send.execute_command(execute_params)
log.debug(f"TypeScript server raw response for {method}: {result}")
if isinstance(result, dict) and "body" in result:
return result["body"]
return result
except Exception as e:
log.error(f"Error forwarding tsserver request {method}: {e}")
return None
def _cleanup_indexed_vue_files(self) -> None:
if not self._indexed_vue_file_uris or self._ts_server is None:
return
log.debug(f"Cleaning up {len(self._indexed_vue_file_uris)} indexed Vue files")
for uri in self._indexed_vue_file_uris:
try:
if uri in self._ts_server.open_file_buffers:
file_buffer = self._ts_server.open_file_buffers[uri]
file_buffer.ref_count -= 1
if file_buffer.ref_count == 0:
self._ts_server.server.notify.did_close_text_document({"textDocument": {"uri": uri}})
del self._ts_server.open_file_buffers[uri]
log.debug(f"Closed indexed Vue file: {uri}")
except Exception as e:
log.debug(f"Error closing indexed Vue file {uri}: {e}")
self._indexed_vue_file_uris.clear()
def _stop_typescript_server(self) -> None:
if self._ts_server is not None:
try:
log.info("Stopping companion TypeScript server")
self._ts_server.stop()
except Exception as e:
log.warning(f"Error stopping TypeScript server: {e}")
finally:
self._ts_server = None
self._ts_server_started = False
@override
def _start_server(self) -> None:
self._start_typescript_server()
def register_capability_handler(params: dict) -> None:
assert "registrations" in params
for registration in params["registrations"]:
if registration["method"] == "workspace/executeCommand":
self.initialize_searcher_command_available.set()
return
def configuration_handler(params: dict) -> list:
items = params.get("items", [])
return [{} for _ in items]
def do_nothing(params: dict) -> None:
return
def window_log_message(msg: dict) -> None:
log.info(f"LSP: window/logMessage: {msg}")
message_text = msg.get("message", "")
if "initialized" in message_text.lower() or "ready" in message_text.lower():
log.info("Vue language server ready signal detected")
self.server_ready.set()
self.completions_available.set()
def tsserver_request_notification_handler(params: list) -> None:
try:
if params and len(params) > 0 and len(params[0]) >= 2:
request_id = params[0][0]
method = params[0][1]
method_params = params[0][2] if len(params[0]) > 2 else {}
log.debug(f"Received tsserver/request: id={request_id}, method={method}")
if method == "_vue:projectInfo":
file_path = method_params.get("file", "")
tsconfig_path = self._find_tsconfig_for_file(file_path)
result = {"configFileName": tsconfig_path} if tsconfig_path else None
response = [[request_id, result]]
self.server.notify.send_notification("tsserver/response", response)
log.debug(f"Sent tsserver/response for projectInfo: {tsconfig_path}")
else:
result = self._forward_tsserver_request(method, method_params)
response = [[request_id, result]]
self.server.notify.send_notification("tsserver/response", response)
log.debug(f"Forwarded tsserver/response for {method}: {result}")
else:
log.warning(f"Unexpected tsserver/request params format: {params}")
except Exception as e:
log.error(f"Error handling tsserver/request: {e}")
self.server.on_request("client/registerCapability", register_capability_handler)
self.server.on_request("workspace/configuration", configuration_handler)
self.server.on_notification("tsserver/request", tsserver_request_notification_handler)
self.server.on_notification("window/logMessage", window_log_message)
self.server.on_notification("$/progress", do_nothing)
self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
log.info("Starting Vue server process")
self.server.start()
initialize_params = self._get_initialize_params(self.repository_root_path)
log.info("Sending initialize request from LSP client to LSP server and awaiting response")
init_response = self.server.send.initialize(initialize_params)
log.debug(f"Received initialize response from Vue server: {init_response}")
assert init_response["capabilities"]["textDocumentSync"] in [1, 2]
self.server.notify.initialized({})
log.info("Waiting for Vue language server to be ready...")
if not self.server_ready.wait(timeout=self.VUE_SERVER_READY_TIMEOUT):
log.info("Timeout waiting for Vue server ready signal, proceeding anyway")
self.server_ready.set()
self.completions_available.set()
else:
log.info("Vue server initialization complete")
def _find_tsconfig_for_file(self, file_path: str) -> str | None:
if not file_path:
tsconfig_path = os.path.join(self.repository_root_path, "tsconfig.json")
return tsconfig_path if os.path.exists(tsconfig_path) else None
current_dir = os.path.dirname(file_path)
repo_root = os.path.abspath(self.repository_root_path)
while current_dir and current_dir.startswith(repo_root):
tsconfig_path = os.path.join(current_dir, "tsconfig.json")
if os.path.exists(tsconfig_path):
return tsconfig_path
parent = os.path.dirname(current_dir)
if parent == current_dir:
break
current_dir = parent
tsconfig_path = os.path.join(repo_root, "tsconfig.json")
return tsconfig_path if os.path.exists(tsconfig_path) else None
@override
def _get_wait_time_for_cross_file_referencing(self) -> float:
return 5.0
@override
def stop(self, shutdown_timeout: float = 5.0) -> None:
self._cleanup_indexed_vue_files()
self._stop_typescript_server()
super().stop(shutdown_timeout)
@override
def _get_preferred_definition(self, definitions: list[ls_types.Location]) -> ls_types.Location:
return prefer_non_node_modules_definition(definitions)
@override
def _request_document_symbols(
self, relative_file_path: str, file_data: LSPFileBuffer | None
) -> list[SymbolInformation] | list[DocumentSymbol] | None:
"""
Override to filter out shorthand property references in Vue files.
In Vue, when using shorthand syntax in defineExpose like `defineExpose({ pressCount })`,
the Vue LSP returns both:
- The Variable definition (e.g., `const pressCount = ref(0)`)
- A Property symbol for the shorthand reference (e.g., `pressCount` in defineExpose)
This causes duplicate symbols with the same name, which breaks symbol lookup.
We filter out Property symbols that have a matching Variable with the same name
at a different location (the definition), keeping only the definition.
"""
symbols = super()._request_document_symbols(relative_file_path, file_data)
if symbols is None or len(symbols) == 0:
return symbols
# Only process DocumentSymbol format (hierarchical symbols with children)
# SymbolInformation format doesn't have the same issue
if not isinstance(symbols[0], dict) or "range" not in symbols[0]:
return symbols
return self._filter_shorthand_property_duplicates(symbols)
def _filter_shorthand_property_duplicates(
self, symbols: list[DocumentSymbol] | list[SymbolInformation]
) -> list[DocumentSymbol] | list[SymbolInformation]:
"""
Filter out Property symbols that have a matching Variable symbol with the same name.
This handles Vue's shorthand property syntax in defineExpose, where the same
identifier appears as both a Variable definition and a Property reference.
"""
VARIABLE_KIND = 13 # SymbolKind.Variable
PROPERTY_KIND = 7 # SymbolKind.Property
def filter_symbols(syms: list[dict]) -> list[dict]:
# Collect all Variable symbol names with their line numbers
variable_names: dict[str, set[int]] = {}
for sym in syms:
if sym.get("kind") == VARIABLE_KIND:
name = sym.get("name", "")
line = sym.get("range", {}).get("start", {}).get("line", -1)
if name not in variable_names:
variable_names[name] = set()
variable_names[name].add(line)
# Filter: keep symbols that are either:
# 1. Not a Property, or
# 2. A Property without a matching Variable name at a different location
filtered = []
for sym in syms:
name = sym.get("name", "")
kind = sym.get("kind")
line = sym.get("range", {}).get("start", {}).get("line", -1)
# If it's a Property with a matching Variable name at a DIFFERENT line, skip it
if kind == PROPERTY_KIND and name in variable_names:
# Check if there's a Variable definition at a different line
var_lines = variable_names[name]
if any(var_line != line for var_line in var_lines):
# This is a shorthand reference, skip it
log.debug(
f"Filtering shorthand property reference '{name}' at line {line} "
f"(Variable definition exists at line(s) {var_lines})"
)
continue
# Recursively filter children
children = sym.get("children", [])
if children:
sym = dict(sym) # Create a copy to avoid mutating the original
sym["children"] = filter_symbols(children)
filtered.append(sym)
return filtered
return filter_symbols(list(symbols)) # type: ignore
```
--------------------------------------------------------------------------------
/src/solidlsp/language_servers/eclipse_jdtls.py:
--------------------------------------------------------------------------------
```python
"""
Provides Java specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Java.
"""
import dataclasses
import logging
import os
import pathlib
import shutil
import threading
import uuid
from pathlib import PurePath
from typing import cast
from overrides import override
from solidlsp.ls import LSPFileBuffer, SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.ls_types import UnifiedSymbolInformation
from solidlsp.ls_utils import FileUtils, PlatformUtils
from solidlsp.lsp_protocol_handler.lsp_types import DocumentSymbol, InitializeParams, SymbolInformation
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings
log = logging.getLogger(__name__)
@dataclasses.dataclass
class RuntimeDependencyPaths:
"""
Stores the paths to the runtime dependencies of EclipseJDTLS
"""
gradle_path: str
lombok_jar_path: str
jre_path: str
jre_home_path: str
jdtls_launcher_jar_path: str
jdtls_readonly_config_path: str
intellicode_jar_path: str
intellisense_members_path: str
class EclipseJDTLS(SolidLanguageServer):
r"""
The EclipseJDTLS class provides a Java specific implementation of the LanguageServer class
You can configure the following options in ls_specific_settings (in serena_config.yml):
- maven_user_settings: Path to Maven settings.xml file (default: ~/.m2/settings.xml)
- gradle_user_home: Path to Gradle user home directory (default: ~/.gradle)
Note: Gradle wrapper is disabled by default. Projects will use the bundled Gradle distribution.
Example configuration in ~/.serena/serena_config.yml:
```yaml
ls_specific_settings:
java:
maven_user_settings: "/home/user/.m2/settings.xml" # Unix/Linux/Mac
# maven_user_settings: 'C:\\Users\\YourName\\.m2\\settings.xml' # Windows (use single quotes!)
gradle_user_home: "/home/user/.gradle" # Unix/Linux/Mac
# gradle_user_home: 'C:\\Users\\YourName\\.gradle' # Windows (use single quotes!)
```
"""
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
"""
Creates a new EclipseJDTLS instance initializing the language server settings appropriately.
This class is not meant to be instantiated directly. Use LanguageServer.create() instead.
"""
runtime_dependency_paths = self._setupRuntimeDependencies(config, solidlsp_settings)
self.runtime_dependency_paths = runtime_dependency_paths
# ws_dir is the workspace directory for the EclipseJDTLS server
ws_dir = str(
PurePath(
solidlsp_settings.ls_resources_dir,
"EclipseJDTLS",
"workspaces",
uuid.uuid4().hex,
)
)
# shared_cache_location is the global cache used by Eclipse JDTLS across all workspaces
shared_cache_location = str(PurePath(solidlsp_settings.ls_resources_dir, "lsp", "EclipseJDTLS", "sharedIndex"))
os.makedirs(shared_cache_location, exist_ok=True)
os.makedirs(ws_dir, exist_ok=True)
jre_path = self.runtime_dependency_paths.jre_path
lombok_jar_path = self.runtime_dependency_paths.lombok_jar_path
jdtls_launcher_jar = self.runtime_dependency_paths.jdtls_launcher_jar_path
data_dir = str(PurePath(ws_dir, "data_dir"))
jdtls_config_path = str(PurePath(ws_dir, "config_path"))
jdtls_readonly_config_path = self.runtime_dependency_paths.jdtls_readonly_config_path
if not os.path.exists(jdtls_config_path):
shutil.copytree(jdtls_readonly_config_path, jdtls_config_path)
for static_path in [
jre_path,
lombok_jar_path,
jdtls_launcher_jar,
jdtls_config_path,
jdtls_readonly_config_path,
]:
assert os.path.exists(static_path), static_path
# TODO: Add "self.runtime_dependency_paths.jre_home_path"/bin to $PATH as well
proc_env = {"syntaxserver": "false", "JAVA_HOME": self.runtime_dependency_paths.jre_home_path}
proc_cwd = repository_root_path
cmd = [
jre_path,
"--add-modules=ALL-SYSTEM",
"--add-opens",
"java.base/java.util=ALL-UNNAMED",
"--add-opens",
"java.base/java.lang=ALL-UNNAMED",
"--add-opens",
"java.base/sun.nio.fs=ALL-UNNAMED",
"-Declipse.application=org.eclipse.jdt.ls.core.id1",
"-Dosgi.bundles.defaultStartLevel=4",
"-Declipse.product=org.eclipse.jdt.ls.core.product",
"-Djava.import.generatesMetadataFilesAtProjectRoot=false",
"-Dfile.encoding=utf8",
"-noverify",
"-XX:+UseParallelGC",
"-XX:GCTimeRatio=4",
"-XX:AdaptiveSizePolicyWeight=90",
"-Dsun.zip.disableMemoryMapping=true",
"-Djava.lsp.joinOnCompletion=true",
"-Xmx3G",
"-Xms100m",
"-Xlog:disable",
"-Dlog.level=ALL",
f"-javaagent:{lombok_jar_path}",
f"-Djdt.core.sharedIndexLocation={shared_cache_location}",
"-jar",
f"{jdtls_launcher_jar}",
"-configuration",
f"{jdtls_config_path}",
"-data",
f"{data_dir}",
]
self.service_ready_event = threading.Event()
self.intellicode_enable_command_available = threading.Event()
self.initialize_searcher_command_available = threading.Event()
super().__init__(
config, repository_root_path, ProcessLaunchInfo(cmd, proc_env, proc_cwd), "java", solidlsp_settings=solidlsp_settings
)
@override
def is_ignored_dirname(self, dirname: str) -> bool:
# Ignore common Java build directories from different build tools:
# - Maven: target
# - Gradle: build, .gradle
# - Eclipse: bin, .settings
# - IntelliJ IDEA: out, .idea
# - General: classes, dist, lib
return super().is_ignored_dirname(dirname) or dirname in [
"target", # Maven
"build", # Gradle
"bin", # Eclipse
"out", # IntelliJ IDEA
"classes", # General
"dist", # General
"lib", # General
]
@classmethod
def _setupRuntimeDependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> RuntimeDependencyPaths:
"""
Setup runtime dependencies for EclipseJDTLS and return the paths.
"""
platformId = PlatformUtils.get_platform_id()
runtime_dependencies = {
"gradle": {
"platform-agnostic": {
"url": "https://services.gradle.org/distributions/gradle-8.14.2-bin.zip",
"archiveType": "zip",
"relative_extraction_path": ".",
}
},
"vscode-java": {
"darwin-arm64": {
"url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-darwin-arm64-1.42.0-561.vsix",
"archiveType": "zip",
"relative_extraction_path": "vscode-java",
},
"osx-arm64": {
"url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-darwin-arm64-1.42.0-561.vsix",
"archiveType": "zip",
"relative_extraction_path": "vscode-java",
"jre_home_path": "extension/jre/21.0.7-macosx-aarch64",
"jre_path": "extension/jre/21.0.7-macosx-aarch64/bin/java",
"lombok_jar_path": "extension/lombok/lombok-1.18.36.jar",
"jdtls_launcher_jar_path": "extension/server/plugins/org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar",
"jdtls_readonly_config_path": "extension/server/config_mac_arm",
},
"osx-x64": {
"url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-darwin-x64-1.42.0-561.vsix",
"archiveType": "zip",
"relative_extraction_path": "vscode-java",
"jre_home_path": "extension/jre/21.0.7-macosx-x86_64",
"jre_path": "extension/jre/21.0.7-macosx-x86_64/bin/java",
"lombok_jar_path": "extension/lombok/lombok-1.18.36.jar",
"jdtls_launcher_jar_path": "extension/server/plugins/org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar",
"jdtls_readonly_config_path": "extension/server/config_mac",
},
"linux-arm64": {
"url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-linux-arm64-1.42.0-561.vsix",
"archiveType": "zip",
"relative_extraction_path": "vscode-java",
"jre_home_path": "extension/jre/21.0.7-linux-aarch64",
"jre_path": "extension/jre/21.0.7-linux-aarch64/bin/java",
"lombok_jar_path": "extension/lombok/lombok-1.18.36.jar",
"jdtls_launcher_jar_path": "extension/server/plugins/org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar",
"jdtls_readonly_config_path": "extension/server/config_linux_arm",
},
"linux-x64": {
"url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-linux-x64-1.42.0-561.vsix",
"archiveType": "zip",
"relative_extraction_path": "vscode-java",
"jre_home_path": "extension/jre/21.0.7-linux-x86_64",
"jre_path": "extension/jre/21.0.7-linux-x86_64/bin/java",
"lombok_jar_path": "extension/lombok/lombok-1.18.36.jar",
"jdtls_launcher_jar_path": "extension/server/plugins/org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar",
"jdtls_readonly_config_path": "extension/server/config_linux",
},
"win-x64": {
"url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-win32-x64-1.42.0-561.vsix",
"archiveType": "zip",
"relative_extraction_path": "vscode-java",
"jre_home_path": "extension/jre/21.0.7-win32-x86_64",
"jre_path": "extension/jre/21.0.7-win32-x86_64/bin/java.exe",
"lombok_jar_path": "extension/lombok/lombok-1.18.36.jar",
"jdtls_launcher_jar_path": "extension/server/plugins/org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar",
"jdtls_readonly_config_path": "extension/server/config_win",
},
},
"intellicode": {
"platform-agnostic": {
"url": "https://VisualStudioExptTeam.gallery.vsassets.io/_apis/public/gallery/publisher/VisualStudioExptTeam/extension/vscodeintellicode/1.2.30/assetbyname/Microsoft.VisualStudio.Services.VSIXPackage",
"alternate_url": "https://marketplace.visualstudio.com/_apis/public/gallery/publishers/VisualStudioExptTeam/vsextensions/vscodeintellicode/1.2.30/vspackage",
"archiveType": "zip",
"relative_extraction_path": "intellicode",
"intellicode_jar_path": "extension/dist/com.microsoft.jdtls.intellicode.core-0.7.0.jar",
"intellisense_members_path": "extension/dist/bundledModels/java_intellisense-members",
}
},
}
# assert platformId.value in [
# "linux-x64",
# "win-x64",
# ], "Only linux-x64 platform is supported for in multilspy at the moment"
gradle_path = str(
PurePath(
cls.ls_resources_dir(solidlsp_settings),
"gradle-8.14.2",
)
)
if not os.path.exists(gradle_path):
FileUtils.download_and_extract_archive(
runtime_dependencies["gradle"]["platform-agnostic"]["url"],
str(PurePath(gradle_path).parent),
runtime_dependencies["gradle"]["platform-agnostic"]["archiveType"],
)
assert os.path.exists(gradle_path)
dependency = runtime_dependencies["vscode-java"][platformId.value]
vscode_java_path = str(PurePath(cls.ls_resources_dir(solidlsp_settings), dependency["relative_extraction_path"]))
os.makedirs(vscode_java_path, exist_ok=True)
jre_home_path = str(PurePath(vscode_java_path, dependency["jre_home_path"]))
jre_path = str(PurePath(vscode_java_path, dependency["jre_path"]))
lombok_jar_path = str(PurePath(vscode_java_path, dependency["lombok_jar_path"]))
jdtls_launcher_jar_path = str(PurePath(vscode_java_path, dependency["jdtls_launcher_jar_path"]))
jdtls_readonly_config_path = str(PurePath(vscode_java_path, dependency["jdtls_readonly_config_path"]))
if not all(
[
os.path.exists(vscode_java_path),
os.path.exists(jre_home_path),
os.path.exists(jre_path),
os.path.exists(lombok_jar_path),
os.path.exists(jdtls_launcher_jar_path),
os.path.exists(jdtls_readonly_config_path),
]
):
FileUtils.download_and_extract_archive(dependency["url"], vscode_java_path, dependency["archiveType"])
os.chmod(jre_path, 0o755)
assert os.path.exists(vscode_java_path)
assert os.path.exists(jre_home_path)
assert os.path.exists(jre_path)
assert os.path.exists(lombok_jar_path)
assert os.path.exists(jdtls_launcher_jar_path)
assert os.path.exists(jdtls_readonly_config_path)
dependency = runtime_dependencies["intellicode"]["platform-agnostic"]
intellicode_directory_path = str(PurePath(cls.ls_resources_dir(solidlsp_settings), dependency["relative_extraction_path"]))
os.makedirs(intellicode_directory_path, exist_ok=True)
intellicode_jar_path = str(PurePath(intellicode_directory_path, dependency["intellicode_jar_path"]))
intellisense_members_path = str(PurePath(intellicode_directory_path, dependency["intellisense_members_path"]))
if not all(
[
os.path.exists(intellicode_directory_path),
os.path.exists(intellicode_jar_path),
os.path.exists(intellisense_members_path),
]
):
FileUtils.download_and_extract_archive(dependency["url"], intellicode_directory_path, dependency["archiveType"])
assert os.path.exists(intellicode_directory_path)
assert os.path.exists(intellicode_jar_path)
assert os.path.exists(intellisense_members_path)
return RuntimeDependencyPaths(
gradle_path=gradle_path,
lombok_jar_path=lombok_jar_path,
jre_path=jre_path,
jre_home_path=jre_home_path,
jdtls_launcher_jar_path=jdtls_launcher_jar_path,
jdtls_readonly_config_path=jdtls_readonly_config_path,
intellicode_jar_path=intellicode_jar_path,
intellisense_members_path=intellisense_members_path,
)
def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams:
"""
Returns the initialize parameters for the EclipseJDTLS server.
"""
# Look into https://github.com/eclipse/eclipse.jdt.ls/blob/master/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/preferences/Preferences.java to understand all the options available
if not os.path.isabs(repository_absolute_path):
repository_absolute_path = os.path.abspath(repository_absolute_path)
repo_uri = pathlib.Path(repository_absolute_path).as_uri()
# Load user's Maven and Gradle configuration paths from ls_specific_settings["java"]
# Maven settings: default to ~/.m2/settings.xml
default_maven_settings_path = os.path.join(os.path.expanduser("~"), ".m2", "settings.xml")
custom_maven_settings_path = self._custom_settings.get("maven_user_settings")
if custom_maven_settings_path is not None:
# User explicitly provided a path
if not os.path.exists(custom_maven_settings_path):
error_msg = (
f"Provided maven settings file not found: {custom_maven_settings_path}. "
f"Fix: create the file, update path in ~/.serena/serena_config.yml (ls_specific_settings -> java -> maven_user_settings), "
f"or remove the setting to use default ({default_maven_settings_path})"
)
log.error(error_msg)
raise FileNotFoundError(error_msg)
maven_settings_path = custom_maven_settings_path
log.info(f"Using Maven settings from custom location: {maven_settings_path}")
elif os.path.exists(default_maven_settings_path):
maven_settings_path = default_maven_settings_path
log.info(f"Using Maven settings from default location: {maven_settings_path}")
else:
maven_settings_path = None
log.info(f"Maven settings not found at default location ({default_maven_settings_path}), will use JDTLS defaults")
# Gradle user home: default to ~/.gradle
default_gradle_home = os.path.join(os.path.expanduser("~"), ".gradle")
custom_gradle_home = self._custom_settings.get("gradle_user_home")
if custom_gradle_home is not None:
# User explicitly provided a path
if not os.path.exists(custom_gradle_home):
error_msg = (
f"Gradle user home directory not found: {custom_gradle_home}. "
f"Fix: create the directory, update path in ~/.serena/serena_config.yml (ls_specific_settings -> java -> gradle_user_home), "
f"or remove the setting to use default (~/.gradle)"
)
log.error(error_msg)
raise FileNotFoundError(error_msg)
gradle_user_home = custom_gradle_home
log.info(f"Using Gradle user home from custom location: {gradle_user_home}")
elif os.path.exists(default_gradle_home):
gradle_user_home = default_gradle_home
log.info(f"Using Gradle user home from default location: {gradle_user_home}")
else:
gradle_user_home = None
log.info(f"Gradle user home not found at default location ({default_gradle_home}), will use JDTLS defaults")
initialize_params = {
"locale": "en",
"rootPath": repository_absolute_path,
"rootUri": pathlib.Path(repository_absolute_path).as_uri(),
"capabilities": {
"workspace": {
"applyEdit": True,
"workspaceEdit": {
"documentChanges": True,
"resourceOperations": ["create", "rename", "delete"],
"failureHandling": "textOnlyTransactional",
"normalizesLineEndings": True,
"changeAnnotationSupport": {"groupsOnLabel": True},
},
"didChangeConfiguration": {"dynamicRegistration": True},
"didChangeWatchedFiles": {"dynamicRegistration": True, "relativePatternSupport": True},
"symbol": {
"dynamicRegistration": True,
"symbolKind": {"valueSet": list(range(1, 27))},
"tagSupport": {"valueSet": [1]},
"resolveSupport": {"properties": ["location.range"]},
},
"codeLens": {"refreshSupport": True},
"executeCommand": {"dynamicRegistration": True},
"configuration": True,
"workspaceFolders": True,
"semanticTokens": {"refreshSupport": True},
"fileOperations": {
"dynamicRegistration": True,
"didCreate": True,
"didRename": True,
"didDelete": True,
"willCreate": True,
"willRename": True,
"willDelete": True,
},
"inlineValue": {"refreshSupport": True},
"inlayHint": {"refreshSupport": True},
"diagnostics": {"refreshSupport": True},
},
"textDocument": {
"publishDiagnostics": {
"relatedInformation": True,
"versionSupport": False,
"tagSupport": {"valueSet": [1, 2]},
"codeDescriptionSupport": True,
"dataSupport": True,
},
"synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True},
# TODO: we have an assert that completion provider is not included in the capabilities at server startup
# Removing this will cause the assert to fail. Investigate why this is the case, simplify config
"completion": {
"dynamicRegistration": True,
"contextSupport": True,
"completionItem": {
"snippetSupport": False,
"commitCharactersSupport": True,
"documentationFormat": ["markdown", "plaintext"],
"deprecatedSupport": True,
"preselectSupport": True,
"tagSupport": {"valueSet": [1]},
"insertReplaceSupport": False,
"resolveSupport": {"properties": ["documentation", "detail", "additionalTextEdits"]},
"insertTextModeSupport": {"valueSet": [1, 2]},
"labelDetailsSupport": True,
},
"insertTextMode": 2,
"completionItemKind": {
"valueSet": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25]
},
"completionList": {"itemDefaults": ["commitCharacters", "editRange", "insertTextFormat", "insertTextMode"]},
},
"hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]},
"signatureHelp": {
"dynamicRegistration": True,
"signatureInformation": {
"documentationFormat": ["markdown", "plaintext"],
"parameterInformation": {"labelOffsetSupport": True},
"activeParameterSupport": True,
},
},
"definition": {"dynamicRegistration": True, "linkSupport": True},
"references": {"dynamicRegistration": True},
"documentSymbol": {
"dynamicRegistration": True,
"symbolKind": {"valueSet": list(range(1, 27))},
"hierarchicalDocumentSymbolSupport": True,
"tagSupport": {"valueSet": [1]},
"labelSupport": True,
},
"rename": {
"dynamicRegistration": True,
"prepareSupport": True,
"prepareSupportDefaultBehavior": 1,
"honorsChangeAnnotations": True,
},
"documentLink": {"dynamicRegistration": True, "tooltipSupport": True},
"typeDefinition": {"dynamicRegistration": True, "linkSupport": True},
"implementation": {"dynamicRegistration": True, "linkSupport": True},
"colorProvider": {"dynamicRegistration": True},
"declaration": {"dynamicRegistration": True, "linkSupport": True},
"selectionRange": {"dynamicRegistration": True},
"callHierarchy": {"dynamicRegistration": True},
"semanticTokens": {
"dynamicRegistration": True,
"tokenTypes": [
"namespace",
"type",
"class",
"enum",
"interface",
"struct",
"typeParameter",
"parameter",
"variable",
"property",
"enumMember",
"event",
"function",
"method",
"macro",
"keyword",
"modifier",
"comment",
"string",
"number",
"regexp",
"operator",
"decorator",
],
"tokenModifiers": [
"declaration",
"definition",
"readonly",
"static",
"deprecated",
"abstract",
"async",
"modification",
"documentation",
"defaultLibrary",
],
"formats": ["relative"],
"requests": {"range": True, "full": {"delta": True}},
"multilineTokenSupport": False,
"overlappingTokenSupport": False,
"serverCancelSupport": True,
"augmentsSyntaxTokens": True,
},
"typeHierarchy": {"dynamicRegistration": True},
"inlineValue": {"dynamicRegistration": True},
"diagnostic": {"dynamicRegistration": True, "relatedDocumentSupport": False},
},
"general": {
"staleRequestSupport": {
"cancel": True,
"retryOnContentModified": [
"textDocument/semanticTokens/full",
"textDocument/semanticTokens/range",
"textDocument/semanticTokens/full/delta",
],
},
"regularExpressions": {"engine": "ECMAScript", "version": "ES2020"},
"positionEncodings": ["utf-16"],
},
"notebookDocument": {"synchronization": {"dynamicRegistration": True, "executionSummarySupport": True}},
},
"initializationOptions": {
"bundles": ["intellicode-core.jar"],
"settings": {
"java": {
"home": None,
"jdt": {
"ls": {
"java": {"home": None},
"vmargs": "-XX:+UseParallelGC -XX:GCTimeRatio=4 -XX:AdaptiveSizePolicyWeight=90 -Dsun.zip.disableMemoryMapping=true -Xmx1G -Xms100m -Xlog:disable",
"lombokSupport": {"enabled": True},
"protobufSupport": {"enabled": True},
"androidSupport": {"enabled": True},
}
},
"errors": {"incompleteClasspath": {"severity": "error"}},
"configuration": {
"checkProjectSettingsExclusions": False,
"updateBuildConfiguration": "interactive",
"maven": {
"userSettings": maven_settings_path,
"globalSettings": None,
"notCoveredPluginExecutionSeverity": "warning",
"defaultMojoExecutionAction": "ignore",
},
"workspaceCacheLimit": 90,
"runtimes": [
{"name": "JavaSE-21", "path": "static/vscode-java/extension/jre/21.0.7-linux-x86_64", "default": True}
],
},
"trace": {"server": "verbose"},
"import": {
"maven": {
"enabled": True,
"offline": {"enabled": False},
"disableTestClasspathFlag": False,
},
"gradle": {
"enabled": True,
"wrapper": {"enabled": False},
"version": None,
"home": "abs(static/gradle-7.3.3)",
"java": {"home": "abs(static/launch_jres/21.0.7-linux-x86_64)"},
"offline": {"enabled": False},
"arguments": None,
"jvmArguments": None,
"user": {"home": gradle_user_home},
"annotationProcessing": {"enabled": True},
},
"exclusions": [
"**/node_modules/**",
"**/.metadata/**",
"**/archetype-resources/**",
"**/META-INF/maven/**",
],
"generatesMetadataFilesAtProjectRoot": False,
},
# Set updateSnapshots to False to improve performance and avoid unnecessary network calls
# Snapshots will only be updated when explicitly requested by the user
"maven": {"downloadSources": True, "updateSnapshots": False},
"eclipse": {"downloadSources": True},
"signatureHelp": {"enabled": True, "description": {"enabled": True}},
"implementationsCodeLens": {"enabled": True},
"format": {
"enabled": True,
"settings": {"url": None, "profile": None},
"comments": {"enabled": True},
"onType": {"enabled": True},
"insertSpaces": True,
"tabSize": 4,
},
"saveActions": {"organizeImports": False},
"project": {
"referencedLibraries": ["lib/**/*.jar"],
"importOnFirstTimeStartup": "automatic",
"importHint": True,
"resourceFilters": ["node_modules", "\\.git"],
"encoding": "ignore",
"exportJar": {"targetPath": "${workspaceFolder}/${workspaceFolderBasename}.jar"},
},
"contentProvider": {"preferred": None},
"autobuild": {"enabled": True},
"maxConcurrentBuilds": 1,
"selectionRange": {"enabled": True},
"showBuildStatusOnStart": {"enabled": "notification"},
"server": {"launchMode": "Standard"},
"sources": {"organizeImports": {"starThreshold": 99, "staticStarThreshold": 99}},
"imports": {"gradle": {"wrapper": {"checksums": []}}},
"templates": {"fileHeader": [], "typeComment": []},
"references": {"includeAccessors": True, "includeDecompiledSources": True},
"typeHierarchy": {"lazyLoad": False},
"settings": {"url": None},
"symbols": {"includeSourceMethodDeclarations": False},
"inlayHints": {"parameterNames": {"enabled": "literals", "exclusions": []}},
"codeAction": {"sortMembers": {"avoidVolatileChanges": True}},
"compile": {
"nullAnalysis": {
"nonnull": [
"javax.annotation.Nonnull",
"org.eclipse.jdt.annotation.NonNull",
"org.springframework.lang.NonNull",
],
"nullable": [
"javax.annotation.Nullable",
"org.eclipse.jdt.annotation.Nullable",
"org.springframework.lang.Nullable",
],
"mode": "automatic",
}
},
"sharedIndexes": {"enabled": "auto", "location": ""},
"silentNotification": False,
"dependency": {
"showMembers": False,
"syncWithFolderExplorer": True,
"autoRefresh": True,
"refreshDelay": 2000,
"packagePresentation": "flat",
},
"help": {"firstView": "auto", "showReleaseNotes": True, "collectErrorLog": False},
"test": {"defaultConfig": "", "config": {}},
}
},
},
"trace": "verbose",
"processId": os.getpid(),
"workspaceFolders": [
{
"uri": repo_uri,
"name": os.path.basename(repository_absolute_path),
}
],
}
initialize_params["initializationOptions"]["workspaceFolders"] = [repo_uri] # type: ignore
bundles = [self.runtime_dependency_paths.intellicode_jar_path]
initialize_params["initializationOptions"]["bundles"] = bundles # type: ignore
initialize_params["initializationOptions"]["settings"]["java"]["configuration"]["runtimes"] = [ # type: ignore
{"name": "JavaSE-21", "path": self.runtime_dependency_paths.jre_home_path, "default": True}
]
for runtime in initialize_params["initializationOptions"]["settings"]["java"]["configuration"]["runtimes"]: # type: ignore
assert "name" in runtime
assert "path" in runtime
assert os.path.exists(runtime["path"]), f"Runtime required for eclipse_jdtls at path {runtime['path']} does not exist"
gradle_settings = initialize_params["initializationOptions"]["settings"]["java"]["import"]["gradle"] # type: ignore
gradle_settings["home"] = self.runtime_dependency_paths.gradle_path
gradle_settings["java"]["home"] = self.runtime_dependency_paths.jre_path
return cast(InitializeParams, initialize_params)
def _start_server(self) -> None:
"""
Starts the Eclipse JDTLS Language Server
"""
def register_capability_handler(params: dict) -> None:
assert "registrations" in params
for registration in params["registrations"]:
if registration["method"] == "textDocument/completion":
assert registration["registerOptions"]["resolveProvider"] == True
assert registration["registerOptions"]["triggerCharacters"] == [
".",
"@",
"#",
"*",
" ",
]
self.completions_available.set()
if registration["method"] == "workspace/executeCommand":
if "java.intellicode.enable" in registration["registerOptions"]["commands"]:
self.intellicode_enable_command_available.set()
return
def lang_status_handler(params: dict) -> None:
# TODO: Should we wait for
# server -> client: {'jsonrpc': '2.0', 'method': 'language/status', 'params': {'type': 'ProjectStatus', 'message': 'OK'}}
# Before proceeding?
if params["type"] == "ServiceReady" and params["message"] == "ServiceReady":
self.service_ready_event.set()
def execute_client_command_handler(params: dict) -> list:
assert params["command"] == "_java.reloadBundles.command"
assert params["arguments"] == []
return []
def window_log_message(msg: dict) -> None:
log.info(f"LSP: window/logMessage: {msg}")
def do_nothing(params: dict) -> None:
return
self.server.on_request("client/registerCapability", register_capability_handler)
self.server.on_notification("language/status", lang_status_handler)
self.server.on_notification("window/logMessage", window_log_message)
self.server.on_request("workspace/executeClientCommand", execute_client_command_handler)
self.server.on_notification("$/progress", do_nothing)
self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
self.server.on_notification("language/actionableNotification", do_nothing)
log.info("Starting EclipseJDTLS server process")
self.server.start()
initialize_params = self._get_initialize_params(self.repository_root_path)
log.info("Sending initialize request from LSP client to LSP server and awaiting response")
init_response = self.server.send.initialize(initialize_params)
assert init_response["capabilities"]["textDocumentSync"]["change"] == 2 # type: ignore
assert "completionProvider" not in init_response["capabilities"]
assert "executeCommandProvider" not in init_response["capabilities"]
self.server.notify.initialized({})
self.server.notify.workspace_did_change_configuration({"settings": initialize_params["initializationOptions"]["settings"]}) # type: ignore
self.intellicode_enable_command_available.wait()
java_intellisense_members_path = self.runtime_dependency_paths.intellisense_members_path
assert os.path.exists(java_intellisense_members_path)
intellicode_enable_result = self.server.send.execute_command(
{
"command": "java.intellicode.enable",
"arguments": [True, java_intellisense_members_path],
}
)
assert intellicode_enable_result
# TODO: Add comments about why we wait here, and how this can be optimized
self.service_ready_event.wait()
def _request_document_symbols(
self, relative_file_path: str, file_data: LSPFileBuffer | None
) -> list[SymbolInformation] | list[DocumentSymbol] | None:
result = super()._request_document_symbols(relative_file_path, file_data=file_data)
if result is None:
return None
# JDTLS sometimes returns symbol names with type information to handle overloads,
# e.g. "myMethod(int) <T>", but we want overloads to be handled via overload_idx,
# which requires the name to be just "myMethod".
def fix_name(symbol: SymbolInformation | DocumentSymbol | UnifiedSymbolInformation) -> None:
if "(" in symbol["name"]:
symbol["name"] = symbol["name"][: symbol["name"].index("(")]
children = symbol.get("children")
if children:
for child in children: # type: ignore
fix_name(child)
for root_symbol in result:
fix_name(root_symbol)
return result
```
--------------------------------------------------------------------------------
/src/solidlsp/language_servers/al_language_server.py:
--------------------------------------------------------------------------------
```python
"""AL Language Server implementation for Microsoft Dynamics 365 Business Central."""
import logging
import os
import pathlib
import platform
import stat
import time
import zipfile
from pathlib import Path
import requests
from overrides import override
from solidlsp.language_servers.common import quote_windows_path
from solidlsp.ls import SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.ls_types import SymbolKind, UnifiedSymbolInformation
from solidlsp.lsp_protocol_handler.lsp_types import Definition, DefinitionParams, LocationLink
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings
log = logging.getLogger(__name__)
class ALLanguageServer(SolidLanguageServer):
"""
Language server implementation for AL (Microsoft Dynamics 365 Business Central).
This implementation uses the AL Language Server from the VS Code AL extension
(ms-dynamics-smb.al). The extension must be installed or available locally.
Key Features:
- Automatic download of AL extension from VS Code marketplace if not present
- Platform-specific executable detection (Windows/Linux/macOS)
- Special initialization sequence required by AL Language Server
- Custom AL-specific LSP commands (al/gotodefinition, al/setActiveWorkspace)
- File opening requirement before symbol retrieval
"""
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
"""
Initialize the AL Language Server.
Args:
config: Language server configuration
logger: Logger instance for debugging
repository_root_path: Root path of the AL project (must contain app.json)
solidlsp_settings: Solid LSP settings
Note:
The initialization process will automatically:
1. Check for AL extension in the resources directory
2. Download it from VS Code marketplace if not found
3. Extract and configure the platform-specific executable
"""
# Setup runtime dependencies and get the language server command
# This will download the AL extension if needed
cmd = self._setup_runtime_dependencies(config, solidlsp_settings)
self._project_load_check_supported: bool = True
"""Whether the AL server supports the project load status check request.
Some AL server versions don't support the 'al/hasProjectClosureLoadedRequest'
custom LSP request. This flag starts as True and is set to False if the
request fails, preventing repeated unsuccessful attempts.
"""
super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd=cmd, cwd=repository_root_path), "al", solidlsp_settings)
@classmethod
def _download_al_extension(cls, url: str, target_dir: str) -> bool:
"""
Download and extract the AL extension from VS Code marketplace.
The VS Code marketplace packages extensions as .vsix files (which are ZIP archives).
This method downloads the VSIX file and extracts it to get the language server binaries.
Args:
logger: Logger for tracking download progress
url: VS Code marketplace URL for the AL extension
target_dir: Directory where the extension will be extracted
Returns:
True if successful, False otherwise
Note:
The download includes progress tracking and proper user-agent headers
to ensure compatibility with the VS Code marketplace.
"""
try:
log.info(f"Downloading AL extension from {url}")
# Create target directory for the extension
os.makedirs(target_dir, exist_ok=True)
# Download with proper headers to mimic VS Code marketplace client
# These headers are required for the marketplace to serve the VSIX file
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Accept": "application/octet-stream, application/vsix, */*",
}
response = requests.get(url, headers=headers, stream=True, timeout=300)
response.raise_for_status()
# Save to temporary VSIX file (will be deleted after extraction)
temp_file = os.path.join(target_dir, "al_extension_temp.vsix")
total_size = int(response.headers.get("content-length", 0))
log.info(f"Downloading {total_size / 1024 / 1024:.1f} MB...")
with open(temp_file, "wb") as f:
downloaded = 0
for chunk in response.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
downloaded += len(chunk)
if total_size > 0 and downloaded % (10 * 1024 * 1024) == 0: # Log progress every 10MB
progress = (downloaded / total_size) * 100
log.info(f"Download progress: {progress:.1f}%")
log.info("Download complete, extracting...")
# Extract VSIX file (VSIX files are just ZIP archives with a different extension)
# This will extract the extension folder containing the language server binaries
with zipfile.ZipFile(temp_file, "r") as zip_ref:
zip_ref.extractall(target_dir)
# Clean up temp file
os.remove(temp_file)
log.info("AL extension extracted successfully")
return True
except Exception as e:
log.error(f"Error downloading/extracting AL extension: {e}")
return False
@classmethod
def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> str:
"""
Setup runtime dependencies for AL Language Server and return the command to start the server.
This method handles the complete setup process:
1. Checks for existing AL extension installations
2. Downloads from VS Code marketplace if not found
3. Configures executable permissions on Unix systems
4. Returns the properly formatted command string
The AL Language Server executable is located in different paths based on the platform:
- Windows: bin/win32/Microsoft.Dynamics.Nav.EditorServices.Host.exe
- Linux: bin/linux/Microsoft.Dynamics.Nav.EditorServices.Host
- macOS: bin/darwin/Microsoft.Dynamics.Nav.EditorServices.Host
"""
system = platform.system()
# Find existing extension or download if needed
extension_path = cls._find_al_extension(solidlsp_settings)
if extension_path is None:
log.info("AL extension not found on disk, attempting to download...")
extension_path = cls._download_and_install_al_extension(solidlsp_settings)
if extension_path is None:
raise RuntimeError(
"Failed to locate or download AL Language Server. Please either:\n"
"1. Set AL_EXTENSION_PATH environment variable to the AL extension directory\n"
"2. Install the AL extension in VS Code (ms-dynamics-smb.al)\n"
"3. Ensure internet connection for automatic download"
)
# Build executable path based on platform
executable_path = cls._get_executable_path(extension_path, system)
if not os.path.exists(executable_path):
raise RuntimeError(f"AL Language Server executable not found at: {executable_path}")
# Prepare and return the executable command
return cls._prepare_executable(executable_path, system)
@classmethod
def _find_al_extension(cls, solidlsp_settings: SolidLSPSettings) -> str | None:
"""
Find AL extension in various locations.
Search order:
1. Environment variable (AL_EXTENSION_PATH)
2. Default download location (~/.serena/ls_resources/al-extension)
3. VS Code installed extensions
Returns:
Path to AL extension directory or None if not found
"""
# Check environment variable
env_path = os.environ.get("AL_EXTENSION_PATH")
if env_path and os.path.exists(env_path):
log.debug(f"Found AL extension via AL_EXTENSION_PATH: {env_path}")
return env_path
elif env_path:
log.warning(f"AL_EXTENSION_PATH set but directory not found: {env_path}")
# Check default download location
default_path = os.path.join(cls.ls_resources_dir(solidlsp_settings), "al-extension", "extension")
if os.path.exists(default_path):
log.debug(f"Found AL extension in default location: {default_path}")
return default_path
# Search VS Code extensions
vscode_path = cls._find_al_extension_in_vscode()
if vscode_path:
log.debug(f"Found AL extension in VS Code: {vscode_path}")
return vscode_path
log.debug("AL extension not found in any known location")
return None
@classmethod
def _download_and_install_al_extension(cls, solidlsp_settings: SolidLSPSettings) -> str | None:
"""
Download and install AL extension from VS Code marketplace.
Returns:
Path to installed extension or None if download failed
"""
al_extension_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "al-extension")
# AL extension version - using latest stable version
AL_VERSION = "latest"
url = f"https://marketplace.visualstudio.com/_apis/public/gallery/publishers/ms-dynamics-smb/vsextensions/al/{AL_VERSION}/vspackage"
log.info(f"Downloading AL extension from: {url}")
if cls._download_al_extension(url, al_extension_dir):
extension_path = os.path.join(al_extension_dir, "extension")
if os.path.exists(extension_path):
log.info("AL extension downloaded and installed successfully")
return extension_path
else:
log.error(f"Download completed but extension not found at: {extension_path}")
else:
log.error("Failed to download AL extension from marketplace")
return None
@classmethod
def _get_executable_path(cls, extension_path: str, system: str) -> str:
"""
Build platform-specific executable path.
Args:
extension_path: Path to AL extension directory
system: Operating system name
Returns:
Full path to executable
"""
if system == "Windows":
return os.path.join(extension_path, "bin", "win32", "Microsoft.Dynamics.Nav.EditorServices.Host.exe")
elif system == "Linux":
return os.path.join(extension_path, "bin", "linux", "Microsoft.Dynamics.Nav.EditorServices.Host")
elif system == "Darwin":
return os.path.join(extension_path, "bin", "darwin", "Microsoft.Dynamics.Nav.EditorServices.Host")
else:
raise RuntimeError(f"Unsupported platform: {system}")
@classmethod
def _prepare_executable(cls, executable_path: str, system: str) -> str:
"""
Prepare the executable by setting permissions and handling path quoting.
Args:
executable_path: Path to the executable
system: Operating system name
logger: Logger instance
Returns:
Properly formatted command string
"""
# Make sure executable has proper permissions on Unix-like systems
if system in ["Linux", "Darwin"]:
st = os.stat(executable_path)
os.chmod(executable_path, st.st_mode | stat.S_IEXEC)
log.debug(f"Set execute permission on: {executable_path}")
log.info(f"Using AL Language Server executable: {executable_path}")
# The AL Language Server uses stdio for LSP communication by default
# Use the utility function to handle Windows path quoting
return quote_windows_path(executable_path)
@classmethod
def _get_language_server_command_fallback(cls) -> str:
"""
Get the command to start the AL language server.
Returns:
Command string to launch the AL language server
Raises:
RuntimeError: If AL extension cannot be found
"""
# Check if AL extension path is configured via environment variable
al_extension_path = os.environ.get("AL_EXTENSION_PATH")
if not al_extension_path:
# Try to find the extension in the current working directory
# (for development/testing when extension is in the serena repo)
cwd_path = Path.cwd()
potential_extension = None
# Look for ms-dynamics-smb.al-* directories
for item in cwd_path.iterdir():
if item.is_dir() and item.name.startswith("ms-dynamics-smb.al-"):
potential_extension = item
break
if potential_extension:
al_extension_path = str(potential_extension)
log.debug(f"Found AL extension in current directory: {al_extension_path}")
else:
# Try to find in common VS Code extension locations
al_extension_path = cls._find_al_extension_in_vscode()
if not al_extension_path:
raise RuntimeError(
"AL Language Server not found. Please either:\n"
"1. Set AL_EXTENSION_PATH environment variable to the VS Code AL extension directory\n"
"2. Install the AL extension in VS Code (ms-dynamics-smb.al)\n"
"3. Place the extension directory in the current working directory"
)
# Determine platform-specific executable
system = platform.system()
if system == "Windows":
executable = os.path.join(al_extension_path, "bin", "win32", "Microsoft.Dynamics.Nav.EditorServices.Host.exe")
elif system == "Linux":
executable = os.path.join(al_extension_path, "bin", "linux", "Microsoft.Dynamics.Nav.EditorServices.Host")
elif system == "Darwin":
executable = os.path.join(al_extension_path, "bin", "darwin", "Microsoft.Dynamics.Nav.EditorServices.Host")
else:
raise RuntimeError(f"Unsupported platform: {system}")
# Verify executable exists
if not os.path.exists(executable):
raise RuntimeError(
f"AL Language Server executable not found at: {executable}\nPlease ensure the AL extension is properly installed."
)
# Make sure executable has proper permissions on Unix-like systems
if system in ["Linux", "Darwin"]:
st = os.stat(executable)
os.chmod(executable, st.st_mode | stat.S_IEXEC)
log.info(f"Using AL Language Server executable: {executable}")
# The AL Language Server uses stdio for LSP communication (no --stdio flag needed)
# Use the utility function to handle Windows path quoting
return quote_windows_path(executable)
@classmethod
def _find_al_extension_in_vscode(cls) -> str | None:
"""
Try to find AL extension in common VS Code extension locations.
Returns:
Path to AL extension directory or None if not found
"""
home = Path.home()
possible_paths = []
# Common VS Code extension paths
if platform.system() == "Windows":
possible_paths.extend(
[
home / ".vscode" / "extensions",
home / ".vscode-insiders" / "extensions",
Path(os.environ.get("APPDATA", "")) / "Code" / "User" / "extensions",
Path(os.environ.get("APPDATA", "")) / "Code - Insiders" / "User" / "extensions",
]
)
else:
possible_paths.extend(
[
home / ".vscode" / "extensions",
home / ".vscode-server" / "extensions",
home / ".vscode-insiders" / "extensions",
]
)
for base_path in possible_paths:
if base_path.exists():
log.debug(f"Searching for AL extension in: {base_path}")
# Look for AL extension directories
for item in base_path.iterdir():
if item.is_dir() and item.name.startswith("ms-dynamics-smb.al-"):
log.debug(f"Found AL extension at: {item}")
return str(item)
return None
@staticmethod
def _get_initialize_params(repository_absolute_path: str) -> dict:
"""
Returns the initialize params for the AL Language Server.
"""
# Ensure we have an absolute path for URI generation
repository_path = pathlib.Path(repository_absolute_path).resolve()
root_uri = repository_path.as_uri()
# AL requires extensive capabilities based on VS Code trace
initialize_params = {
"processId": os.getpid(),
"rootPath": str(repository_path),
"rootUri": root_uri,
"capabilities": {
"workspace": {
"applyEdit": True,
"workspaceEdit": {
"documentChanges": True,
"resourceOperations": ["create", "rename", "delete"],
"failureHandling": "textOnlyTransactional",
"normalizesLineEndings": True,
},
"configuration": True,
"didChangeWatchedFiles": {"dynamicRegistration": True},
"symbol": {"dynamicRegistration": True, "symbolKind": {"valueSet": list(range(1, 27))}},
"executeCommand": {"dynamicRegistration": True},
"didChangeConfiguration": {"dynamicRegistration": True},
"workspaceFolders": True,
},
"textDocument": {
"synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True},
"completion": {
"dynamicRegistration": True,
"contextSupport": True,
"completionItem": {
"snippetSupport": True,
"commitCharactersSupport": True,
"documentationFormat": ["markdown", "plaintext"],
"deprecatedSupport": True,
"preselectSupport": True,
},
},
"hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]},
"definition": {"dynamicRegistration": True, "linkSupport": True},
"references": {"dynamicRegistration": True},
"documentHighlight": {"dynamicRegistration": True},
"documentSymbol": {
"dynamicRegistration": True,
"symbolKind": {"valueSet": list(range(1, 27))},
"hierarchicalDocumentSymbolSupport": True,
},
"codeAction": {"dynamicRegistration": True},
"formatting": {"dynamicRegistration": True},
"rangeFormatting": {"dynamicRegistration": True},
"rename": {"dynamicRegistration": True, "prepareSupport": True},
},
"window": {
"showMessage": {"messageActionItem": {"additionalPropertiesSupport": True}},
"showDocument": {"support": True},
"workDoneProgress": True,
},
},
"trace": "verbose",
"workspaceFolders": [{"uri": root_uri, "name": repository_path.name}],
}
return initialize_params
@override
def _start_server(self) -> None:
"""
Starts the AL Language Server process and initializes it.
This method sets up custom notification handlers for AL-specific messages
before starting the server. The AL server sends various notifications
during initialization and project loading that need to be handled.
"""
# Set up event handlers
def do_nothing(params: str) -> None:
return
def window_log_message(msg: dict) -> None:
log.info(f"AL LSP: window/logMessage: {msg}")
def publish_diagnostics(params: dict) -> None:
# AL server publishes diagnostics during initialization
uri = params.get("uri", "")
diagnostics = params.get("diagnostics", [])
log.debug(f"AL LSP: Diagnostics for {uri}: {len(diagnostics)} issues")
def handle_al_notifications(params: dict) -> None:
# AL server sends custom notifications during project loading
log.debug("AL LSP: Notification received")
# Register handlers for AL-specific notifications
# These notifications are sent by the AL server during initialization and operation
self.server.on_notification("window/logMessage", window_log_message) # Server log messages
self.server.on_notification("textDocument/publishDiagnostics", publish_diagnostics) # Compilation diagnostics
self.server.on_notification("$/progress", do_nothing) # Progress notifications during loading
self.server.on_notification("al/refreshExplorerObjects", handle_al_notifications) # AL-specific object updates
# Start the server process
log.info("Starting AL Language Server process")
self.server.start()
# Send initialize request
initialize_params = self._get_initialize_params(self.repository_root_path)
log.info("Sending initialize request from LSP client to AL LSP server and awaiting response")
# Send initialize and wait for response
resp = self.server.send_request("initialize", initialize_params)
if resp is None:
raise RuntimeError("AL Language Server initialization failed - no response")
log.info("AL Language Server initialized successfully")
# Send initialized notification
self.server.send_notification("initialized", {})
log.info("Sent initialized notification")
@override
def start(self) -> "ALLanguageServer":
"""
Start the AL Language Server with special initialization.
"""
# Call parent start method
super().start()
# AL-specific post-initialization
self._post_initialize_al_workspace()
# Note: set_active_workspace() can be called manually if needed for multi-workspace scenarios
# We don't call it automatically to avoid issues during single-workspace initialization
return self
def _post_initialize_al_workspace(self) -> None:
"""
Post-initialization setup for AL Language Server.
The AL server requires additional setup after initialization:
1. Send workspace configuration - provides AL settings and paths
2. Open app.json to trigger project loading - AL uses app.json to identify project structure
3. Optionally wait for project to be loaded if supported
This special initialization sequence is unique to AL and necessary for proper
symbol resolution and navigation features.
"""
# No sleep needed - server is already initialized
# Send workspace configuration first
# This tells AL about assembly paths, package caches, and code analysis settings
try:
self.server.send_notification(
"workspace/didChangeConfiguration",
{
"settings": {
"workspacePath": self.repository_root_path,
"alResourceConfigurationSettings": {
"assemblyProbingPaths": ["./.netpackages"],
"codeAnalyzers": [],
"enableCodeAnalysis": False,
"backgroundCodeAnalysis": "Project",
"packageCachePaths": ["./.alpackages"],
"ruleSetPath": None,
"enableCodeActions": True,
"incrementalBuild": False,
"outputAnalyzerStatistics": True,
"enableExternalRulesets": True,
},
"setActiveWorkspace": True,
"expectedProjectReferenceDefinitions": [],
"activeWorkspaceClosure": [self.repository_root_path],
}
},
)
log.debug("Sent workspace configuration")
except Exception as e:
log.warning(f"Failed to send workspace config: {e}")
# Check if app.json exists and open it
# app.json is the AL project manifest file (similar to package.json for Node.js)
# Opening it triggers AL to load the project and index all AL files
app_json_path = Path(self.repository_root_path) / "app.json"
if app_json_path.exists():
try:
with open(app_json_path, encoding="utf-8") as f:
app_json_content = f.read()
# Use forward slashes for URI
app_json_uri = app_json_path.as_uri()
# Send textDocument/didOpen for app.json
self.server.send_notification(
"textDocument/didOpen",
{"textDocument": {"uri": app_json_uri, "languageId": "json", "version": 1, "text": app_json_content}},
)
log.debug(f"Opened app.json: {app_json_uri}")
except Exception as e:
log.warning(f"Failed to open app.json: {e}")
# Try to set active workspace (AL-specific custom LSP request)
# This is optional and may not be supported by all AL server versions
workspace_uri = Path(self.repository_root_path).resolve().as_uri()
try:
result = self.server.send_request(
"al/setActiveWorkspace",
{
"currentWorkspaceFolderPath": {"uri": workspace_uri, "name": Path(self.repository_root_path).name, "index": 0},
"settings": {
"workspacePath": self.repository_root_path,
"setActiveWorkspace": True,
},
"timeout": 2, # Quick timeout since this is optional
},
)
log.debug(f"Set active workspace result: {result}")
except Exception as e:
# This is a custom AL request, not critical if it fails
log.debug(f"Failed to set active workspace (non-critical): {e}")
# Check if project supports load status check (optional)
# Many AL server versions don't support this, so we use a short timeout
# and continue regardless of the result
self._wait_for_project_load(timeout=3)
@override
def is_ignored_dirname(self, dirname: str) -> bool:
"""
Define AL-specific directories to ignore during file scanning.
These directories contain generated files, dependencies, or cache data
that should not be analyzed for symbols.
Args:
dirname: Directory name to check
Returns:
True if directory should be ignored
"""
al_ignore_dirs = {
".alpackages", # AL package cache - downloaded dependencies
".alcache", # AL compiler cache - intermediate compilation files
".altemplates", # AL templates - code generation templates
".snapshots", # Test snapshots - test result snapshots
"out", # Compiled output - generated .app files
".vscode", # VS Code settings - editor configuration
"Reference", # Reference assemblies - .NET dependencies
".netpackages", # .NET packages - NuGet packages for AL
"bin", # Binary output - compiled binaries
"obj", # Object files - intermediate build artifacts
}
# Check parent class ignore list first, then AL-specific
return super().is_ignored_dirname(dirname) or dirname in al_ignore_dirs
@override
def request_full_symbol_tree(self, within_relative_path: str | None = None) -> list[UnifiedSymbolInformation]:
"""
Override to handle AL's requirement of opening files before requesting symbols.
The AL Language Server requires files to be explicitly opened via textDocument/didOpen
before it can provide meaningful symbols. Without this, it only returns directory symbols.
This is different from most language servers which can provide symbols for unopened files.
This method:
1. Scans the repository for all AL files (.al and .dal extensions)
2. Opens each file with the AL server
3. Requests symbols for each file
4. Combines all symbols into a hierarchical tree structure
5. Closes the files to free resources
Args:
within_relative_path: Restrict search to this file or directory path
include_body: Whether to include symbol body content
Returns:
Full symbol tree with all AL symbols from opened files organized by directory
"""
log.debug("AL: Starting request_full_symbol_tree with file opening")
# Determine the root path for scanning
if within_relative_path is not None:
within_abs_path = os.path.join(self.repository_root_path, within_relative_path)
if not os.path.exists(within_abs_path):
raise FileNotFoundError(f"File or directory not found: {within_abs_path}")
if os.path.isfile(within_abs_path):
# Single file case - use parent class implementation
root_nodes = self.request_document_symbols(within_relative_path).root_symbols
return root_nodes
# Directory case - scan within this directory
scan_root = Path(within_abs_path)
else:
# Scan entire repository
scan_root = Path(self.repository_root_path)
# For AL, we always need to open files to get symbols
al_files = []
# Walk through the repository to find all AL files
for root, dirs, files in os.walk(scan_root):
# Skip ignored directories
dirs[:] = [d for d in dirs if not self.is_ignored_dirname(d)]
# Find AL files
for file in files:
if file.endswith((".al", ".dal")):
file_path = Path(root) / file
# Use forward slashes for consistent paths
try:
relative_path = str(file_path.relative_to(self.repository_root_path)).replace("\\", "/")
al_files.append((file_path, relative_path))
except ValueError:
# File is outside repository root, skip it
continue
log.debug(f"AL: Found {len(al_files)} AL files")
if not al_files:
log.warning("AL: No AL files found in repository")
return []
# Collect all symbols from all files
all_file_symbols: list[UnifiedSymbolInformation] = []
file_symbol: UnifiedSymbolInformation
for file_path, relative_path in al_files:
try:
# Use our overridden request_document_symbols which handles opening
log.debug(f"AL: Getting symbols for {relative_path}")
all_syms, root_syms = self.request_document_symbols(relative_path).get_all_symbols_and_roots()
if root_syms:
# Create a file-level symbol containing the document symbols
file_symbol = {
"name": file_path.stem, # Just the filename without extension
"kind": SymbolKind.File,
"children": root_syms,
"location": {
"uri": file_path.as_uri(),
"relativePath": relative_path,
"absolutePath": str(file_path),
"range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}},
},
}
all_file_symbols.append(file_symbol)
log.debug(f"AL: Added {len(root_syms)} symbols from {relative_path}")
elif all_syms:
# If we only got all_syms but not root, use all_syms
file_symbol = {
"name": file_path.stem,
"kind": SymbolKind.File,
"children": all_syms,
"location": {
"uri": file_path.as_uri(),
"relativePath": relative_path,
"absolutePath": str(file_path),
"range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}},
},
}
all_file_symbols.append(file_symbol)
log.debug(f"AL: Added {len(all_syms)} symbols from {relative_path}")
except Exception as e:
log.warning(f"AL: Failed to get symbols for {relative_path}: {e}")
if all_file_symbols:
log.debug(f"AL: Returning symbols from {len(all_file_symbols)} files")
# Group files by directory
directory_structure: dict[str, list] = {}
for file_symbol in all_file_symbols:
rel_path = file_symbol["location"]["relativePath"]
assert rel_path is not None
path_parts = rel_path.split("/")
if len(path_parts) > 1:
# File is in a subdirectory
dir_path = "/".join(path_parts[:-1])
if dir_path not in directory_structure:
directory_structure[dir_path] = []
directory_structure[dir_path].append(file_symbol)
else:
# File is in root
if "." not in directory_structure:
directory_structure["."] = []
directory_structure["."].append(file_symbol)
# Build hierarchical structure
result = []
repo_path = Path(self.repository_root_path)
for dir_path, file_symbols in directory_structure.items():
if dir_path == ".":
# Root level files
result.extend(file_symbols)
else:
# Create directory symbol
dir_symbol = {
"name": Path(dir_path).name,
"kind": SymbolKind.Package, # Package/Directory
"children": file_symbols,
"location": {
"relativePath": dir_path,
"absolutePath": str(repo_path / dir_path),
"range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}},
},
}
result.append(dir_symbol)
return result
else:
log.warning("AL: No symbols found in any files")
return []
# ===== Phase 1: Custom AL Command Implementations =====
@override
def _send_definition_request(self, definition_params: DefinitionParams) -> Definition | list[LocationLink] | None:
"""
Override to use AL's custom gotodefinition command.
AL Language Server uses 'al/gotodefinition' instead of the standard
'textDocument/definition' request. This custom command provides better
navigation for AL-specific constructs like table extensions, page extensions,
and codeunit references.
If the custom command fails, we fall back to the standard LSP method.
"""
# Convert standard params to AL format (same structure, different method)
al_params = {"textDocument": definition_params["textDocument"], "position": definition_params["position"]}
try:
# Use custom AL command instead of standard LSP
response = self.server.send_request("al/gotodefinition", al_params)
log.debug(f"AL gotodefinition response: {response}")
return response # type: ignore[return-value]
except Exception as e:
log.warning(f"Failed to use al/gotodefinition, falling back to standard: {e}")
# Fallback to standard LSP method if custom command fails
return super()._send_definition_request(definition_params)
def check_project_loaded(self) -> bool:
"""
Check if AL project closure is fully loaded.
Uses AL's custom 'al/hasProjectClosureLoadedRequest' to determine if
the project and all its dependencies have been fully loaded and indexed.
This is important because AL operations may fail or return incomplete
results if the project is still loading.
Returns:
bool: True if project is loaded, False otherwise
"""
if not hasattr(self, "server") or not self.server_started:
log.debug("Cannot check project load - server not started")
return False
# Check if we've already determined this request isn't supported
if not self._project_load_check_supported:
return True # Assume loaded if check isn't supported
try:
# Use a very short timeout since this is just a status check
response = self.server.send_request("al/hasProjectClosureLoadedRequest", {"timeout": 1})
# Response can be boolean directly, dict with 'loaded' field, or None
if isinstance(response, bool):
return response
elif isinstance(response, dict):
return response.get("loaded", False)
elif response is None:
# None typically means the project is still loading
log.debug("Project load check returned None")
return False
else:
log.debug(f"Unexpected response type for project load check: {type(response)}")
return False
except Exception as e:
# Mark as unsupported to avoid repeated failed attempts
self._project_load_check_supported = False
log.debug(f"Project load check not supported by this AL server version: {e}")
# Assume loaded if we can't check
return True
def _wait_for_project_load(self, timeout: int = 3) -> bool:
"""
Wait for project to be fully loaded.
Polls the AL server to check if the project is loaded.
This is optional as not all AL server versions support this check.
We use a short timeout and continue regardless of the result.
Args:
timeout: Maximum time to wait in seconds (default 3s)
Returns:
bool: True if project loaded within timeout, False otherwise
"""
start_time = time.time()
log.debug(f"Checking AL project load status (timeout: {timeout}s)...")
while time.time() - start_time < timeout:
if self.check_project_loaded():
elapsed = time.time() - start_time
log.info(f"AL project fully loaded after {elapsed:.1f}s")
return True
time.sleep(0.5)
log.debug(f"Project load check timed out after {timeout}s (non-critical)")
return False
def set_active_workspace(self, workspace_uri: str | None = None) -> None:
"""
Set the active AL workspace.
This is important when multiple workspaces exist to ensure operations
target the correct workspace. The AL server can handle multiple projects
simultaneously, but only one can be "active" at a time for operations
like symbol search and navigation.
This uses the custom 'al/setActiveWorkspace' LSP command.
Args:
workspace_uri: URI of workspace to set as active, or None to use repository root
"""
if not hasattr(self, "server") or not self.server_started:
log.debug("Cannot set active workspace - server not started")
return
if workspace_uri is None:
workspace_uri = Path(self.repository_root_path).resolve().as_uri()
params = {"workspaceUri": workspace_uri}
try:
self.server.send_request("al/setActiveWorkspace", params)
log.info(f"Set active workspace to: {workspace_uri}")
except Exception as e:
log.warning(f"Failed to set active workspace: {e}")
# Non-critical error, continue operation
```
--------------------------------------------------------------------------------
/src/serena/cli.py:
--------------------------------------------------------------------------------
```python
import collections
import glob
import json
import os
import shutil
import subprocess
import sys
from collections.abc import Iterator
from logging import Logger
from pathlib import Path
from typing import Any, Literal
import click
from sensai.util import logging
from sensai.util.logging import FileLoggerContext, datetime_tag
from sensai.util.string import dict_string
from tqdm import tqdm
from serena.agent import SerenaAgent
from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode
from serena.config.serena_config import LanguageBackend, ProjectConfig, SerenaConfig, SerenaPaths
from serena.constants import (
DEFAULT_CONTEXT,
DEFAULT_MODES,
PROMPT_TEMPLATES_DIR_INTERNAL,
SERENA_LOG_FORMAT,
SERENAS_OWN_CONTEXT_YAMLS_DIR,
SERENAS_OWN_MODE_YAMLS_DIR,
)
from serena.mcp import SerenaMCPFactory
from serena.project import Project
from serena.tools import FindReferencingSymbolsTool, FindSymbolTool, GetSymbolsOverviewTool, SearchForPatternTool, ToolRegistry
from serena.util.logging import MemoryLogHandler
from solidlsp.ls_config import Language
from solidlsp.util.subprocess_util import subprocess_kwargs
log = logging.getLogger(__name__)
def find_project_root(root: str | Path | None = None) -> str:
"""Find project root by walking up from CWD.
Checks for .serena/project.yml first (explicit Serena project), then .git (git root).
Falls back to CWD if no marker is found.
:param root: If provided, constrains the search to this directory and below
(acts as a virtual filesystem root). Search stops at this boundary.
:return: absolute path to project root (falls back to CWD if no marker found)
"""
current = Path.cwd().resolve()
boundary = Path(root).resolve() if root is not None else None
def ancestors() -> Iterator[Path]:
"""Yield current directory and ancestors up to boundary."""
yield current
for parent in current.parents:
yield parent
if boundary is not None and parent == boundary:
return
# First pass: look for .serena
for directory in ancestors():
if (directory / ".serena" / "project.yml").is_file():
return str(directory)
# Second pass: look for .git
for directory in ancestors():
if (directory / ".git").exists(): # .git can be file (worktree) or dir
return str(directory)
# Fall back to CWD
return str(current)
# --------------------- Utilities -------------------------------------
def _open_in_editor(path: str) -> None:
"""Open the given file in the system's default editor or viewer."""
editor = os.environ.get("EDITOR")
run_kwargs = subprocess_kwargs()
try:
if editor:
subprocess.run([editor, path], check=False, **run_kwargs)
elif sys.platform.startswith("win"):
try:
os.startfile(path)
except OSError:
subprocess.run(["notepad.exe", path], check=False, **run_kwargs)
elif sys.platform == "darwin":
subprocess.run(["open", path], check=False, **run_kwargs)
else:
subprocess.run(["xdg-open", path], check=False, **run_kwargs)
except Exception as e:
print(f"Failed to open {path}: {e}")
class ProjectType(click.ParamType):
"""ParamType allowing either a project name or a path to a project directory."""
name = "[PROJECT_NAME|PROJECT_PATH]"
def convert(self, value: str, param: Any, ctx: Any) -> str:
path = Path(value).resolve()
if path.exists() and path.is_dir():
return str(path)
return value
PROJECT_TYPE = ProjectType()
class AutoRegisteringGroup(click.Group):
"""
A click.Group subclass that automatically registers any click.Command
attributes defined on the class into the group.
After initialization, it inspects its own class for attributes that are
instances of click.Command (typically created via @click.command) and
calls self.add_command(cmd) on each. This lets you define your commands
as static methods on the subclass for IDE-friendly organization without
manual registration.
"""
def __init__(self, name: str, help: str):
super().__init__(name=name, help=help)
# Scan class attributes for click.Command instances and register them.
for attr in dir(self.__class__):
cmd = getattr(self.__class__, attr)
if isinstance(cmd, click.Command):
self.add_command(cmd)
class TopLevelCommands(AutoRegisteringGroup):
"""Root CLI group containing the core Serena commands."""
def __init__(self) -> None:
super().__init__(name="serena", help="Serena CLI commands. You can run `<command> --help` for more info on each command.")
@staticmethod
@click.command("start-mcp-server", help="Starts the Serena MCP server.")
@click.option("--project", "project", type=PROJECT_TYPE, default=None, help="Path or name of project to activate at startup.")
@click.option("--project-file", "project", type=PROJECT_TYPE, default=None, help="[DEPRECATED] Use --project instead.")
@click.argument("project_file_arg", type=PROJECT_TYPE, required=False, default=None, metavar="")
@click.option(
"--context", type=str, default=DEFAULT_CONTEXT, show_default=True, help="Built-in context name or path to custom context YAML."
)
@click.option(
"--mode",
"modes",
type=str,
multiple=True,
default=DEFAULT_MODES,
show_default=True,
help="Built-in mode names or paths to custom mode YAMLs.",
)
@click.option(
"--language-backend",
type=click.Choice([lb.value for lb in LanguageBackend]),
default=None,
help="Override the configured language backend.",
)
@click.option(
"--transport",
type=click.Choice(["stdio", "sse", "streamable-http"]),
default="stdio",
show_default=True,
help="Transport protocol.",
)
@click.option(
"--host",
type=str,
default="0.0.0.0",
show_default=True,
help="Listen address for the MCP server (when using corresponding transport).",
)
@click.option(
"--port", type=int, default=8000, show_default=True, help="Listen port for the MCP server (when using corresponding transport)."
)
@click.option("--enable-web-dashboard", type=bool, is_flag=False, default=None, help="Override dashboard setting in config.")
@click.option("--enable-gui-log-window", type=bool, is_flag=False, default=None, help="Override GUI log window setting in config.")
@click.option(
"--log-level",
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]),
default=None,
help="Override log level in config.",
)
@click.option("--trace-lsp-communication", type=bool, is_flag=False, default=None, help="Whether to trace LSP communication.")
@click.option("--tool-timeout", type=float, default=None, help="Override tool execution timeout in config.")
@click.option(
"--project-from-cwd",
is_flag=True,
default=False,
help="Auto-detect project from current working directory (searches for .serena/project.yml or .git, falls back to CWD). Intended for CLI-based agents like Claude Code, Gemini and Codex.",
)
def start_mcp_server(
project: str | None,
project_file_arg: str | None,
project_from_cwd: bool | None,
context: str,
modes: tuple[str, ...],
language_backend: str | None,
transport: Literal["stdio", "sse", "streamable-http"],
host: str,
port: int,
enable_web_dashboard: bool | None,
enable_gui_log_window: bool | None,
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None,
trace_lsp_communication: bool | None,
tool_timeout: float | None,
) -> None:
# initialize logging, using INFO level initially (will later be adjusted by SerenaAgent according to the config)
# * memory log handler (for use by GUI/Dashboard)
# * stream handler for stderr (for direct console output, which will also be captured by clients like Claude Desktop)
# * file handler
# (Note that stdout must never be used for logging, as it is used by the MCP server to communicate with the client.)
Logger.root.setLevel(logging.INFO)
formatter = logging.Formatter(SERENA_LOG_FORMAT)
memory_log_handler = MemoryLogHandler()
Logger.root.addHandler(memory_log_handler)
stderr_handler = logging.StreamHandler(stream=sys.stderr)
stderr_handler.formatter = formatter
Logger.root.addHandler(stderr_handler)
log_path = SerenaPaths().get_next_log_file_path("mcp")
file_handler = logging.FileHandler(log_path, mode="w")
file_handler.formatter = formatter
Logger.root.addHandler(file_handler)
log.info("Initializing Serena MCP server")
log.info("Storing logs in %s", log_path)
# Handle --project-from-cwd flag
if project_from_cwd:
if project is not None or project_file_arg is not None:
raise click.UsageError("--project-from-cwd cannot be used with --project or positional project argument")
project = find_project_root()
log.info("Auto-detected project root: %s", project)
project_file = project_file_arg or project
factory = SerenaMCPFactory(context=context, project=project_file, memory_log_handler=memory_log_handler)
server = factory.create_mcp_server(
host=host,
port=port,
modes=modes,
language_backend=LanguageBackend.from_str(language_backend) if language_backend else None,
enable_web_dashboard=enable_web_dashboard,
enable_gui_log_window=enable_gui_log_window,
log_level=log_level,
trace_lsp_communication=trace_lsp_communication,
tool_timeout=tool_timeout,
)
if project_file_arg:
log.warning(
"Positional project arg is deprecated; use --project instead. Used: %s",
project_file,
)
log.info("Starting MCP server …")
server.run(transport=transport)
@staticmethod
@click.command("print-system-prompt", help="Print the system prompt for a project.")
@click.argument("project", type=click.Path(exists=True), default=os.getcwd(), required=False)
@click.option(
"--log-level",
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]),
default="WARNING",
help="Log level for prompt generation.",
)
@click.option("--only-instructions", is_flag=True, help="Print only the initial instructions, without prefix/postfix.")
@click.option(
"--context", type=str, default=DEFAULT_CONTEXT, show_default=True, help="Built-in context name or path to custom context YAML."
)
@click.option(
"--mode",
"modes",
type=str,
multiple=True,
default=DEFAULT_MODES,
show_default=True,
help="Built-in mode names or paths to custom mode YAMLs.",
)
def print_system_prompt(project: str, log_level: str, only_instructions: bool, context: str, modes: tuple[str, ...]) -> None:
prefix = "You will receive access to Serena's symbolic tools. Below are instructions for using them, take them into account."
postfix = "You begin by acknowledging that you understood the above instructions and are ready to receive tasks."
from serena.tools.workflow_tools import InitialInstructionsTool
lvl = logging.getLevelNamesMapping()[log_level.upper()]
logging.configure(level=lvl)
context_instance = SerenaAgentContext.load(context)
mode_instances = [SerenaAgentMode.load(mode) for mode in modes]
agent = SerenaAgent(
project=os.path.abspath(project),
serena_config=SerenaConfig(web_dashboard=False, log_level=lvl),
context=context_instance,
modes=mode_instances,
)
tool = agent.get_tool(InitialInstructionsTool)
instr = tool.apply()
if only_instructions:
print(instr)
else:
print(f"{prefix}\n{instr}\n{postfix}")
class ModeCommands(AutoRegisteringGroup):
"""Group for 'mode' subcommands."""
def __init__(self) -> None:
super().__init__(name="mode", help="Manage Serena modes. You can run `mode <command> --help` for more info on each command.")
@staticmethod
@click.command("list", help="List available modes.")
def list() -> None:
mode_names = SerenaAgentMode.list_registered_mode_names()
max_len_name = max(len(name) for name in mode_names) if mode_names else 20
for name in mode_names:
mode_yml_path = SerenaAgentMode.get_path(name)
is_internal = Path(mode_yml_path).is_relative_to(SERENAS_OWN_MODE_YAMLS_DIR)
descriptor = "(internal)" if is_internal else f"(at {mode_yml_path})"
name_descr_string = f"{name:<{max_len_name + 4}}{descriptor}"
click.echo(name_descr_string)
@staticmethod
@click.command("create", help="Create a new mode or copy an internal one.")
@click.option(
"--name",
"-n",
type=str,
default=None,
help="Name for the new mode. If --from-internal is passed may be left empty to create a mode of the same name, which will then override the internal mode.",
)
@click.option("--from-internal", "from_internal", type=str, default=None, help="Copy from an internal mode.")
def create(name: str, from_internal: str) -> None:
if not (name or from_internal):
raise click.UsageError("Provide at least one of --name or --from-internal.")
mode_name = name or from_internal
dest = os.path.join(SerenaPaths().user_modes_dir, f"{mode_name}.yml")
src = (
os.path.join(SERENAS_OWN_MODE_YAMLS_DIR, f"{from_internal}.yml")
if from_internal
else os.path.join(SERENAS_OWN_MODE_YAMLS_DIR, "mode.template.yml")
)
if not os.path.exists(src):
raise FileNotFoundError(
f"Internal mode '{from_internal}' not found in {SERENAS_OWN_MODE_YAMLS_DIR}. Available modes: {SerenaAgentMode.list_registered_mode_names()}"
)
os.makedirs(os.path.dirname(dest), exist_ok=True)
shutil.copyfile(src, dest)
click.echo(f"Created mode '{mode_name}' at {dest}")
_open_in_editor(dest)
@staticmethod
@click.command("edit", help="Edit a custom mode YAML file.")
@click.argument("mode_name")
def edit(mode_name: str) -> None:
path = os.path.join(SerenaPaths().user_modes_dir, f"{mode_name}.yml")
if not os.path.exists(path):
if mode_name in SerenaAgentMode.list_registered_mode_names(include_user_modes=False):
click.echo(
f"Mode '{mode_name}' is an internal mode and cannot be edited directly. "
f"Use 'mode create --from-internal {mode_name}' to create a custom mode that overrides it before editing."
)
else:
click.echo(f"Custom mode '{mode_name}' not found. Create it with: mode create --name {mode_name}.")
return
_open_in_editor(path)
@staticmethod
@click.command("delete", help="Delete a custom mode file.")
@click.argument("mode_name")
def delete(mode_name: str) -> None:
path = os.path.join(SerenaPaths().user_modes_dir, f"{mode_name}.yml")
if not os.path.exists(path):
click.echo(f"Custom mode '{mode_name}' not found.")
return
os.remove(path)
click.echo(f"Deleted custom mode '{mode_name}'.")
class ContextCommands(AutoRegisteringGroup):
"""Group for 'context' subcommands."""
def __init__(self) -> None:
super().__init__(
name="context", help="Manage Serena contexts. You can run `context <command> --help` for more info on each command."
)
@staticmethod
@click.command("list", help="List available contexts.")
def list() -> None:
context_names = SerenaAgentContext.list_registered_context_names()
max_len_name = max(len(name) for name in context_names) if context_names else 20
for name in context_names:
context_yml_path = SerenaAgentContext.get_path(name)
is_internal = Path(context_yml_path).is_relative_to(SERENAS_OWN_CONTEXT_YAMLS_DIR)
descriptor = "(internal)" if is_internal else f"(at {context_yml_path})"
name_descr_string = f"{name:<{max_len_name + 4}}{descriptor}"
click.echo(name_descr_string)
@staticmethod
@click.command("create", help="Create a new context or copy an internal one.")
@click.option(
"--name",
"-n",
type=str,
default=None,
help="Name for the new context. If --from-internal is passed may be left empty to create a context of the same name, which will then override the internal context",
)
@click.option("--from-internal", "from_internal", type=str, default=None, help="Copy from an internal context.")
def create(name: str, from_internal: str) -> None:
if not (name or from_internal):
raise click.UsageError("Provide at least one of --name or --from-internal.")
ctx_name = name or from_internal
dest = os.path.join(SerenaPaths().user_contexts_dir, f"{ctx_name}.yml")
src = (
os.path.join(SERENAS_OWN_CONTEXT_YAMLS_DIR, f"{from_internal}.yml")
if from_internal
else os.path.join(SERENAS_OWN_CONTEXT_YAMLS_DIR, "context.template.yml")
)
if not os.path.exists(src):
raise FileNotFoundError(
f"Internal context '{from_internal}' not found in {SERENAS_OWN_CONTEXT_YAMLS_DIR}. Available contexts: {SerenaAgentContext.list_registered_context_names()}"
)
os.makedirs(os.path.dirname(dest), exist_ok=True)
shutil.copyfile(src, dest)
click.echo(f"Created context '{ctx_name}' at {dest}")
_open_in_editor(dest)
@staticmethod
@click.command("edit", help="Edit a custom context YAML file.")
@click.argument("context_name")
def edit(context_name: str) -> None:
path = os.path.join(SerenaPaths().user_contexts_dir, f"{context_name}.yml")
if not os.path.exists(path):
if context_name in SerenaAgentContext.list_registered_context_names(include_user_contexts=False):
click.echo(
f"Context '{context_name}' is an internal context and cannot be edited directly. "
f"Use 'context create --from-internal {context_name}' to create a custom context that overrides it before editing."
)
else:
click.echo(f"Custom context '{context_name}' not found. Create it with: context create --name {context_name}.")
return
_open_in_editor(path)
@staticmethod
@click.command("delete", help="Delete a custom context file.")
@click.argument("context_name")
def delete(context_name: str) -> None:
path = os.path.join(SerenaPaths().user_contexts_dir, f"{context_name}.yml")
if not os.path.exists(path):
click.echo(f"Custom context '{context_name}' not found.")
return
os.remove(path)
click.echo(f"Deleted custom context '{context_name}'.")
class SerenaConfigCommands(AutoRegisteringGroup):
"""Group for 'config' subcommands."""
def __init__(self) -> None:
super().__init__(name="config", help="Manage Serena configuration.")
@staticmethod
@click.command(
"edit", help="Edit serena_config.yml in your default editor. Will create a config file from the template if no config is found."
)
def edit() -> None:
serena_config = SerenaConfig.from_config_file()
assert serena_config.config_file_path is not None
_open_in_editor(serena_config.config_file_path)
class ProjectCommands(AutoRegisteringGroup):
"""Group for 'project' subcommands."""
def __init__(self) -> None:
super().__init__(
name="project", help="Manage Serena projects. You can run `project <command> --help` for more info on each command."
)
@staticmethod
def _create_project(project_path: str, name: str | None, language: tuple[str, ...]) -> ProjectConfig:
"""
Helper method to create a project configuration file.
:param project_path: Path to the project directory
:param name: Optional project name (defaults to directory name if not specified)
:param language: Tuple of language names
:return: The generated ProjectConfig instance
:raises FileExistsError: If project.yml already exists
:raises ValueError: If an unsupported language is specified
"""
yml_path = os.path.join(project_path, ProjectConfig.rel_path_to_project_yml())
if os.path.exists(yml_path):
raise FileExistsError(f"Project file {yml_path} already exists.")
languages: list[Language] = []
if language:
for lang in language:
try:
languages.append(Language(lang.lower()))
except ValueError:
all_langs = [l.value for l in Language]
raise ValueError(f"Unknown language '{lang}'. Supported: {all_langs}")
generated_conf = ProjectConfig.autogenerate(
project_root=project_path, project_name=name, languages=languages if languages else None, interactive=True
)
yml_path = ProjectConfig.path_to_project_yml(project_path)
languages_str = ", ".join([lang.value for lang in generated_conf.languages]) if generated_conf.languages else "N/A"
click.echo(f"Generated project with languages {{{languages_str}}} at {yml_path}.")
return generated_conf
@staticmethod
@click.command("create", help="Create a new Serena project configuration.")
@click.argument("project_path", type=click.Path(exists=True, file_okay=False), default=os.getcwd())
@click.option("--name", type=str, default=None, help="Project name; defaults to directory name if not specified.")
@click.option(
"--language", type=str, multiple=True, help="Programming language(s); inferred if not specified. Can be passed multiple times."
)
@click.option("--index", is_flag=True, help="Index the project after creation.")
@click.option(
"--log-level",
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]),
default="WARNING",
help="Log level for indexing (only used if --index is set).",
)
@click.option("--timeout", type=float, default=10, help="Timeout for indexing a single file (only used if --index is set).")
def create(project_path: str, name: str | None, language: tuple[str, ...], index: bool, log_level: str, timeout: float) -> None:
try:
ProjectCommands._create_project(project_path, name, language)
if index:
click.echo("Indexing project...")
ProjectCommands._index_project(project_path, log_level, timeout=timeout)
except FileExistsError as e:
raise click.ClickException(f"Project already exists: {e}\nUse 'serena project index' to index an existing project.")
except ValueError as e:
raise click.ClickException(str(e))
@staticmethod
@click.command("index", help="Index a project by saving symbols to the LSP cache. Auto-creates project.yml if it doesn't exist.")
@click.argument("project", type=click.Path(exists=True), default=os.getcwd(), required=False)
@click.option("--name", type=str, default=None, help="Project name (only used if auto-creating project.yml).")
@click.option(
"--language",
type=str,
multiple=True,
help="Programming language(s) (only used if auto-creating project.yml). Inferred if not specified.",
)
@click.option(
"--log-level",
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]),
default="WARNING",
help="Log level for indexing.",
)
@click.option("--timeout", type=float, default=10, help="Timeout for indexing a single file.")
def index(project: str, name: str | None, language: tuple[str, ...], log_level: str, timeout: float) -> None:
# Check if project.yml exists, if not auto-create it
yml_path = os.path.join(project, ProjectConfig.rel_path_to_project_yml())
if not os.path.exists(yml_path):
click.echo(f"Project configuration not found at {yml_path}. Auto-creating...")
try:
ProjectCommands._create_project(project, name, language)
except FileExistsError:
# Race condition - file was created between check and creation
pass
except ValueError as e:
raise click.ClickException(str(e))
ProjectCommands._index_project(project, log_level, timeout=timeout)
@staticmethod
@click.command("index-deprecated", help="Deprecated alias for 'serena project index'.")
@click.argument("project", type=click.Path(exists=True), default=os.getcwd(), required=False)
@click.option("--log-level", type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]), default="WARNING")
@click.option("--timeout", type=float, default=10, help="Timeout for indexing a single file.")
def index_deprecated(project: str, log_level: str, timeout: float) -> None:
click.echo("Deprecated! Use `serena project index` instead.")
ProjectCommands._index_project(project, log_level, timeout=timeout)
@staticmethod
def _index_project(project: str, log_level: str, timeout: float) -> None:
lvl = logging.getLevelNamesMapping()[log_level.upper()]
logging.configure(level=lvl)
serena_config = SerenaConfig.from_config_file()
proj = Project.load(os.path.abspath(project))
click.echo(f"Indexing symbols in project {project} …")
ls_mgr = proj.create_language_server_manager(
log_level=lvl, ls_timeout=timeout, ls_specific_settings=serena_config.ls_specific_settings
)
try:
log_file = os.path.join(project, ".serena", "logs", "indexing.txt")
files = proj.gather_source_files()
collected_exceptions: list[Exception] = []
files_failed = []
language_file_counts: dict[Language, int] = collections.defaultdict(lambda: 0)
for i, f in enumerate(tqdm(files, desc="Indexing")):
try:
ls = ls_mgr.get_language_server(f)
ls.request_document_symbols(f)
language_file_counts[ls.language] += 1
except Exception as e:
log.error(f"Failed to index {f}, continuing.")
collected_exceptions.append(e)
files_failed.append(f)
if (i + 1) % 10 == 0:
ls_mgr.save_all_caches()
reported_language_file_counts = {k.value: v for k, v in language_file_counts.items()}
click.echo(f"Indexed files per language: {dict_string(reported_language_file_counts, brackets=None)}")
ls_mgr.save_all_caches()
if len(files_failed) > 0:
os.makedirs(os.path.dirname(log_file), exist_ok=True)
with open(log_file, "w") as f:
for file, exception in zip(files_failed, collected_exceptions, strict=True):
f.write(f"{file}\n")
f.write(f"{exception}\n")
click.echo(f"Failed to index {len(files_failed)} files, see:\n{log_file}")
finally:
ls_mgr.stop_all()
@staticmethod
@click.command("is_ignored_path", help="Check if a path is ignored by the project configuration.")
@click.argument("path", type=click.Path(exists=False, file_okay=True, dir_okay=True))
@click.argument("project", type=click.Path(exists=True, file_okay=False, dir_okay=True), default=os.getcwd())
def is_ignored_path(path: str, project: str) -> None:
"""
Check if a given path is ignored by the project configuration.
:param path: The path to check.
:param project: The path to the project directory, defaults to the current working directory.
"""
proj = Project.load(os.path.abspath(project))
if os.path.isabs(path):
path = os.path.relpath(path, start=proj.project_root)
is_ignored = proj.is_ignored_path(path)
click.echo(f"Path '{path}' IS {'ignored' if is_ignored else 'IS NOT ignored'} by the project configuration.")
@staticmethod
@click.command("index-file", help="Index a single file by saving its symbols to the LSP cache.")
@click.argument("file", type=click.Path(exists=True, file_okay=True, dir_okay=False))
@click.argument("project", type=click.Path(exists=True, file_okay=False, dir_okay=True), default=os.getcwd())
@click.option("--verbose", "-v", is_flag=True, help="Print detailed information about the indexed symbols.")
def index_file(file: str, project: str, verbose: bool) -> None:
"""
Index a single file by saving its symbols to the LSP cache, useful for debugging.
:param file: path to the file to index, must be inside the project directory.
:param project: path to the project directory, defaults to the current working directory.
:param verbose: if set, prints detailed information about the indexed symbols.
"""
proj = Project.load(os.path.abspath(project))
if os.path.isabs(file):
file = os.path.relpath(file, start=proj.project_root)
if proj.is_ignored_path(file, ignore_non_source_files=True):
click.echo(f"'{file}' is ignored or declared as non-code file by the project configuration, won't index.")
exit(1)
ls_mgr = proj.create_language_server_manager()
try:
for ls in ls_mgr.iter_language_servers():
click.echo(f"Indexing for language {ls.language.value} …")
document_symbols = ls.request_document_symbols(file)
symbols, _ = document_symbols.get_all_symbols_and_roots()
if verbose:
click.echo(f"Symbols in file '{file}':")
for symbol in symbols:
click.echo(f" - {symbol['name']} at line {symbol['selectionRange']['start']['line']} of kind {symbol['kind']}")
ls.save_cache()
click.echo(f"Successfully indexed file '{file}', {len(symbols)} symbols saved to cache in {ls.cache_dir}.")
finally:
ls_mgr.stop_all()
@staticmethod
@click.command("health-check", help="Perform a comprehensive health check of the project's tools and language server.")
@click.argument("project", type=click.Path(exists=True, file_okay=False, dir_okay=True), default=os.getcwd())
def health_check(project: str) -> None:
"""
Perform a comprehensive health check of the project's tools and language server.
:param project: path to the project directory, defaults to the current working directory.
"""
# NOTE: completely written by Claude Code, only functionality was reviewed, not implementation
logging.configure(level=logging.INFO)
project_path = os.path.abspath(project)
proj = Project.load(project_path)
# Create log file with timestamp
timestamp = datetime_tag()
log_dir = os.path.join(project_path, ".serena", "logs", "health-checks")
os.makedirs(log_dir, exist_ok=True)
log_file = os.path.join(log_dir, f"health_check_{timestamp}.log")
with FileLoggerContext(log_file, append=False, enabled=True):
log.info("Starting health check for project: %s", project_path)
try:
# Create SerenaAgent with dashboard disabled
log.info("Creating SerenaAgent with disabled dashboard...")
config = SerenaConfig(gui_log_window_enabled=False, web_dashboard=False)
agent = SerenaAgent(project=project_path, serena_config=config)
log.info("SerenaAgent created successfully")
# Find first non-empty file that can be analyzed
log.info("Searching for analyzable files...")
files = proj.gather_source_files()
target_file = None
for file_path in files:
try:
full_path = os.path.join(project_path, file_path)
if os.path.getsize(full_path) > 0:
target_file = file_path
log.info("Found analyzable file: %s", target_file)
break
except (OSError, FileNotFoundError):
continue
if not target_file:
log.error("No analyzable files found in project")
click.echo("❌ Health check failed: No analyzable files found")
click.echo(f"Log saved to: {log_file}")
return
# Get tools from agent
overview_tool = agent.get_tool(GetSymbolsOverviewTool)
find_symbol_tool = agent.get_tool(FindSymbolTool)
find_refs_tool = agent.get_tool(FindReferencingSymbolsTool)
search_pattern_tool = agent.get_tool(SearchForPatternTool)
# Test 1: Get symbols overview
log.info("Testing GetSymbolsOverviewTool on file: %s", target_file)
overview_result = agent.execute_task(lambda: overview_tool.apply(target_file))
overview_data = json.loads(overview_result)
log.info("GetSymbolsOverviewTool returned %d symbols", len(overview_data))
if not overview_data:
log.error("No symbols found in file %s", target_file)
click.echo("❌ Health check failed: No symbols found in target file")
click.echo(f"Log saved to: {log_file}")
return
# Extract suitable symbol (prefer class or function over variables)
# LSP symbol kinds: 5=class, 12=function, 6=method, 9=constructor
preferred_kinds = [5, 12, 6, 9] # class, function, method, constructor
selected_symbol = None
for symbol in overview_data:
if symbol.get("kind") in preferred_kinds:
selected_symbol = symbol
break
# If no preferred symbol found, use first available
if not selected_symbol:
selected_symbol = overview_data[0]
log.info("No class or function found, using first available symbol")
symbol_name = selected_symbol.get("name_path", "unknown")
symbol_kind = selected_symbol.get("kind", "unknown")
log.info("Using symbol for testing: %s (kind: %d)", symbol_name, symbol_kind)
# Test 2: FindSymbolTool
log.info("Testing FindSymbolTool for symbol: %s", symbol_name)
find_symbol_result = agent.execute_task(
lambda: find_symbol_tool.apply(symbol_name, relative_path=target_file, include_body=True)
)
find_symbol_data = json.loads(find_symbol_result)
log.info("FindSymbolTool found %d matches for symbol %s", len(find_symbol_data), symbol_name)
# Test 3: FindReferencingSymbolsTool
log.info("Testing FindReferencingSymbolsTool for symbol: %s", symbol_name)
try:
find_refs_result = agent.execute_task(lambda: find_refs_tool.apply(symbol_name, relative_path=target_file))
find_refs_data = json.loads(find_refs_result)
log.info("FindReferencingSymbolsTool found %d references for symbol %s", len(find_refs_data), symbol_name)
except Exception as e:
log.warning("FindReferencingSymbolsTool failed for symbol %s: %s", symbol_name, str(e))
find_refs_data = []
# Test 4: SearchForPatternTool to verify references
log.info("Testing SearchForPatternTool for pattern: %s", symbol_name)
try:
search_result = agent.execute_task(
lambda: search_pattern_tool.apply(substring_pattern=symbol_name, restrict_search_to_code_files=True)
)
search_data = json.loads(search_result)
pattern_matches = sum(len(matches) for matches in search_data.values())
log.info("SearchForPatternTool found %d pattern matches for %s", pattern_matches, symbol_name)
except Exception as e:
log.warning("SearchForPatternTool failed for pattern %s: %s", symbol_name, str(e))
pattern_matches = 0
# Verify tools worked as expected
tools_working = True
if not find_symbol_data:
log.error("FindSymbolTool returned no results")
tools_working = False
if len(find_refs_data) == 0 and pattern_matches == 0:
log.warning("Both FindReferencingSymbolsTool and SearchForPatternTool found no matches - this might indicate an issue")
log.info("Health check completed successfully")
if tools_working:
click.echo("✅ Health check passed - All tools working correctly")
else:
click.echo("⚠️ Health check completed with warnings - Check log for details")
except Exception as e:
log.exception("Health check failed with exception: %s", str(e))
click.echo(f"❌ Health check failed: {e!s}")
finally:
click.echo(f"Log saved to: {log_file}")
class ToolCommands(AutoRegisteringGroup):
"""Group for 'tool' subcommands."""
def __init__(self) -> None:
super().__init__(
name="tools",
help="Commands related to Serena's tools. You can run `serena tools <command> --help` for more info on each command.",
)
@staticmethod
@click.command(
"list",
help="Prints an overview of the tools that are active by default (not just the active ones for your project). For viewing all tools, pass `--all / -a`",
)
@click.option("--quiet", "-q", is_flag=True)
@click.option("--all", "-a", "include_optional", is_flag=True, help="List all tools, including those not enabled by default.")
@click.option("--only-optional", is_flag=True, help="List only optional tools (those not enabled by default).")
def list(quiet: bool = False, include_optional: bool = False, only_optional: bool = False) -> None:
tool_registry = ToolRegistry()
if quiet:
if only_optional:
tool_names = tool_registry.get_tool_names_optional()
elif include_optional:
tool_names = tool_registry.get_tool_names()
else:
tool_names = tool_registry.get_tool_names_default_enabled()
for tool_name in tool_names:
click.echo(tool_name)
else:
ToolRegistry().print_tool_overview(include_optional=include_optional, only_optional=only_optional)
@staticmethod
@click.command(
"description",
help="Print the description of a tool, optionally with a specific context (the latter may modify the default description).",
)
@click.argument("tool_name", type=str)
@click.option("--context", type=str, default=None, help="Context name or path to context file.")
def description(tool_name: str, context: str | None = None) -> None:
# Load the context
serena_context = None
if context:
serena_context = SerenaAgentContext.load(context)
agent = SerenaAgent(
project=None,
serena_config=SerenaConfig(web_dashboard=False, log_level=logging.INFO),
context=serena_context,
)
tool = agent.get_tool_by_name(tool_name)
mcp_tool = SerenaMCPFactory.make_mcp_tool(tool)
click.echo(mcp_tool.description)
class PromptCommands(AutoRegisteringGroup):
def __init__(self) -> None:
super().__init__(name="prompts", help="Commands related to Serena's prompts that are outside of contexts and modes.")
@staticmethod
def _get_user_prompt_yaml_path(prompt_yaml_name: str) -> str:
templates_dir = SerenaPaths().user_prompt_templates_dir
os.makedirs(templates_dir, exist_ok=True)
return os.path.join(templates_dir, prompt_yaml_name)
@staticmethod
@click.command("list", help="Lists yamls that are used for defining prompts.")
def list() -> None:
serena_prompt_yaml_names = [os.path.basename(f) for f in glob.glob(PROMPT_TEMPLATES_DIR_INTERNAL + "/*.yml")]
for prompt_yaml_name in serena_prompt_yaml_names:
user_prompt_yaml_path = PromptCommands._get_user_prompt_yaml_path(prompt_yaml_name)
if os.path.exists(user_prompt_yaml_path):
click.echo(f"{user_prompt_yaml_path} merged with default prompts in {prompt_yaml_name}")
else:
click.echo(prompt_yaml_name)
@staticmethod
@click.command("create-override", help="Create an override of an internal prompts yaml for customizing Serena's prompts")
@click.argument("prompt_yaml_name")
def create_override(prompt_yaml_name: str) -> None:
"""
:param prompt_yaml_name: The yaml name of the prompt you want to override. Call the `list` command for discovering valid prompt yaml names.
:return:
"""
# for convenience, we can pass names without .yml
if not prompt_yaml_name.endswith(".yml"):
prompt_yaml_name = prompt_yaml_name + ".yml"
user_prompt_yaml_path = PromptCommands._get_user_prompt_yaml_path(prompt_yaml_name)
if os.path.exists(user_prompt_yaml_path):
raise FileExistsError(f"{user_prompt_yaml_path} already exists.")
serena_prompt_yaml_path = os.path.join(PROMPT_TEMPLATES_DIR_INTERNAL, prompt_yaml_name)
shutil.copyfile(serena_prompt_yaml_path, user_prompt_yaml_path)
_open_in_editor(user_prompt_yaml_path)
@staticmethod
@click.command("edit-override", help="Edit an existing prompt override file")
@click.argument("prompt_yaml_name")
def edit_override(prompt_yaml_name: str) -> None:
"""
:param prompt_yaml_name: The yaml name of the prompt override to edit.
:return:
"""
# for convenience, we can pass names without .yml
if not prompt_yaml_name.endswith(".yml"):
prompt_yaml_name = prompt_yaml_name + ".yml"
user_prompt_yaml_path = PromptCommands._get_user_prompt_yaml_path(prompt_yaml_name)
if not os.path.exists(user_prompt_yaml_path):
click.echo(f"Override file '{prompt_yaml_name}' not found. Create it with: prompts create-override {prompt_yaml_name}")
return
_open_in_editor(user_prompt_yaml_path)
@staticmethod
@click.command("list-overrides", help="List existing prompt override files")
def list_overrides() -> None:
user_templates_dir = SerenaPaths().user_prompt_templates_dir
os.makedirs(user_templates_dir, exist_ok=True)
serena_prompt_yaml_names = [os.path.basename(f) for f in glob.glob(PROMPT_TEMPLATES_DIR_INTERNAL + "/*.yml")]
override_files = glob.glob(os.path.join(user_templates_dir, "*.yml"))
for file_path in override_files:
if os.path.basename(file_path) in serena_prompt_yaml_names:
click.echo(file_path)
@staticmethod
@click.command("delete-override", help="Delete a prompt override file")
@click.argument("prompt_yaml_name")
def delete_override(prompt_yaml_name: str) -> None:
"""
:param prompt_yaml_name: The yaml name of the prompt override to delete."
:return:
"""
# for convenience, we can pass names without .yml
if not prompt_yaml_name.endswith(".yml"):
prompt_yaml_name = prompt_yaml_name + ".yml"
user_prompt_yaml_path = PromptCommands._get_user_prompt_yaml_path(prompt_yaml_name)
if not os.path.exists(user_prompt_yaml_path):
click.echo(f"Override file '{prompt_yaml_name}' not found.")
return
os.remove(user_prompt_yaml_path)
click.echo(f"Deleted override file '{prompt_yaml_name}'.")
# Expose groups so we can reference them in pyproject.toml
mode = ModeCommands()
context = ContextCommands()
project = ProjectCommands()
config = SerenaConfigCommands()
tools = ToolCommands()
prompts = PromptCommands()
# Expose toplevel commands for the same reason
top_level = TopLevelCommands()
start_mcp_server = top_level.start_mcp_server
index_project = project.index_deprecated
# needed for the help script to work - register all subcommands to the top-level group
for subgroup in (mode, context, project, config, tools, prompts):
top_level.add_command(subgroup)
def get_help() -> str:
"""Retrieve the help text for the top-level Serena CLI."""
return top_level.get_help(click.Context(top_level, info_name="serena"))
```