This is page 15 of 21. Use http://codebase.md/oraios/serena?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .devcontainer
│ └── devcontainer.json
├── .dockerignore
├── .env.example
├── .github
│ ├── FUNDING.yml
│ ├── ISSUE_TEMPLATE
│ │ ├── config.yml
│ │ ├── feature_request.md
│ │ └── issue--bug--performance-problem--question-.md
│ └── workflows
│ ├── codespell.yml
│ ├── docker.yml
│ ├── 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
--------------------------------------------------------------------------------
/test/solidlsp/python/test_symbol_retrieval.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Tests for the language server symbol-related functionality.
3 |
4 | These tests focus on the following methods:
5 | - request_containing_symbol
6 | - request_referencing_symbols
7 | """
8 |
9 | import os
10 |
11 | import pytest
12 |
13 | from serena.symbol import LanguageServerSymbol
14 | from solidlsp import SolidLanguageServer
15 | from solidlsp.ls_config import Language
16 | from solidlsp.ls_types import SymbolKind
17 |
18 | pytestmark = pytest.mark.python
19 |
20 |
21 | class TestLanguageServerSymbols:
22 | """Test the language server's symbol-related functionality."""
23 |
24 | @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True)
25 | def test_request_containing_symbol_function(self, language_server: SolidLanguageServer) -> None:
26 | """Test request_containing_symbol for a function."""
27 | # Test for a position inside the create_user method
28 | file_path = os.path.join("test_repo", "services.py")
29 | # Line 17 is inside the create_user method body
30 | containing_symbol = language_server.request_containing_symbol(file_path, 17, 20, include_body=True)
31 |
32 | # Verify that we found the containing symbol
33 | assert containing_symbol is not None
34 | assert containing_symbol["name"] == "create_user"
35 | assert containing_symbol["kind"] == SymbolKind.Method
36 | if "body" in containing_symbol:
37 | assert containing_symbol["body"].strip().startswith("def create_user(self")
38 |
39 | @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True)
40 | def test_references_to_variables(self, language_server: SolidLanguageServer) -> None:
41 | """Test request_referencing_symbols for a variable."""
42 | file_path = os.path.join("test_repo", "variables.py")
43 | # Line 75 contains the field status that is later modified
44 | ref_symbols = [ref.symbol for ref in language_server.request_referencing_symbols(file_path, 74, 4)]
45 |
46 | assert len(ref_symbols) > 0
47 | ref_lines = [ref["location"]["range"]["start"]["line"] for ref in ref_symbols if "location" in ref and "range" in ref["location"]]
48 | ref_names = [ref["name"] for ref in ref_symbols]
49 | assert 87 in ref_lines
50 | assert 95 in ref_lines
51 | assert "dataclass_instance" in ref_names
52 | assert "second_dataclass" in ref_names
53 |
54 | @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True)
55 | def test_request_containing_symbol_class(self, language_server: SolidLanguageServer) -> None:
56 | """Test request_containing_symbol for a class."""
57 | # Test for a position inside the UserService class but outside any method
58 | file_path = os.path.join("test_repo", "services.py")
59 | # Line 9 is the class definition line for UserService
60 | containing_symbol = language_server.request_containing_symbol(file_path, 9, 7)
61 |
62 | # Verify that we found the containing symbol
63 | assert containing_symbol is not None
64 | assert containing_symbol["name"] == "UserService"
65 | assert containing_symbol["kind"] == SymbolKind.Class
66 |
67 | @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True)
68 | def test_request_containing_symbol_nested(self, language_server: SolidLanguageServer) -> None:
69 | """Test request_containing_symbol with nested scopes."""
70 | # Test for a position inside a method which is inside a class
71 | file_path = os.path.join("test_repo", "services.py")
72 | # Line 18 is inside the create_user method inside UserService class
73 | containing_symbol = language_server.request_containing_symbol(file_path, 18, 25)
74 |
75 | # Verify that we found the innermost containing symbol (the method)
76 | assert containing_symbol is not None
77 | assert containing_symbol["name"] == "create_user"
78 | assert containing_symbol["kind"] == SymbolKind.Method
79 |
80 | # Get the parent containing symbol
81 | if "location" in containing_symbol and "range" in containing_symbol["location"]:
82 | parent_symbol = language_server.request_containing_symbol(
83 | file_path,
84 | containing_symbol["location"]["range"]["start"]["line"],
85 | containing_symbol["location"]["range"]["start"]["character"] - 1,
86 | )
87 |
88 | # Verify that the parent is the class
89 | assert parent_symbol is not None
90 | assert parent_symbol["name"] == "UserService"
91 | assert parent_symbol["kind"] == SymbolKind.Class
92 |
93 | @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True)
94 | def test_request_containing_symbol_none(self, language_server: SolidLanguageServer) -> None:
95 | """Test request_containing_symbol for a position with no containing symbol."""
96 | # Test for a position outside any function/class (e.g., in imports)
97 | file_path = os.path.join("test_repo", "services.py")
98 | # Line 1 is in imports, not inside any function or class
99 | containing_symbol = language_server.request_containing_symbol(file_path, 1, 10)
100 |
101 | # Should return None or an empty dictionary
102 | assert containing_symbol is None or containing_symbol == {}
103 |
104 | @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True)
105 | def test_request_referencing_symbols_function(self, language_server: SolidLanguageServer) -> None:
106 | """Test request_referencing_symbols for a function."""
107 | # Test referencing symbols for create_user function
108 | file_path = os.path.join("test_repo", "services.py")
109 | # Line 15 contains the create_user function definition
110 | symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots()
111 | create_user_symbol = next((s for s in symbols[0] if s.get("name") == "create_user"), None)
112 | if not create_user_symbol or "selectionRange" not in create_user_symbol:
113 | raise AssertionError("create_user symbol or its selectionRange not found")
114 | sel_start = create_user_symbol["selectionRange"]["start"]
115 | ref_symbols = [
116 | ref.symbol for ref in language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"])
117 | ]
118 | assert len(ref_symbols) > 0, "No referencing symbols found for create_user (selectionRange)"
119 |
120 | # Verify the structure of referencing symbols
121 | for symbol in ref_symbols:
122 | assert "name" in symbol
123 | assert "kind" in symbol
124 | if "location" in symbol and "range" in symbol["location"]:
125 | assert "start" in symbol["location"]["range"]
126 | assert "end" in symbol["location"]["range"]
127 |
128 | @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True)
129 | def test_request_referencing_symbols_class(self, language_server: SolidLanguageServer) -> None:
130 | """Test request_referencing_symbols for a class."""
131 | # Test referencing symbols for User class
132 | file_path = os.path.join("test_repo", "models.py")
133 | # Line 31 contains the User class definition
134 | symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots()
135 | user_symbol = next((s for s in symbols[0] if s.get("name") == "User"), None)
136 | if not user_symbol or "selectionRange" not in user_symbol:
137 | raise AssertionError("User symbol or its selectionRange not found")
138 | sel_start = user_symbol["selectionRange"]["start"]
139 | ref_symbols = [
140 | ref.symbol for ref in language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"])
141 | ]
142 | services_references = [
143 | symbol
144 | for symbol in ref_symbols
145 | if "location" in symbol and "uri" in symbol["location"] and "services.py" in symbol["location"]["uri"]
146 | ]
147 | assert len(services_references) > 0, "No referencing symbols from services.py for User (selectionRange)"
148 |
149 | @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True)
150 | def test_request_referencing_symbols_parameter(self, language_server: SolidLanguageServer) -> None:
151 | """Test request_referencing_symbols for a function parameter."""
152 | # Test referencing symbols for id parameter in get_user
153 | file_path = os.path.join("test_repo", "services.py")
154 | # Line 24 contains the get_user method with id parameter
155 | symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots()
156 | get_user_symbol = next((s for s in symbols[0] if s.get("name") == "get_user"), None)
157 | if not get_user_symbol or "selectionRange" not in get_user_symbol:
158 | raise AssertionError("get_user symbol or its selectionRange not found")
159 | sel_start = get_user_symbol["selectionRange"]["start"]
160 | ref_symbols = [
161 | ref.symbol for ref in language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"])
162 | ]
163 | method_refs = [
164 | symbol
165 | for symbol in ref_symbols
166 | if "location" in symbol and "range" in symbol["location"] and symbol["location"]["range"]["start"]["line"] > sel_start["line"]
167 | ]
168 | assert len(method_refs) > 0, "No referencing symbols within method body for get_user (selectionRange)"
169 |
170 | @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True)
171 | def test_request_referencing_symbols_none(self, language_server: SolidLanguageServer) -> None:
172 | """Test request_referencing_symbols for a position with no symbol."""
173 | # For positions with no symbol, the method might throw an error or return None/empty list
174 | # We'll modify our test to handle this by using a try-except block
175 |
176 | file_path = os.path.join("test_repo", "services.py")
177 | # Line 3 is a blank line or comment
178 | try:
179 | ref_symbols = [ref.symbol for ref in language_server.request_referencing_symbols(file_path, 3, 0)]
180 | # If we get here, make sure we got an empty result
181 | assert ref_symbols == [] or ref_symbols is None
182 | except Exception:
183 | # The method might raise an exception for invalid positions
184 | # which is acceptable behavior
185 | pass
186 |
187 | # Tests for request_defining_symbol
188 | @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True)
189 | def test_request_defining_symbol_variable(self, language_server: SolidLanguageServer) -> None:
190 | """Test request_defining_symbol for a variable usage."""
191 | # Test finding the definition of a symbol in the create_user method
192 | file_path = os.path.join("test_repo", "services.py")
193 | # Line 21 contains self.users[id] = user
194 | defining_symbol = language_server.request_defining_symbol(file_path, 21, 10)
195 |
196 | # Verify that we found the defining symbol
197 | # The defining symbol method returns a dictionary with information about the defining symbol
198 | assert defining_symbol is not None
199 | assert defining_symbol.get("name") == "create_user"
200 |
201 | # Verify the location and kind of the symbol
202 | # SymbolKind.Method = 6 for a method
203 | assert defining_symbol.get("kind") == SymbolKind.Method.value
204 | if "location" in defining_symbol and "uri" in defining_symbol["location"]:
205 | assert "services.py" in defining_symbol["location"]["uri"]
206 |
207 | @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True)
208 | def test_request_defining_symbol_imported_class(self, language_server: SolidLanguageServer) -> None:
209 | """Test request_defining_symbol for an imported class."""
210 | # Test finding the definition of the 'User' class used in the UserService.create_user method
211 | file_path = os.path.join("test_repo", "services.py")
212 | # Line 20 references 'User' which was imported from models
213 | defining_symbol = language_server.request_defining_symbol(file_path, 20, 15)
214 |
215 | # Verify that we found the defining symbol - this should be the User class from models
216 | assert defining_symbol is not None
217 | assert defining_symbol.get("name") == "User"
218 |
219 | @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True)
220 | def test_request_defining_symbol_method_call(self, language_server: SolidLanguageServer) -> None:
221 | """Test request_defining_symbol for a method call."""
222 | # Create an example file path for a file that calls UserService.create_user
223 | examples_file_path = os.path.join("examples", "user_management.py")
224 |
225 | # Find the line number where create_user is called
226 | # This could vary, so we'll use a relative position that makes sense
227 | defining_symbol = language_server.request_defining_symbol(examples_file_path, 10, 30)
228 |
229 | # Verify that we found the defining symbol - should be the create_user method
230 | # Because this might fail if the structure isn't exactly as expected, we'll use try-except
231 | try:
232 | assert defining_symbol is not None
233 | assert defining_symbol.get("name") == "create_user"
234 | # The defining symbol should be in the services.py file
235 | if "location" in defining_symbol and "uri" in defining_symbol["location"]:
236 | assert "services.py" in defining_symbol["location"]["uri"]
237 | except AssertionError:
238 | # If the file structure doesn't match what we expect, we can't guarantee this test
239 | # will pass, so we'll consider it a warning rather than a failure
240 | import warnings
241 |
242 | warnings.warn("Could not verify method call definition - file structure may differ from expected")
243 |
244 | @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True)
245 | def test_request_defining_symbol_none(self, language_server: SolidLanguageServer) -> None:
246 | """Test request_defining_symbol for a position with no symbol."""
247 | # Test for a position with no symbol (e.g., whitespace or comment)
248 | file_path = os.path.join("test_repo", "services.py")
249 | # Line 3 is a blank line
250 | defining_symbol = language_server.request_defining_symbol(file_path, 3, 0)
251 |
252 | # Should return None for positions with no symbol
253 | assert defining_symbol is None
254 |
255 | @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True)
256 | def test_request_containing_symbol_variable(self, language_server: SolidLanguageServer) -> None:
257 | """Test request_containing_symbol where the symbol is a variable."""
258 | # Test for a position inside a variable definition
259 | file_path = os.path.join("test_repo", "services.py")
260 | # Line 74 defines the 'user' variable
261 | containing_symbol = language_server.request_containing_symbol(file_path, 73, 1)
262 |
263 | # Verify that we found the containing symbol
264 | assert containing_symbol is not None
265 | assert containing_symbol["name"] == "user_var_str"
266 | assert containing_symbol["kind"] == SymbolKind.Variable
267 |
268 | @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True)
269 | def test_request_defining_symbol_nested_function(self, language_server: SolidLanguageServer) -> None:
270 | """Test request_defining_symbol for a nested function or closure."""
271 | # Use the existing nested.py file which contains nested classes and methods
272 | file_path = os.path.join("test_repo", "nested.py")
273 |
274 | # Test 1: Find definition of nested method - line with 'b = OuterClass().NestedClass().find_me()'
275 | defining_symbol = language_server.request_defining_symbol(file_path, 15, 35) # Position of find_me() call
276 |
277 | # This should resolve to the find_me method in the NestedClass
278 | assert defining_symbol is not None
279 | assert defining_symbol.get("name") == "find_me"
280 | assert defining_symbol.get("kind") == SymbolKind.Method.value
281 |
282 | # Test 2: Find definition of the nested class
283 | defining_symbol = language_server.request_defining_symbol(file_path, 15, 18) # Position of NestedClass
284 |
285 | # This should resolve to the NestedClass
286 | assert defining_symbol is not None
287 | assert defining_symbol.get("name") == "NestedClass"
288 | assert defining_symbol.get("kind") == SymbolKind.Class.value
289 |
290 | # Test 3: Find definition of a method-local function
291 | defining_symbol = language_server.request_defining_symbol(file_path, 9, 15) # Position inside func_within_func
292 |
293 | # This is challenging for many language servers and may fail
294 | try:
295 | assert defining_symbol is not None
296 | assert defining_symbol.get("name") == "func_within_func"
297 | except (AssertionError, TypeError, KeyError):
298 | # This is expected to potentially fail in many implementations
299 | import warnings
300 |
301 | warnings.warn("Could not resolve nested class method definition - implementation limitation")
302 |
303 | # Test 2: Find definition of the nested class
304 | defining_symbol = language_server.request_defining_symbol(file_path, 15, 18) # Position of NestedClass
305 |
306 | # This should resolve to the NestedClass
307 | assert defining_symbol is not None
308 | assert defining_symbol.get("name") == "NestedClass"
309 | assert defining_symbol.get("kind") == SymbolKind.Class.value
310 |
311 | # Test 3: Find definition of a method-local function
312 | defining_symbol = language_server.request_defining_symbol(file_path, 9, 15) # Position inside func_within_func
313 |
314 | # This is challenging for many language servers and may fail
315 | assert defining_symbol is not None
316 | assert defining_symbol.get("name") == "func_within_func"
317 | assert defining_symbol.get("kind") == SymbolKind.Function.value
318 |
319 | @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True)
320 | def test_symbol_methods_integration(self, language_server: SolidLanguageServer) -> None:
321 | """Test the integration between different symbol-related methods."""
322 | # This test demonstrates using the various symbol methods together
323 | # by finding a symbol and then checking its definition
324 |
325 | file_path = os.path.join("test_repo", "services.py")
326 |
327 | # First approach: Use a method from the UserService class
328 | # Step 1: Find a method we know exists
329 | containing_symbol = language_server.request_containing_symbol(file_path, 15, 8) # create_user method
330 | assert containing_symbol is not None
331 | assert containing_symbol["name"] == "create_user"
332 |
333 | # Step 2: Get the defining symbol for the same position
334 | # This should be the same method
335 | defining_symbol = language_server.request_defining_symbol(file_path, 15, 8)
336 | assert defining_symbol is not None
337 | assert defining_symbol["name"] == "create_user"
338 |
339 | # Step 3: Verify that they refer to the same symbol
340 | assert defining_symbol["kind"] == containing_symbol["kind"]
341 | if "location" in defining_symbol and "location" in containing_symbol:
342 | assert defining_symbol["location"]["uri"] == containing_symbol["location"]["uri"]
343 |
344 | # The integration test is successful if we've gotten this far,
345 | # as it demonstrates the integration between request_containing_symbol and request_defining_symbol
346 |
347 | # Try to get the container information for our method, but be flexible
348 | # since implementations may vary
349 | container_name = defining_symbol.get("containerName", None)
350 | if container_name and "UserService" in container_name:
351 | # If containerName contains UserService, that's a valid implementation
352 | pass
353 | else:
354 | # Try an alternative approach - looking for the containing class
355 | try:
356 | # Look for the class symbol in the file
357 | for line in range(5, 12): # Approximate range where UserService class should be defined
358 | symbol = language_server.request_containing_symbol(file_path, line, 5) # column 5 should be within class definition
359 | if symbol and symbol.get("name") == "UserService" and symbol.get("kind") == SymbolKind.Class.value:
360 | # Found the class - this is also a valid implementation
361 | break
362 | except Exception:
363 | # Just log a warning - this is an alternative verification and not essential
364 | import warnings
365 |
366 | warnings.warn("Could not verify container hierarchy - implementation detail")
367 |
368 | @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True)
369 | def test_symbol_tree_structure(self, language_server: SolidLanguageServer) -> None:
370 | """Test that the symbol tree structure is correctly built."""
371 | # Get all symbols in the test file
372 | repo_structure = language_server.request_full_symbol_tree()
373 | assert len(repo_structure) == 1
374 | # Assert that the root symbol is the test_repo directory
375 | assert repo_structure[0]["name"] == "test_repo"
376 | assert repo_structure[0]["kind"] == SymbolKind.Package
377 | assert "children" in repo_structure[0]
378 | # Assert that the children are the top-level packages
379 | child_names = {child["name"] for child in repo_structure[0]["children"]}
380 | child_kinds = {child["kind"] for child in repo_structure[0]["children"]}
381 | assert child_names == {"test_repo", "custom_test", "examples", "scripts"}
382 | assert child_kinds == {SymbolKind.Package}
383 | examples_package = next(child for child in repo_structure[0]["children"] if child["name"] == "examples")
384 | # assert that children are __init__ and user_management
385 | assert {child["name"] for child in examples_package["children"]} == {"__init__", "user_management"}
386 | assert {child["kind"] for child in examples_package["children"]} == {SymbolKind.File}
387 |
388 | # assert that tree of user_management node is same as retrieved directly
389 | user_management_node = next(child for child in examples_package["children"] if child["name"] == "user_management")
390 | if "location" in user_management_node and "relativePath" in user_management_node["location"]:
391 | user_management_rel_path = user_management_node["location"]["relativePath"]
392 | assert user_management_rel_path == os.path.join("examples", "user_management.py")
393 | _, user_management_roots = language_server.request_document_symbols(
394 | os.path.join("examples", "user_management.py")
395 | ).get_all_symbols_and_roots()
396 | assert user_management_roots == user_management_node["children"]
397 |
398 | @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True)
399 | def test_symbol_tree_structure_subdir(self, language_server: SolidLanguageServer) -> None:
400 | """Test that the symbol tree structure is correctly built."""
401 | # Get all symbols in the test file
402 | examples_package_roots = language_server.request_full_symbol_tree(within_relative_path="examples")
403 | assert len(examples_package_roots) == 1
404 | examples_package = examples_package_roots[0]
405 | assert examples_package["name"] == "examples"
406 | assert examples_package["kind"] == SymbolKind.Package
407 | # assert that children are __init__ and user_management
408 | assert {child["name"] for child in examples_package["children"]} == {"__init__", "user_management"}
409 | assert {child["kind"] for child in examples_package["children"]} == {SymbolKind.File}
410 |
411 | # assert that tree of user_management node is same as retrieved directly
412 | user_management_node = next(child for child in examples_package["children"] if child["name"] == "user_management")
413 | if "location" in user_management_node and "relativePath" in user_management_node["location"]:
414 | user_management_rel_path = user_management_node["location"]["relativePath"]
415 | assert user_management_rel_path == os.path.join("examples", "user_management.py")
416 | _, user_management_roots = language_server.request_document_symbols(
417 | os.path.join("examples", "user_management.py")
418 | ).get_all_symbols_and_roots()
419 | assert user_management_roots == user_management_node["children"]
420 |
421 | @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True)
422 | def test_request_dir_overview(self, language_server: SolidLanguageServer) -> None:
423 | """Test that request_dir_overview returns correct symbol information for files in a directory."""
424 | # Get overview of the examples directory
425 | overview = language_server.request_dir_overview("test_repo")
426 |
427 | # Verify that we have entries for both files
428 | assert os.path.join("test_repo", "nested.py") in overview
429 |
430 | # Get the symbols for user_management.py
431 | services_symbols = overview[os.path.join("test_repo", "services.py")]
432 | assert len(services_symbols) > 0
433 |
434 | # Check for specific symbols from services.py
435 | expected_symbols = {
436 | "UserService",
437 | "ItemService",
438 | "create_service_container",
439 | "user_var_str",
440 | "user_service",
441 | }
442 | retrieved_symbols = {symbol["name"] for symbol in services_symbols if "name" in symbol}
443 | assert expected_symbols.issubset(retrieved_symbols)
444 |
445 | @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True)
446 | def test_request_document_overview(self, language_server: SolidLanguageServer) -> None:
447 | """Test that request_document_overview returns correct symbol information for a file."""
448 | # Get overview of the user_management.py file
449 | overview = language_server.request_document_overview(os.path.join("examples", "user_management.py"))
450 |
451 | # Verify that we have entries for both files
452 | symbol_names = {LanguageServerSymbol(s_info).name for s_info in overview}
453 | assert {"UserStats", "UserManager", "process_user_data", "main"}.issubset(symbol_names)
454 |
455 | @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True)
456 | def test_containing_symbol_of_var_is_file(self, language_server: SolidLanguageServer) -> None:
457 | """Test that the containing symbol of a variable is the file itself."""
458 | # Get the containing symbol of a variable in a file
459 | file_path = os.path.join("test_repo", "services.py")
460 | # import of typing
461 | references_to_typing = [
462 | ref.symbol
463 | for ref in language_server.request_referencing_symbols(file_path, 4, 6, include_imports=False, include_file_symbols=True)
464 | ]
465 | assert {ref["kind"] for ref in references_to_typing} == {SymbolKind.File}
466 | assert {ref["body"] for ref in references_to_typing} == {""}
467 |
468 | # now include bodies
469 | references_to_typing = [
470 | ref.symbol
471 | for ref in language_server.request_referencing_symbols(
472 | file_path, 4, 6, include_imports=False, include_file_symbols=True, include_body=True
473 | )
474 | ]
475 | assert {ref["kind"] for ref in references_to_typing} == {SymbolKind.File}
476 | assert references_to_typing[0]["body"]
477 |
```
--------------------------------------------------------------------------------
/src/solidlsp/lsp_protocol_handler/lsp_requests.py:
--------------------------------------------------------------------------------
```python
1 | # Code generated. DO NOT EDIT.
2 | # LSP v3.17.0
3 | # TODO: Look into use of https://pypi.org/project/ts2python/ to generate the types for https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/
4 |
5 | """
6 | This file provides the python interface corresponding to the requests and notifications defined in Typescript in the language server protocol.
7 | This file is obtained from https://github.com/predragnikolic/OLSP under the MIT License with the following terms:
8 |
9 | MIT License
10 |
11 | Copyright (c) 2023 Предраг Николић
12 |
13 | Permission is hereby granted, free of charge, to any person obtaining a copy
14 | of this software and associated documentation files (the "Software"), to deal
15 | in the Software without restriction, including without limitation the rights
16 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17 | copies of the Software, and to permit persons to whom the Software is
18 | furnished to do so, subject to the following conditions:
19 |
20 | The above copyright notice and this permission notice shall be included in all
21 | copies or substantial portions of the Software.
22 |
23 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29 | SOFTWARE.
30 | """
31 |
32 | from typing import Any, Union
33 |
34 | from solidlsp.lsp_protocol_handler import lsp_types
35 |
36 |
37 | class LspRequest:
38 | def __init__(self, send_request: Any) -> None:
39 | self.send_request = send_request
40 |
41 | async def implementation(
42 | self, params: lsp_types.ImplementationParams
43 | ) -> Union["lsp_types.Definition", list["lsp_types.LocationLink"], None]:
44 | """A request to resolve the implementation locations of a symbol at a given text
45 | document position. The request's parameter is of type [TextDocumentPositionParams]
46 | (#TextDocumentPositionParams) the response is of type {@link Definition} or a
47 | Thenable that resolves to such.
48 | """
49 | return await self.send_request("textDocument/implementation", params)
50 |
51 | async def type_definition(
52 | self, params: lsp_types.TypeDefinitionParams
53 | ) -> Union["lsp_types.Definition", list["lsp_types.LocationLink"], None]:
54 | """A request to resolve the type definition locations of a symbol at a given text
55 | document position. The request's parameter is of type [TextDocumentPositionParams]
56 | (#TextDocumentPositionParams) the response is of type {@link Definition} or a
57 | Thenable that resolves to such.
58 | """
59 | return await self.send_request("textDocument/typeDefinition", params)
60 |
61 | async def document_color(self, params: lsp_types.DocumentColorParams) -> list["lsp_types.ColorInformation"]:
62 | """A request to list all color symbols found in a given text document. The request's
63 | parameter is of type {@link DocumentColorParams} the
64 | response is of type {@link ColorInformation ColorInformation[]} or a Thenable
65 | that resolves to such.
66 | """
67 | return await self.send_request("textDocument/documentColor", params)
68 |
69 | async def color_presentation(self, params: lsp_types.ColorPresentationParams) -> list["lsp_types.ColorPresentation"]:
70 | """A request to list all presentation for a color. The request's
71 | parameter is of type {@link ColorPresentationParams} the
72 | response is of type {@link ColorInformation ColorInformation[]} or a Thenable
73 | that resolves to such.
74 | """
75 | return await self.send_request("textDocument/colorPresentation", params)
76 |
77 | async def folding_range(self, params: lsp_types.FoldingRangeParams) -> list["lsp_types.FoldingRange"] | None:
78 | """A request to provide folding ranges in a document. The request's
79 | parameter is of type {@link FoldingRangeParams}, the
80 | response is of type {@link FoldingRangeList} or a Thenable
81 | that resolves to such.
82 | """
83 | return await self.send_request("textDocument/foldingRange", params)
84 |
85 | async def declaration(
86 | self, params: lsp_types.DeclarationParams
87 | ) -> Union["lsp_types.Declaration", list["lsp_types.LocationLink"], None]:
88 | """A request to resolve the type definition locations of a symbol at a given text
89 | document position. The request's parameter is of type [TextDocumentPositionParams]
90 | (#TextDocumentPositionParams) the response is of type {@link Declaration}
91 | or a typed array of {@link DeclarationLink} or a Thenable that resolves
92 | to such.
93 | """
94 | return await self.send_request("textDocument/declaration", params)
95 |
96 | async def selection_range(self, params: lsp_types.SelectionRangeParams) -> list["lsp_types.SelectionRange"] | None:
97 | """A request to provide selection ranges in a document. The request's
98 | parameter is of type {@link SelectionRangeParams}, the
99 | response is of type {@link SelectionRange SelectionRange[]} or a Thenable
100 | that resolves to such.
101 | """
102 | return await self.send_request("textDocument/selectionRange", params)
103 |
104 | async def prepare_call_hierarchy(self, params: lsp_types.CallHierarchyPrepareParams) -> list["lsp_types.CallHierarchyItem"] | None:
105 | """A request to result a `CallHierarchyItem` in a document at a given position.
106 | Can be used as an input to an incoming or outgoing call hierarchy.
107 |
108 | @since 3.16.0
109 | """
110 | return await self.send_request("textDocument/prepareCallHierarchy", params)
111 |
112 | async def incoming_calls(
113 | self, params: lsp_types.CallHierarchyIncomingCallsParams
114 | ) -> list["lsp_types.CallHierarchyIncomingCall"] | None:
115 | """A request to resolve the incoming calls for a given `CallHierarchyItem`.
116 |
117 | @since 3.16.0
118 | """
119 | return await self.send_request("callHierarchy/incomingCalls", params)
120 |
121 | async def outgoing_calls(
122 | self, params: lsp_types.CallHierarchyOutgoingCallsParams
123 | ) -> list["lsp_types.CallHierarchyOutgoingCall"] | None:
124 | """A request to resolve the outgoing calls for a given `CallHierarchyItem`.
125 |
126 | @since 3.16.0
127 | """
128 | return await self.send_request("callHierarchy/outgoingCalls", params)
129 |
130 | async def semantic_tokens_full(self, params: lsp_types.SemanticTokensParams) -> Union["lsp_types.SemanticTokens", None]:
131 | """@since 3.16.0"""
132 | return await self.send_request("textDocument/semanticTokens/full", params)
133 |
134 | async def semantic_tokens_delta(
135 | self, params: lsp_types.SemanticTokensDeltaParams
136 | ) -> Union["lsp_types.SemanticTokens", "lsp_types.SemanticTokensDelta", None]:
137 | """@since 3.16.0"""
138 | return await self.send_request("textDocument/semanticTokens/full/delta", params)
139 |
140 | async def semantic_tokens_range(self, params: lsp_types.SemanticTokensRangeParams) -> Union["lsp_types.SemanticTokens", None]:
141 | """@since 3.16.0"""
142 | return await self.send_request("textDocument/semanticTokens/range", params)
143 |
144 | async def linked_editing_range(self, params: lsp_types.LinkedEditingRangeParams) -> Union["lsp_types.LinkedEditingRanges", None]:
145 | """A request to provide ranges that can be edited together.
146 |
147 | @since 3.16.0
148 | """
149 | return await self.send_request("textDocument/linkedEditingRange", params)
150 |
151 | async def will_create_files(self, params: lsp_types.CreateFilesParams) -> Union["lsp_types.WorkspaceEdit", None]:
152 | """The will create files request is sent from the client to the server before files are actually
153 | created as long as the creation is triggered from within the client.
154 |
155 | @since 3.16.0
156 | """
157 | return await self.send_request("workspace/willCreateFiles", params)
158 |
159 | async def will_rename_files(self, params: lsp_types.RenameFilesParams) -> Union["lsp_types.WorkspaceEdit", None]:
160 | """The will rename files request is sent from the client to the server before files are actually
161 | renamed as long as the rename is triggered from within the client.
162 |
163 | @since 3.16.0
164 | """
165 | return await self.send_request("workspace/willRenameFiles", params)
166 |
167 | async def will_delete_files(self, params: lsp_types.DeleteFilesParams) -> Union["lsp_types.WorkspaceEdit", None]:
168 | """The did delete files notification is sent from the client to the server when
169 | files were deleted from within the client.
170 |
171 | @since 3.16.0
172 | """
173 | return await self.send_request("workspace/willDeleteFiles", params)
174 |
175 | async def moniker(self, params: lsp_types.MonikerParams) -> list["lsp_types.Moniker"] | None:
176 | """A request to get the moniker of a symbol at a given text document position.
177 | The request parameter is of type {@link TextDocumentPositionParams}.
178 | The response is of type {@link Moniker Moniker[]} or `null`.
179 | """
180 | return await self.send_request("textDocument/moniker", params)
181 |
182 | async def prepare_type_hierarchy(self, params: lsp_types.TypeHierarchyPrepareParams) -> list["lsp_types.TypeHierarchyItem"] | None:
183 | """A request to result a `TypeHierarchyItem` in a document at a given position.
184 | Can be used as an input to a subtypes or supertypes type hierarchy.
185 |
186 | @since 3.17.0
187 | """
188 | return await self.send_request("textDocument/prepareTypeHierarchy", params)
189 |
190 | async def type_hierarchy_supertypes(
191 | self, params: lsp_types.TypeHierarchySupertypesParams
192 | ) -> list["lsp_types.TypeHierarchyItem"] | None:
193 | """A request to resolve the supertypes for a given `TypeHierarchyItem`.
194 |
195 | @since 3.17.0
196 | """
197 | return await self.send_request("typeHierarchy/supertypes", params)
198 |
199 | async def type_hierarchy_subtypes(self, params: lsp_types.TypeHierarchySubtypesParams) -> list["lsp_types.TypeHierarchyItem"] | None:
200 | """A request to resolve the subtypes for a given `TypeHierarchyItem`.
201 |
202 | @since 3.17.0
203 | """
204 | return await self.send_request("typeHierarchy/subtypes", params)
205 |
206 | async def inline_value(self, params: lsp_types.InlineValueParams) -> list["lsp_types.InlineValue"] | None:
207 | """A request to provide inline values in a document. The request's parameter is of
208 | type {@link InlineValueParams}, the response is of type
209 | {@link InlineValue InlineValue[]} or a Thenable that resolves to such.
210 |
211 | @since 3.17.0
212 | """
213 | return await self.send_request("textDocument/inlineValue", params)
214 |
215 | async def inlay_hint(self, params: lsp_types.InlayHintParams) -> list["lsp_types.InlayHint"] | None:
216 | """A request to provide inlay hints in a document. The request's parameter is of
217 | type {@link InlayHintsParams}, the response is of type
218 | {@link InlayHint InlayHint[]} or a Thenable that resolves to such.
219 |
220 | @since 3.17.0
221 | """
222 | return await self.send_request("textDocument/inlayHint", params)
223 |
224 | async def resolve_inlay_hint(self, params: lsp_types.InlayHint) -> "lsp_types.InlayHint":
225 | """A request to resolve additional properties for an inlay hint.
226 | The request's parameter is of type {@link InlayHint}, the response is
227 | of type {@link InlayHint} or a Thenable that resolves to such.
228 |
229 | @since 3.17.0
230 | """
231 | return await self.send_request("inlayHint/resolve", params)
232 |
233 | async def text_document_diagnostic(self, params: lsp_types.DocumentDiagnosticParams) -> "lsp_types.DocumentDiagnosticReport":
234 | """The document diagnostic request definition.
235 |
236 | @since 3.17.0
237 | """
238 | return await self.send_request("textDocument/diagnostic", params)
239 |
240 | async def workspace_diagnostic(self, params: lsp_types.WorkspaceDiagnosticParams) -> "lsp_types.WorkspaceDiagnosticReport":
241 | """The workspace diagnostic request definition.
242 |
243 | @since 3.17.0
244 | """
245 | return await self.send_request("workspace/diagnostic", params)
246 |
247 | async def initialize(self, params: lsp_types.InitializeParams) -> "lsp_types.InitializeResult":
248 | """The initialize request is sent from the client to the server.
249 | It is sent once as the request after starting up the server.
250 | The requests parameter is of type {@link InitializeParams}
251 | the response if of type {@link InitializeResult} of a Thenable that
252 | resolves to such.
253 | """
254 | return await self.send_request("initialize", params)
255 |
256 | async def shutdown(self) -> None:
257 | """A shutdown request is sent from the client to the server.
258 | It is sent once when the client decides to shutdown the
259 | server. The only notification that is sent after a shutdown request
260 | is the exit event.
261 | """
262 | return await self.send_request("shutdown")
263 |
264 | async def will_save_wait_until(self, params: lsp_types.WillSaveTextDocumentParams) -> list["lsp_types.TextEdit"] | None:
265 | """A document will save request is sent from the client to the server before
266 | the document is actually saved. The request can return an array of TextEdits
267 | which will be applied to the text document before it is saved. Please note that
268 | clients might drop results if computing the text edits took too long or if a
269 | server constantly fails on this request. This is done to keep the save fast and
270 | reliable.
271 | """
272 | return await self.send_request("textDocument/willSaveWaitUntil", params)
273 |
274 | async def completion(
275 | self, params: lsp_types.CompletionParams
276 | ) -> Union[list["lsp_types.CompletionItem"], "lsp_types.CompletionList", None]:
277 | """Request to request completion at a given text document position. The request's
278 | parameter is of type {@link TextDocumentPosition} the response
279 | is of type {@link CompletionItem CompletionItem[]} or {@link CompletionList}
280 | or a Thenable that resolves to such.
281 |
282 | The request can delay the computation of the {@link CompletionItem.detail `detail`}
283 | and {@link CompletionItem.documentation `documentation`} properties to the `completionItem/resolve`
284 | request. However, properties that are needed for the initial sorting and filtering, like `sortText`,
285 | `filterText`, `insertText`, and `textEdit`, must not be changed during resolve.
286 | """
287 | return await self.send_request("textDocument/completion", params)
288 |
289 | async def resolve_completion_item(self, params: lsp_types.CompletionItem) -> "lsp_types.CompletionItem":
290 | """Request to resolve additional information for a given completion item.The request's
291 | parameter is of type {@link CompletionItem} the response
292 | is of type {@link CompletionItem} or a Thenable that resolves to such.
293 | """
294 | return await self.send_request("completionItem/resolve", params)
295 |
296 | async def hover(self, params: lsp_types.HoverParams) -> Union["lsp_types.Hover", None]:
297 | """Request to request hover information at a given text document position. The request's
298 | parameter is of type {@link TextDocumentPosition} the response is of
299 | type {@link Hover} or a Thenable that resolves to such.
300 | """
301 | return await self.send_request("textDocument/hover", params)
302 |
303 | async def signature_help(self, params: lsp_types.SignatureHelpParams) -> Union["lsp_types.SignatureHelp", None]:
304 | return await self.send_request("textDocument/signatureHelp", params)
305 |
306 | async def definition(self, params: lsp_types.DefinitionParams) -> Union["lsp_types.Definition", list["lsp_types.LocationLink"], None]:
307 | """A request to resolve the definition location of a symbol at a given text
308 | document position. The request's parameter is of type [TextDocumentPosition]
309 | (#TextDocumentPosition) the response is of either type {@link Definition}
310 | or a typed array of {@link DefinitionLink} or a Thenable that resolves
311 | to such.
312 | """
313 | return await self.send_request("textDocument/definition", params)
314 |
315 | async def references(self, params: lsp_types.ReferenceParams) -> list["lsp_types.Location"] | None:
316 | """A request to resolve project-wide references for the symbol denoted
317 | by the given text document position. The request's parameter is of
318 | type {@link ReferenceParams} the response is of type
319 | {@link Location Location[]} or a Thenable that resolves to such.
320 | """
321 | return await self.send_request("textDocument/references", params)
322 |
323 | async def document_highlight(self, params: lsp_types.DocumentHighlightParams) -> list["lsp_types.DocumentHighlight"] | None:
324 | """Request to resolve a {@link DocumentHighlight} for a given
325 | text document position. The request's parameter is of type [TextDocumentPosition]
326 | (#TextDocumentPosition) the request response is of type [DocumentHighlight[]]
327 | (#DocumentHighlight) or a Thenable that resolves to such.
328 | """
329 | return await self.send_request("textDocument/documentHighlight", params)
330 |
331 | async def document_symbol(
332 | self, params: lsp_types.DocumentSymbolParams
333 | ) -> list["lsp_types.SymbolInformation"] | list["lsp_types.DocumentSymbol"] | None:
334 | """A request to list all symbols found in a given text document. The request's
335 | parameter is of type {@link TextDocumentIdentifier} the
336 | response is of type {@link SymbolInformation SymbolInformation[]} or a Thenable
337 | that resolves to such.
338 | """
339 | return await self.send_request("textDocument/documentSymbol", params)
340 |
341 | async def code_action(self, params: lsp_types.CodeActionParams) -> list[Union["lsp_types.Command", "lsp_types.CodeAction"]] | None:
342 | """A request to provide commands for the given text document and range."""
343 | return await self.send_request("textDocument/codeAction", params)
344 |
345 | async def resolve_code_action(self, params: lsp_types.CodeAction) -> "lsp_types.CodeAction":
346 | """Request to resolve additional information for a given code action.The request's
347 | parameter is of type {@link CodeAction} the response
348 | is of type {@link CodeAction} or a Thenable that resolves to such.
349 | """
350 | return await self.send_request("codeAction/resolve", params)
351 |
352 | async def workspace_symbol(
353 | self, params: lsp_types.WorkspaceSymbolParams
354 | ) -> list["lsp_types.SymbolInformation"] | list["lsp_types.WorkspaceSymbol"] | None:
355 | """A request to list project-wide symbols matching the query string given
356 | by the {@link WorkspaceSymbolParams}. The response is
357 | of type {@link SymbolInformation SymbolInformation[]} or a Thenable that
358 | resolves to such.
359 |
360 | @since 3.17.0 - support for WorkspaceSymbol in the returned data. Clients
361 | need to advertise support for WorkspaceSymbols via the client capability
362 | `workspace.symbol.resolveSupport`.
363 | """
364 | return await self.send_request("workspace/symbol", params)
365 |
366 | async def resolve_workspace_symbol(self, params: lsp_types.WorkspaceSymbol) -> "lsp_types.WorkspaceSymbol":
367 | """A request to resolve the range inside the workspace
368 | symbol's location.
369 |
370 | @since 3.17.0
371 | """
372 | return await self.send_request("workspaceSymbol/resolve", params)
373 |
374 | async def code_lens(self, params: lsp_types.CodeLensParams) -> list["lsp_types.CodeLens"] | None:
375 | """A request to provide code lens for the given text document."""
376 | return await self.send_request("textDocument/codeLens", params)
377 |
378 | async def resolve_code_lens(self, params: lsp_types.CodeLens) -> "lsp_types.CodeLens":
379 | """A request to resolve a command for a given code lens."""
380 | return await self.send_request("codeLens/resolve", params)
381 |
382 | async def document_link(self, params: lsp_types.DocumentLinkParams) -> list["lsp_types.DocumentLink"] | None:
383 | """A request to provide document links"""
384 | return await self.send_request("textDocument/documentLink", params)
385 |
386 | async def resolve_document_link(self, params: lsp_types.DocumentLink) -> "lsp_types.DocumentLink":
387 | """Request to resolve additional information for a given document link. The request's
388 | parameter is of type {@link DocumentLink} the response
389 | is of type {@link DocumentLink} or a Thenable that resolves to such.
390 | """
391 | return await self.send_request("documentLink/resolve", params)
392 |
393 | async def formatting(self, params: lsp_types.DocumentFormattingParams) -> list["lsp_types.TextEdit"] | None:
394 | """A request to to format a whole document."""
395 | return await self.send_request("textDocument/formatting", params)
396 |
397 | async def range_formatting(self, params: lsp_types.DocumentRangeFormattingParams) -> list["lsp_types.TextEdit"] | None:
398 | """A request to to format a range in a document."""
399 | return await self.send_request("textDocument/rangeFormatting", params)
400 |
401 | async def on_type_formatting(self, params: lsp_types.DocumentOnTypeFormattingParams) -> list["lsp_types.TextEdit"] | None:
402 | """A request to format a document on type."""
403 | return await self.send_request("textDocument/onTypeFormatting", params)
404 |
405 | async def rename(self, params: lsp_types.RenameParams) -> Union["lsp_types.WorkspaceEdit", None]:
406 | """A request to rename a symbol."""
407 | return await self.send_request("textDocument/rename", params)
408 |
409 | async def prepare_rename(self, params: lsp_types.PrepareRenameParams) -> Union["lsp_types.PrepareRenameResult", None]:
410 | """A request to test and perform the setup necessary for a rename.
411 |
412 | @since 3.16 - support for default behavior
413 | """
414 | return await self.send_request("textDocument/prepareRename", params)
415 |
416 | async def execute_command(self, params: lsp_types.ExecuteCommandParams) -> Union["lsp_types.LSPAny", None]:
417 | """A request send from the client to the server to execute a command. The request might return
418 | a workspace edit which the client will apply to the workspace.
419 | """
420 | return await self.send_request("workspace/executeCommand", params)
421 |
422 |
423 | class LspNotification:
424 | def __init__(self, send_notification: Any) -> None:
425 | self.send_notification = send_notification
426 |
427 | def did_change_workspace_folders(self, params: lsp_types.DidChangeWorkspaceFoldersParams) -> None:
428 | """The `workspace/didChangeWorkspaceFolders` notification is sent from the client to the server when the workspace
429 | folder configuration changes.
430 | """
431 | return self.send_notification("workspace/didChangeWorkspaceFolders", params)
432 |
433 | def cancel_work_done_progress(self, params: lsp_types.WorkDoneProgressCancelParams) -> None:
434 | """The `window/workDoneProgress/cancel` notification is sent from the client to the server to cancel a progress
435 | initiated on the server side.
436 | """
437 | return self.send_notification("window/workDoneProgress/cancel", params)
438 |
439 | def did_create_files(self, params: lsp_types.CreateFilesParams) -> None:
440 | """The did create files notification is sent from the client to the server when
441 | files were created from within the client.
442 |
443 | @since 3.16.0
444 | """
445 | return self.send_notification("workspace/didCreateFiles", params)
446 |
447 | def did_rename_files(self, params: lsp_types.RenameFilesParams) -> None:
448 | """The did rename files notification is sent from the client to the server when
449 | files were renamed from within the client.
450 |
451 | @since 3.16.0
452 | """
453 | return self.send_notification("workspace/didRenameFiles", params)
454 |
455 | def did_delete_files(self, params: lsp_types.DeleteFilesParams) -> None:
456 | """The will delete files request is sent from the client to the server before files are actually
457 | deleted as long as the deletion is triggered from within the client.
458 |
459 | @since 3.16.0
460 | """
461 | return self.send_notification("workspace/didDeleteFiles", params)
462 |
463 | def did_open_notebook_document(self, params: lsp_types.DidOpenNotebookDocumentParams) -> None:
464 | """A notification sent when a notebook opens.
465 |
466 | @since 3.17.0
467 | """
468 | return self.send_notification("notebookDocument/didOpen", params)
469 |
470 | def did_change_notebook_document(self, params: lsp_types.DidChangeNotebookDocumentParams) -> None:
471 | return self.send_notification("notebookDocument/didChange", params)
472 |
473 | def did_save_notebook_document(self, params: lsp_types.DidSaveNotebookDocumentParams) -> None:
474 | """A notification sent when a notebook document is saved.
475 |
476 | @since 3.17.0
477 | """
478 | return self.send_notification("notebookDocument/didSave", params)
479 |
480 | def did_close_notebook_document(self, params: lsp_types.DidCloseNotebookDocumentParams) -> None:
481 | """A notification sent when a notebook closes.
482 |
483 | @since 3.17.0
484 | """
485 | return self.send_notification("notebookDocument/didClose", params)
486 |
487 | def initialized(self, params: lsp_types.InitializedParams) -> None:
488 | """The initialized notification is sent from the client to the
489 | server after the client is fully initialized and the server
490 | is allowed to send requests from the server to the client.
491 | """
492 | return self.send_notification("initialized", params)
493 |
494 | def exit(self) -> None:
495 | """The exit event is sent from the client to the server to
496 | ask the server to exit its process.
497 | """
498 | return self.send_notification("exit")
499 |
500 | def workspace_did_change_configuration(self, params: lsp_types.DidChangeConfigurationParams) -> None:
501 | """The configuration change notification is sent from the client to the server
502 | when the client's configuration has changed. The notification contains
503 | the changed configuration as defined by the language client.
504 | """
505 | return self.send_notification("workspace/didChangeConfiguration", params)
506 |
507 | def did_open_text_document(self, params: lsp_types.DidOpenTextDocumentParams) -> None:
508 | """The document open notification is sent from the client to the server to signal
509 | newly opened text documents. The document's truth is now managed by the client
510 | and the server must not try to read the document's truth using the document's
511 | uri. Open in this sense means it is managed by the client. It doesn't necessarily
512 | mean that its content is presented in an editor. An open notification must not
513 | be sent more than once without a corresponding close notification send before.
514 | This means open and close notification must be balanced and the max open count
515 | is one.
516 | """
517 | return self.send_notification("textDocument/didOpen", params)
518 |
519 | def did_change_text_document(self, params: lsp_types.DidChangeTextDocumentParams) -> None:
520 | """The document change notification is sent from the client to the server to signal
521 | changes to a text document.
522 | """
523 | return self.send_notification("textDocument/didChange", params)
524 |
525 | def did_close_text_document(self, params: lsp_types.DidCloseTextDocumentParams) -> None:
526 | """The document close notification is sent from the client to the server when
527 | the document got closed in the client. The document's truth now exists where
528 | the document's uri points to (e.g. if the document's uri is a file uri the
529 | truth now exists on disk). As with the open notification the close notification
530 | is about managing the document's content. Receiving a close notification
531 | doesn't mean that the document was open in an editor before. A close
532 | notification requires a previous open notification to be sent.
533 | """
534 | return self.send_notification("textDocument/didClose", params)
535 |
536 | def did_save_text_document(self, params: lsp_types.DidSaveTextDocumentParams) -> None:
537 | """The document save notification is sent from the client to the server when
538 | the document got saved in the client.
539 | """
540 | return self.send_notification("textDocument/didSave", params)
541 |
542 | def will_save_text_document(self, params: lsp_types.WillSaveTextDocumentParams) -> None:
543 | """A document will save notification is sent from the client to the server before
544 | the document is actually saved.
545 | """
546 | return self.send_notification("textDocument/willSave", params)
547 |
548 | def did_change_watched_files(self, params: lsp_types.DidChangeWatchedFilesParams) -> None:
549 | """The watched files notification is sent from the client to the server when
550 | the client detects changes to file watched by the language client.
551 | """
552 | return self.send_notification("workspace/didChangeWatchedFiles", params)
553 |
554 | def set_trace(self, params: lsp_types.SetTraceParams) -> None:
555 | return self.send_notification("$/setTrace", params)
556 |
557 | def cancel_request(self, params: lsp_types.CancelParams) -> None:
558 | return self.send_notification("$/cancelRequest", params)
559 |
560 | def progress(self, params: lsp_types.ProgressParams) -> None:
561 | return self.send_notification("$/progress", params)
562 |
```
--------------------------------------------------------------------------------
/src/serena/config/serena_config.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | The Serena Model Context Protocol (MCP) Server
3 | """
4 |
5 | import dataclasses
6 | import os
7 | import shutil
8 | from collections.abc import Sequence
9 | from copy import deepcopy
10 | from dataclasses import dataclass, field
11 | from datetime import datetime
12 | from enum import Enum
13 | from functools import cached_property
14 | from pathlib import Path
15 | from typing import TYPE_CHECKING, Any, Optional, Self, TypeVar
16 |
17 | import yaml
18 | from ruamel.yaml.comments import CommentedMap
19 | from sensai.util import logging
20 | from sensai.util.logging import LogTime, datetime_tag
21 | from sensai.util.string import ToStringMixin
22 |
23 | from serena.constants import (
24 | DEFAULT_SOURCE_FILE_ENCODING,
25 | PROJECT_TEMPLATE_FILE,
26 | REPO_ROOT,
27 | SERENA_CONFIG_TEMPLATE_FILE,
28 | SERENA_FILE_ENCODING,
29 | SERENA_MANAGED_DIR_NAME,
30 | )
31 | from serena.util.general import get_dataclass_default, load_yaml, save_yaml
32 | from serena.util.inspection import determine_programming_language_composition
33 | from solidlsp.ls_config import Language
34 |
35 | from ..analytics import RegisteredTokenCountEstimator
36 | from ..util.class_decorators import singleton
37 | from ..util.cli_util import ask_yes_no
38 |
39 | if TYPE_CHECKING:
40 | from ..project import Project
41 |
42 | log = logging.getLogger(__name__)
43 | T = TypeVar("T")
44 | DEFAULT_TOOL_TIMEOUT: float = 240
45 | DictType = dict | CommentedMap
46 | TDict = TypeVar("TDict", bound=DictType)
47 |
48 |
49 | @singleton
50 | class SerenaPaths:
51 | """
52 | Provides paths to various Serena-related directories and files.
53 | """
54 |
55 | def __init__(self) -> None:
56 | home_dir = os.getenv("SERENA_HOME")
57 | if home_dir is None or home_dir.strip() == "":
58 | home_dir = str(Path.home() / SERENA_MANAGED_DIR_NAME)
59 | else:
60 | home_dir = home_dir.strip()
61 | self.serena_user_home_dir: str = home_dir
62 | """
63 | the path to the Serena home directory, where the user's configuration/data is stored.
64 | This is ~/.serena by default, but it can be overridden via the SERENA_HOME environment variable.
65 | """
66 | self.user_prompt_templates_dir: str = os.path.join(self.serena_user_home_dir, "prompt_templates")
67 | """
68 | directory containing prompt templates defined by the user.
69 | Prompts defined by the user take precedence over Serena's built-in prompt templates.
70 | """
71 | self.user_contexts_dir: str = os.path.join(self.serena_user_home_dir, "contexts")
72 | """
73 | directory containing contexts defined by the user.
74 | If a name of a context matches a name of a context in SERENAS_OWN_CONTEXT_YAMLS_DIR,
75 | the user context will override the default context definition.
76 | """
77 | self.user_modes_dir: str = os.path.join(self.serena_user_home_dir, "modes")
78 | """
79 | directory containing modes defined by the user.
80 | If a name of a mode matches a name of a mode in SERENAS_OWN_MODES_YAML_DIR,
81 | the user mode will override the default mode definition.
82 | """
83 |
84 | def get_next_log_file_path(self, prefix: str) -> str:
85 | """
86 | :param prefix: the filename prefix indicating the type of the log file
87 | :return: the full path to the log file to use
88 | """
89 | log_dir = os.path.join(self.serena_user_home_dir, "logs", datetime.now().strftime("%Y-%m-%d"))
90 | os.makedirs(log_dir, exist_ok=True)
91 | return os.path.join(log_dir, prefix + "_" + datetime_tag() + ".txt")
92 |
93 | # TODO: Paths from constants.py should be moved here
94 |
95 |
96 | @dataclass
97 | class ToolInclusionDefinition:
98 | """
99 | Defines which tools to include/exclude in Serena's operation.
100 | This can mean either
101 | * defining exclusions/inclusions to apply to an existing set of tools [incremental mode], or
102 | * defining a fixed set of tools to use [fixed mode].
103 | """
104 |
105 | excluded_tools: Sequence[str] = ()
106 | """
107 | the names of tools to exclude from use [incremental mode]
108 | """
109 | included_optional_tools: Sequence[str] = ()
110 | """
111 | the names of optional tools to include [incremental mode]
112 | """
113 | fixed_tools: Sequence[str] = ()
114 | """
115 | the names of tools to use as a fixed set of tools [fixed mode]
116 | """
117 |
118 | def is_fixed_tool_set(self) -> bool:
119 | num_fixed = len(self.fixed_tools)
120 | num_incremental = len(self.excluded_tools) + len(self.included_optional_tools)
121 | if num_fixed > 0 and num_incremental > 0:
122 | raise ValueError("Cannot use both fixed_tools and excluded_tools/included_optional_tools at the same time.")
123 | return num_fixed > 0
124 |
125 |
126 | class SerenaConfigError(Exception):
127 | pass
128 |
129 |
130 | def get_serena_managed_in_project_dir(project_root: str | Path) -> str:
131 | return os.path.join(project_root, SERENA_MANAGED_DIR_NAME)
132 |
133 |
134 | class LanguageBackend(Enum):
135 | LSP = "LSP"
136 | """
137 | Use the language server protocol (LSP), spawning freely available language servers
138 | via the SolidLSP library that is part of Serena
139 | """
140 | JETBRAINS = "JetBrains"
141 | """
142 | Use the Serena plugin in your JetBrains IDE.
143 | (requires the plugin to be installed and the project being worked on to be open in your IDE)
144 | """
145 |
146 | @staticmethod
147 | def from_str(backend_str: str) -> "LanguageBackend":
148 | for backend in LanguageBackend:
149 | if backend.value.lower() == backend_str.lower():
150 | return backend
151 | raise ValueError(f"Unknown language backend '{backend_str}': valid values are {[b.value for b in LanguageBackend]}")
152 |
153 |
154 | @dataclass(kw_only=True)
155 | class ProjectConfig(ToolInclusionDefinition, ToStringMixin):
156 | project_name: str
157 | languages: list[Language]
158 | ignored_paths: list[str] = field(default_factory=list)
159 | read_only: bool = False
160 | ignore_all_files_in_gitignore: bool = True
161 | initial_prompt: str = ""
162 | encoding: str = DEFAULT_SOURCE_FILE_ENCODING
163 |
164 | SERENA_DEFAULT_PROJECT_FILE = "project.yml"
165 |
166 | def _tostring_includes(self) -> list[str]:
167 | return ["project_name"]
168 |
169 | @classmethod
170 | def autogenerate(
171 | cls,
172 | project_root: str | Path,
173 | project_name: str | None = None,
174 | languages: list[Language] | None = None,
175 | save_to_disk: bool = True,
176 | interactive: bool = False,
177 | ) -> Self:
178 | """
179 | Autogenerate a project configuration for a given project root.
180 |
181 | :param project_root: the path to the project root
182 | :param project_name: the name of the project; if None, the name of the project will be the name of the directory
183 | containing the project
184 | :param languages: the languages of the project; if None, they will be determined automatically
185 | :param save_to_disk: whether to save the project configuration to disk
186 | :param interactive: whether to run in interactive CLI mode, asking the user for input where appropriate
187 | :return: the project configuration
188 | """
189 | project_root = Path(project_root).resolve()
190 | if not project_root.exists():
191 | raise FileNotFoundError(f"Project root not found: {project_root}")
192 | with LogTime("Project configuration auto-generation", logger=log):
193 | log.info("Project root: %s", project_root)
194 | project_name = project_name or project_root.name
195 | if languages is None:
196 | # determine languages automatically
197 | log.info("Determining programming languages used in the project")
198 | language_composition = determine_programming_language_composition(str(project_root))
199 | log.info("Language composition: %s", language_composition)
200 | if len(language_composition) == 0:
201 | language_values = ", ".join([lang.value for lang in Language])
202 | raise ValueError(
203 | f"No source files found in {project_root}\n\n"
204 | f"To use Serena with this project, you need to either\n"
205 | f" 1. specify a programming language by adding parameters --language <language>\n"
206 | f" when creating the project via the Serena CLI command OR\n"
207 | f" 2. add source files in one of the supported languages first.\n\n"
208 | f"Supported languages are: {language_values}\n"
209 | f"Read the documentation for more information."
210 | )
211 | # sort languages by number of files found
212 | languages_and_percentages = sorted(
213 | language_composition.items(), key=lambda item: (item[1], item[0].get_priority()), reverse=True
214 | )
215 | # find the language with the highest percentage and enable it
216 | top_language_pair = languages_and_percentages[0]
217 | other_language_pairs = languages_and_percentages[1:]
218 | languages_to_use: list[str] = [top_language_pair[0].value]
219 | # if in interactive mode, ask the user which other languages to enable
220 | if len(other_language_pairs) > 0 and interactive:
221 | print(
222 | "Detected and enabled main language '%s' (%.2f%% of source files)."
223 | % (top_language_pair[0].value, top_language_pair[1])
224 | )
225 | print(f"Additionally detected {len(other_language_pairs)} other language(s).\n")
226 | print("Note: Enable only languages you need symbolic retrieval/editing capabilities for.")
227 | print(" Additional language servers use resources and some languages may require additional")
228 | print(" system-level installations/configuration (see Serena documentation).")
229 | print("\nWhich additional languages do you want to enable?")
230 | for lang, perc in other_language_pairs:
231 | enable = ask_yes_no("Enable %s (%.2f%% of source files)?" % (lang.value, perc), default=False)
232 | if enable:
233 | languages_to_use.append(lang.value)
234 | print()
235 | log.info("Using languages: %s", languages_to_use)
236 | else:
237 | languages_to_use = [lang.value for lang in languages]
238 | config_with_comments = cls.load_commented_map(PROJECT_TEMPLATE_FILE)
239 | config_with_comments["project_name"] = project_name
240 | config_with_comments["languages"] = languages_to_use
241 | if save_to_disk:
242 | project_yml_path = cls.path_to_project_yml(project_root)
243 | log.info("Saving project configuration to %s", project_yml_path)
244 | save_yaml(project_yml_path, config_with_comments, preserve_comments=True)
245 | return cls._from_dict(config_with_comments)
246 |
247 | @classmethod
248 | def path_to_project_yml(cls, project_root: str | Path) -> str:
249 | return os.path.join(project_root, cls.rel_path_to_project_yml())
250 |
251 | @classmethod
252 | def rel_path_to_project_yml(cls) -> str:
253 | return os.path.join(SERENA_MANAGED_DIR_NAME, cls.SERENA_DEFAULT_PROJECT_FILE)
254 |
255 | @classmethod
256 | def _apply_defaults_to_dict(cls, data: TDict) -> TDict:
257 | # apply defaults for new fields
258 | data["languages"] = data.get("languages", [])
259 | data["ignored_paths"] = data.get("ignored_paths", [])
260 | data["excluded_tools"] = data.get("excluded_tools", [])
261 | data["included_optional_tools"] = data.get("included_optional_tools", [])
262 | data["read_only"] = data.get("read_only", False)
263 | data["ignore_all_files_in_gitignore"] = data.get("ignore_all_files_in_gitignore", True)
264 | data["initial_prompt"] = data.get("initial_prompt", "")
265 | data["encoding"] = data.get("encoding", DEFAULT_SOURCE_FILE_ENCODING)
266 |
267 | # backward compatibility: handle single "language" field
268 | if len(data["languages"]) == 0 and "language" in data:
269 | data["languages"] = [data["language"]]
270 | if "language" in data:
271 | del data["language"]
272 |
273 | return data
274 |
275 | @classmethod
276 | def load_commented_map(cls, yml_path: str) -> CommentedMap:
277 | """
278 | Load the project configuration as a CommentedMap, preserving comments and ensuring
279 | completeness of the configuration by applying default values for missing fields
280 | and backward compatibility adjustments.
281 |
282 | :param yml_path: the path to the project.yml file
283 | :return: a CommentedMap representing a full project configuration
284 | """
285 | data = load_yaml(yml_path, preserve_comments=True)
286 | return cls._apply_defaults_to_dict(data)
287 |
288 | @classmethod
289 | def _from_dict(cls, data: dict[str, Any]) -> Self:
290 | """
291 | Create a ProjectConfig instance from a (full) configuration dictionary
292 | """
293 | lang_name_mapping = {"javascript": "typescript"}
294 | languages: list[Language] = []
295 | for language_str in data["languages"]:
296 | orig_language_str = language_str
297 | try:
298 | language_str = language_str.lower()
299 | if language_str in lang_name_mapping:
300 | language_str = lang_name_mapping[language_str]
301 | language = Language(language_str)
302 | languages.append(language)
303 | except ValueError as e:
304 | raise ValueError(
305 | f"Invalid language: {orig_language_str}.\nValid language_strings are: {[l.value for l in Language]}"
306 | ) from e
307 |
308 | return cls(
309 | project_name=data["project_name"],
310 | languages=languages,
311 | ignored_paths=data["ignored_paths"],
312 | excluded_tools=data["excluded_tools"],
313 | included_optional_tools=data["included_optional_tools"],
314 | read_only=data["read_only"],
315 | ignore_all_files_in_gitignore=data["ignore_all_files_in_gitignore"],
316 | initial_prompt=data["initial_prompt"],
317 | encoding=data["encoding"],
318 | )
319 |
320 | def to_yaml_dict(self) -> dict:
321 | """
322 | :return: a yaml-serializable dictionary representation of this configuration
323 | """
324 | d = dataclasses.asdict(self)
325 | d["languages"] = [lang.value for lang in self.languages]
326 | return d
327 |
328 | @classmethod
329 | def load(cls, project_root: Path | str, autogenerate: bool = False) -> Self:
330 | """
331 | Load a ProjectConfig instance from the path to the project root.
332 | """
333 | project_root = Path(project_root)
334 | yaml_path = project_root / cls.rel_path_to_project_yml()
335 | if not yaml_path.exists():
336 | if autogenerate:
337 | return cls.autogenerate(project_root)
338 | else:
339 | raise FileNotFoundError(f"Project configuration file not found: {yaml_path}")
340 | yaml_data = cls.load_commented_map(str(yaml_path))
341 | if "project_name" not in yaml_data:
342 | yaml_data["project_name"] = project_root.name
343 | return cls._from_dict(yaml_data)
344 |
345 |
346 | class RegisteredProject(ToStringMixin):
347 | def __init__(self, project_root: str, project_config: "ProjectConfig", project_instance: Optional["Project"] = None) -> None:
348 | """
349 | Represents a registered project in the Serena configuration.
350 |
351 | :param project_root: the root directory of the project
352 | :param project_config: the configuration of the project
353 | """
354 | self.project_root = Path(project_root).resolve()
355 | self.project_config = project_config
356 | self._project_instance = project_instance
357 |
358 | def _tostring_exclude_private(self) -> bool:
359 | return True
360 |
361 | @property
362 | def project_name(self) -> str:
363 | return self.project_config.project_name
364 |
365 | @classmethod
366 | def from_project_instance(cls, project_instance: "Project") -> "RegisteredProject":
367 | return RegisteredProject(
368 | project_root=project_instance.project_root,
369 | project_config=project_instance.project_config,
370 | project_instance=project_instance,
371 | )
372 |
373 | def matches_root_path(self, path: str | Path) -> bool:
374 | """
375 | Check if the given path matches the project root path.
376 |
377 | :param path: the path to check
378 | :return: True if the path matches the project root, False otherwise
379 | """
380 | return self.project_root == Path(path).resolve()
381 |
382 | def get_project_instance(self) -> "Project":
383 | """
384 | Returns the project instance for this registered project, loading it if necessary.
385 | """
386 | if self._project_instance is None:
387 | from ..project import Project
388 |
389 | with LogTime(f"Loading project instance for {self}", logger=log):
390 | self._project_instance = Project(project_root=str(self.project_root), project_config=self.project_config)
391 | return self._project_instance
392 |
393 |
394 | @dataclass(kw_only=True)
395 | class SerenaConfig(ToolInclusionDefinition, ToStringMixin):
396 | """
397 | Holds the Serena agent configuration, which is typically loaded from a YAML configuration file
398 | (when instantiated via :method:`from_config_file`), which is updated when projects are added or removed.
399 | For testing purposes, it can also be instantiated directly with the desired parameters.
400 | """
401 |
402 | projects: list[RegisteredProject] = field(default_factory=list)
403 | gui_log_window_enabled: bool = False
404 | log_level: int = logging.INFO
405 | trace_lsp_communication: bool = False
406 | web_dashboard: bool = True
407 | web_dashboard_open_on_launch: bool = True
408 | web_dashboard_listen_address: str = "127.0.0.1"
409 | tool_timeout: float = DEFAULT_TOOL_TIMEOUT
410 | loaded_commented_yaml: CommentedMap | None = None
411 | config_file_path: str | None = None
412 | """
413 | the path to the configuration file to which updates of the configuration shall be saved;
414 | if None, the configuration is not saved to disk
415 | """
416 |
417 | language_backend: LanguageBackend = LanguageBackend.LSP
418 | """
419 | the language backend to use for code understanding features
420 | """
421 |
422 | token_count_estimator: str = RegisteredTokenCountEstimator.CHAR_COUNT.name
423 | """Only relevant if `record_tool_usage` is True; the name of the token count estimator to use for tool usage statistics.
424 | See the `RegisteredTokenCountEstimator` enum for available options.
425 |
426 | Note: some token estimators (like tiktoken) may require downloading data files
427 | on the first run, which can take some time and require internet access. Others, like the Anthropic ones, may require an API key
428 | and rate limits may apply.
429 | """
430 | default_max_tool_answer_chars: int = 150_000
431 | """Used as default for tools where the apply method has a default maximal answer length.
432 | Even though the value of the max_answer_chars can be changed when calling the tool, it may make sense to adjust this default
433 | through the global configuration.
434 | """
435 | ls_specific_settings: dict = field(default_factory=dict)
436 | """Advanced configuration option allowing to configure language server implementation specific options, see SolidLSPSettings for more info."""
437 |
438 | CONFIG_FILE = "serena_config.yml"
439 |
440 | def _tostring_includes(self) -> list[str]:
441 | return ["config_file_path"]
442 |
443 | @classmethod
444 | def _generate_config_file(cls, config_file_path: str) -> None:
445 | """
446 | Generates a Serena configuration file at the specified path from the template file.
447 |
448 | :param config_file_path: the path where the configuration file should be generated
449 | """
450 | log.info(f"Auto-generating Serena configuration file in {config_file_path}")
451 | loaded_commented_yaml = load_yaml(SERENA_CONFIG_TEMPLATE_FILE, preserve_comments=True)
452 | save_yaml(config_file_path, loaded_commented_yaml, preserve_comments=True)
453 |
454 | @classmethod
455 | def _determine_config_file_path(cls) -> str:
456 | """
457 | :return: the location where the Serena configuration file is stored/should be stored
458 | """
459 | config_path = os.path.join(SerenaPaths().serena_user_home_dir, cls.CONFIG_FILE)
460 |
461 | # if the config file does not exist, check if we can migrate it from the old location
462 | if not os.path.exists(config_path):
463 | old_config_path = os.path.join(REPO_ROOT, cls.CONFIG_FILE)
464 | if os.path.exists(old_config_path):
465 | log.info(f"Moving Serena configuration file from {old_config_path} to {config_path}")
466 | os.makedirs(os.path.dirname(config_path), exist_ok=True)
467 | shutil.move(old_config_path, config_path)
468 |
469 | return config_path
470 |
471 | @classmethod
472 | def from_config_file(cls, generate_if_missing: bool = True) -> "SerenaConfig":
473 | """
474 | Static constructor to create SerenaConfig from the configuration file
475 | """
476 | config_file_path = cls._determine_config_file_path()
477 |
478 | # create the configuration file from the template if necessary
479 | if not os.path.exists(config_file_path):
480 | if not generate_if_missing:
481 | raise FileNotFoundError(f"Serena configuration file not found: {config_file_path}")
482 | log.info(f"Serena configuration file not found at {config_file_path}, autogenerating...")
483 | cls._generate_config_file(config_file_path)
484 |
485 | # load the configuration
486 | log.info(f"Loading Serena configuration from {config_file_path}")
487 | try:
488 | loaded_commented_yaml = load_yaml(config_file_path, preserve_comments=True)
489 | except Exception as e:
490 | raise ValueError(f"Error loading Serena configuration from {config_file_path}: {e}") from e
491 |
492 | # create the configuration instance
493 | instance = cls(loaded_commented_yaml=loaded_commented_yaml, config_file_path=config_file_path)
494 |
495 | # read projects
496 | if "projects" not in loaded_commented_yaml:
497 | raise SerenaConfigError("`projects` key not found in Serena configuration. Please update your `serena_config.yml` file.")
498 |
499 | # load list of known projects
500 | instance.projects = []
501 | num_migrations = 0
502 | for path in loaded_commented_yaml["projects"]:
503 | path = Path(path).resolve()
504 | if not path.exists() or (path.is_dir() and not (path / ProjectConfig.rel_path_to_project_yml()).exists()):
505 | log.warning(f"Project path {path} does not exist or does not contain a project configuration file, skipping.")
506 | continue
507 | if path.is_file():
508 | path = cls._migrate_out_of_project_config_file(path)
509 | if path is None:
510 | continue
511 | num_migrations += 1
512 | project_config = ProjectConfig.load(path)
513 | project = RegisteredProject(
514 | project_root=str(path),
515 | project_config=project_config,
516 | )
517 | instance.projects.append(project)
518 |
519 | def get_value_or_default(key: str, field_name: str | None = None) -> Any:
520 | if field_name is None:
521 | field_name = key
522 | return loaded_commented_yaml.get(key, get_dataclass_default(SerenaConfig, field_name))
523 |
524 | # determine language backend
525 | language_backend = get_dataclass_default(SerenaConfig, "language_backend")
526 | if "language_backend" in loaded_commented_yaml:
527 | backend_str = loaded_commented_yaml["language_backend"]
528 | language_backend = LanguageBackend.from_str(backend_str)
529 | else:
530 | # backward compatibility (migrate Boolean field "jetbrains")
531 | if "jetbrains" in loaded_commented_yaml:
532 | num_migrations += 1
533 | if loaded_commented_yaml["jetbrains"]:
534 | language_backend = LanguageBackend.JETBRAINS
535 | del loaded_commented_yaml["jetbrains"]
536 | instance.language_backend = language_backend
537 |
538 | # set other configuration parameters (primitive types)
539 | instance.gui_log_window_enabled = get_value_or_default("gui_log_window", "gui_log_window_enabled")
540 | instance.web_dashboard_listen_address = get_value_or_default("web_dashboard_listen_address")
541 | instance.log_level = loaded_commented_yaml.get("log_level", loaded_commented_yaml.get("gui_log_level", logging.INFO))
542 | instance.web_dashboard = get_value_or_default("web_dashboard")
543 | instance.web_dashboard_open_on_launch = get_value_or_default("web_dashboard_open_on_launch")
544 | instance.tool_timeout = get_value_or_default("tool_timeout")
545 | instance.trace_lsp_communication = get_value_or_default("trace_lsp_communication")
546 | instance.excluded_tools = get_value_or_default("excluded_tools")
547 | instance.included_optional_tools = get_value_or_default("included_optional_tools")
548 | instance.token_count_estimator = get_value_or_default("token_count_estimator")
549 | instance.default_max_tool_answer_chars = get_value_or_default("default_max_tool_answer_chars")
550 | instance.ls_specific_settings = get_value_or_default("ls_specific_settings")
551 |
552 | # re-save the configuration file if any migrations were performed
553 | if num_migrations > 0:
554 | log.info("Legacy configuration was migrated; re-saving configuration file")
555 | instance.save()
556 |
557 | return instance
558 |
559 | @classmethod
560 | def _migrate_out_of_project_config_file(cls, path: Path) -> Path | None:
561 | """
562 | Migrates a legacy project configuration file (which is a YAML file containing the project root) to the
563 | in-project configuration file (project.yml) inside the project root directory.
564 |
565 | :param path: the path to the legacy project configuration file
566 | :return: the project root path if the migration was successful, None otherwise.
567 | """
568 | log.info(f"Found legacy project configuration file {path}, migrating to in-project configuration.")
569 | try:
570 | with open(path, encoding=SERENA_FILE_ENCODING) as f:
571 | project_config_data = yaml.safe_load(f)
572 | if "project_name" not in project_config_data:
573 | project_name = path.stem
574 | with open(path, "a", encoding=SERENA_FILE_ENCODING) as f:
575 | f.write(f"\nproject_name: {project_name}")
576 | project_root = project_config_data["project_root"]
577 | shutil.move(str(path), str(Path(project_root) / ProjectConfig.rel_path_to_project_yml()))
578 | return Path(project_root).resolve()
579 | except Exception as e:
580 | log.error(f"Error migrating configuration file: {e}")
581 | return None
582 |
583 | @cached_property
584 | def project_paths(self) -> list[str]:
585 | return sorted(str(project.project_root) for project in self.projects)
586 |
587 | @cached_property
588 | def project_names(self) -> list[str]:
589 | return sorted(project.project_config.project_name for project in self.projects)
590 |
591 | def get_project(self, project_root_or_name: str) -> Optional["Project"]:
592 | # look for project by name
593 | project_candidates = []
594 | for project in self.projects:
595 | if project.project_config.project_name == project_root_or_name:
596 | project_candidates.append(project)
597 | if len(project_candidates) == 1:
598 | return project_candidates[0].get_project_instance()
599 | elif len(project_candidates) > 1:
600 | raise ValueError(
601 | f"Multiple projects found with name '{project_root_or_name}'. Please activate it by location instead. "
602 | f"Locations: {[p.project_root for p in project_candidates]}"
603 | )
604 | # no project found by name; check if it's a path
605 | if os.path.isdir(project_root_or_name):
606 | for project in self.projects:
607 | if project.matches_root_path(project_root_or_name):
608 | return project.get_project_instance()
609 | return None
610 |
611 | def add_project_from_path(self, project_root: Path | str) -> "Project":
612 | """
613 | Add a project to the Serena configuration from a given path. Will raise a FileExistsError if a
614 | project already exists at the path.
615 |
616 | :param project_root: the path to the project to add
617 | :return: the project that was added
618 | """
619 | from ..project import Project
620 |
621 | project_root = Path(project_root).resolve()
622 | if not project_root.exists():
623 | raise FileNotFoundError(f"Error: Path does not exist: {project_root}")
624 | if not project_root.is_dir():
625 | raise FileNotFoundError(f"Error: Path is not a directory: {project_root}")
626 |
627 | for already_registered_project in self.projects:
628 | if str(already_registered_project.project_root) == str(project_root):
629 | raise FileExistsError(
630 | f"Project with path {project_root} was already added with name '{already_registered_project.project_name}'."
631 | )
632 |
633 | project_config = ProjectConfig.load(project_root, autogenerate=True)
634 |
635 | new_project = Project(project_root=str(project_root), project_config=project_config, is_newly_created=True)
636 | self.projects.append(RegisteredProject.from_project_instance(new_project))
637 | self.save()
638 |
639 | return new_project
640 |
641 | def remove_project(self, project_name: str) -> None:
642 | # find the index of the project with the desired name and remove it
643 | for i, project in enumerate(list(self.projects)):
644 | if project.project_name == project_name:
645 | del self.projects[i]
646 | break
647 | else:
648 | raise ValueError(f"Project '{project_name}' not found in Serena configuration; valid project names: {self.project_names}")
649 | self.save()
650 |
651 | def save(self) -> None:
652 | """
653 | Saves the configuration to the file from which it was loaded (if any)
654 | """
655 | if self.config_file_path is None:
656 | return
657 |
658 | assert self.loaded_commented_yaml is not None, "Cannot save configuration without loaded YAML"
659 |
660 | loaded_original_yaml = deepcopy(self.loaded_commented_yaml)
661 |
662 | # convert project objects into list of paths
663 | loaded_original_yaml["projects"] = sorted({str(project.project_root) for project in self.projects})
664 |
665 | # convert language backend to string
666 | loaded_original_yaml["language_backend"] = self.language_backend.value
667 |
668 | save_yaml(self.config_file_path, loaded_original_yaml, preserve_comments=True)
669 |
```
--------------------------------------------------------------------------------
/src/serena/symbol.py:
--------------------------------------------------------------------------------
```python
1 | import json
2 | import logging
3 | import os
4 | from abc import ABC, abstractmethod
5 | from collections.abc import Callable, Iterator, Sequence
6 | from dataclasses import asdict, dataclass
7 | from typing import TYPE_CHECKING, Any, NotRequired, Self, TypedDict, Union
8 |
9 | from sensai.util.string import ToStringMixin
10 |
11 | from solidlsp import SolidLanguageServer
12 | from solidlsp.ls import ReferenceInSymbol as LSPReferenceInSymbol
13 | from solidlsp.ls_types import Position, SymbolKind, UnifiedSymbolInformation
14 |
15 | from .ls_manager import LanguageServerManager
16 | from .project import Project
17 |
18 | if TYPE_CHECKING:
19 | from .agent import SerenaAgent
20 |
21 | log = logging.getLogger(__name__)
22 | NAME_PATH_SEP = "/"
23 |
24 |
25 | @dataclass
26 | class LanguageServerSymbolLocation:
27 | """
28 | Represents the (start) location of a symbol identifier, which, within Serena, uniquely identifies the symbol.
29 | """
30 |
31 | relative_path: str | None
32 | """
33 | the relative path of the file containing the symbol; if None, the symbol is defined outside of the project's scope
34 | """
35 | line: int | None
36 | """
37 | the line number in which the symbol identifier is defined (if the symbol is a function, class, etc.);
38 | may be None for some types of symbols (e.g. SymbolKind.File)
39 | """
40 | column: int | None
41 | """
42 | the column number in which the symbol identifier is defined (if the symbol is a function, class, etc.);
43 | may be None for some types of symbols (e.g. SymbolKind.File)
44 | """
45 |
46 | def __post_init__(self) -> None:
47 | if self.relative_path is not None:
48 | self.relative_path = self.relative_path.replace("/", os.path.sep)
49 |
50 | def to_dict(self, include_relative_path: bool = True) -> dict[str, Any]:
51 | result = asdict(self)
52 | if not include_relative_path:
53 | result.pop("relative_path", None)
54 | return result
55 |
56 | def has_position_in_file(self) -> bool:
57 | return self.relative_path is not None and self.line is not None and self.column is not None
58 |
59 |
60 | @dataclass
61 | class PositionInFile:
62 | """
63 | Represents a character position within a file
64 | """
65 |
66 | line: int
67 | """
68 | the 0-based line number in the file
69 | """
70 | col: int
71 | """
72 | the 0-based column
73 | """
74 |
75 | def to_lsp_position(self) -> Position:
76 | """
77 | Convert to LSP Position.
78 | """
79 | return Position(line=self.line, character=self.col)
80 |
81 |
82 | class Symbol(ToStringMixin, ABC):
83 | @abstractmethod
84 | def get_body_start_position(self) -> PositionInFile | None:
85 | pass
86 |
87 | @abstractmethod
88 | def get_body_end_position(self) -> PositionInFile | None:
89 | pass
90 |
91 | def get_body_start_position_or_raise(self) -> PositionInFile:
92 | """
93 | Get the start position of the symbol body, raising an error if it is not defined.
94 | """
95 | pos = self.get_body_start_position()
96 | if pos is None:
97 | raise ValueError(f"Body start position is not defined for {self}")
98 | return pos
99 |
100 | def get_body_end_position_or_raise(self) -> PositionInFile:
101 | """
102 | Get the end position of the symbol body, raising an error if it is not defined.
103 | """
104 | pos = self.get_body_end_position()
105 | if pos is None:
106 | raise ValueError(f"Body end position is not defined for {self}")
107 | return pos
108 |
109 | @abstractmethod
110 | def is_neighbouring_definition_separated_by_empty_line(self) -> bool:
111 | """
112 | :return: whether a symbol definition of this symbol's kind is usually separated from the
113 | previous/next definition by at least one empty line.
114 | """
115 |
116 |
117 | class NamePathMatcher(ToStringMixin):
118 | """
119 | Matches name paths of symbols against search patterns.
120 |
121 | A name path is a path in the symbol tree *within a source file*.
122 | For example, the method `my_method` defined in class `MyClass` would have the name path `MyClass/my_method`.
123 | If a symbol is overloaded (e.g., in Java), a 0-based index is appended (e.g. "MyClass/my_method[0]") to
124 | uniquely identify it.
125 |
126 | A matching pattern can be:
127 | * a simple name (e.g. "method"), which will match any symbol with that name
128 | * a relative path like "class/method", which will match any symbol with that name path suffix
129 | * an absolute name path "/class/method" (absolute name path), which requires an exact match of the full name path within the source file.
130 | Append an index `[i]` to match a specific overload only, e.g. "MyClass/my_method[1]".
131 | """
132 |
133 | def __init__(self, name_path_pattern: str, substring_matching: bool) -> None:
134 | """
135 | :param name_path_pattern: the name path expression to match against
136 | :param substring_matching: whether to use substring matching for the last segment
137 | """
138 | assert name_path_pattern, "name_path must not be empty"
139 | self._expr = name_path_pattern
140 | self._substring_matching = substring_matching
141 | self._is_absolute_pattern = name_path_pattern.startswith(NAME_PATH_SEP)
142 | self._pattern_parts = name_path_pattern.lstrip(NAME_PATH_SEP).rstrip(NAME_PATH_SEP).split(NAME_PATH_SEP)
143 |
144 | # extract overload index "[idx]" if present at end of last part
145 | self._overload_idx: int | None = None
146 | last_part = self._pattern_parts[-1]
147 | if last_part.endswith("]") and "[" in last_part:
148 | bracket_idx = last_part.rfind("[")
149 | index_part = last_part[bracket_idx + 1 : -1]
150 | if index_part.isdigit():
151 | self._pattern_parts[-1] = last_part[:bracket_idx]
152 | self._overload_idx = int(index_part)
153 |
154 | def _tostring_includes(self) -> list[str]:
155 | return ["_expr"]
156 |
157 | def matches_ls_symbol(self, symbol: "LanguageServerSymbol") -> bool:
158 | return self.matches_components(symbol.get_name_path_parts(), symbol.overload_idx)
159 |
160 | def matches_components(self, symbol_name_path_parts: list[str], overload_idx: int | None) -> bool:
161 | # filtering based on ancestors
162 | if len(self._pattern_parts) > len(symbol_name_path_parts):
163 | # can't possibly match if pattern has more parts than symbol
164 | return False
165 | if self._is_absolute_pattern and len(self._pattern_parts) != len(symbol_name_path_parts):
166 | # for absolute patterns, the number of parts must match exactly
167 | return False
168 | if symbol_name_path_parts[-len(self._pattern_parts) : -1] != self._pattern_parts[:-1]:
169 | # ancestors must match
170 | return False
171 |
172 | # matching the last part of the symbol name
173 | name_to_match = self._pattern_parts[-1]
174 | symbol_name = symbol_name_path_parts[-1]
175 | if self._substring_matching:
176 | if name_to_match not in symbol_name:
177 | return False
178 | else:
179 | if name_to_match != symbol_name:
180 | return False
181 |
182 | # check for matching overload index
183 | if self._overload_idx is not None:
184 | if overload_idx != self._overload_idx:
185 | return False
186 |
187 | return True
188 |
189 |
190 | class LanguageServerSymbol(Symbol, ToStringMixin):
191 | def __init__(self, symbol_root_from_ls: UnifiedSymbolInformation) -> None:
192 | self.symbol_root = symbol_root_from_ls
193 |
194 | def _tostring_includes(self) -> list[str]:
195 | return []
196 |
197 | def _tostring_additional_entries(self) -> dict[str, Any]:
198 | return dict(name=self.name, kind=self.kind, num_children=len(self.symbol_root["children"]))
199 |
200 | @property
201 | def name(self) -> str:
202 | return self.symbol_root["name"]
203 |
204 | @property
205 | def kind(self) -> str:
206 | return SymbolKind(self.symbol_kind).name
207 |
208 | @property
209 | def symbol_kind(self) -> SymbolKind:
210 | return self.symbol_root["kind"]
211 |
212 | def is_low_level(self) -> bool:
213 | """
214 | :return: whether the symbol is a low-level symbol (variable, constant, etc.), which typically represents data
215 | rather than structure and therefore is not relevant in a high-level overview of the code.
216 | """
217 | return self.symbol_kind >= SymbolKind.Variable.value
218 |
219 | @property
220 | def overload_idx(self) -> int | None:
221 | return self.symbol_root.get("overload_idx")
222 |
223 | def is_neighbouring_definition_separated_by_empty_line(self) -> bool:
224 | return self.symbol_kind in (SymbolKind.Function, SymbolKind.Method, SymbolKind.Class, SymbolKind.Interface, SymbolKind.Struct)
225 |
226 | @property
227 | def relative_path(self) -> str | None:
228 | location = self.symbol_root.get("location")
229 | if location:
230 | return location.get("relativePath")
231 | return None
232 |
233 | @property
234 | def location(self) -> LanguageServerSymbolLocation:
235 | """
236 | :return: the start location of the actual symbol identifier
237 | """
238 | return LanguageServerSymbolLocation(relative_path=self.relative_path, line=self.line, column=self.column)
239 |
240 | @property
241 | def body_start_position(self) -> Position | None:
242 | location = self.symbol_root.get("location")
243 | if location:
244 | range_info = location.get("range")
245 | if range_info:
246 | start_pos = range_info.get("start")
247 | if start_pos:
248 | return start_pos
249 | return None
250 |
251 | @property
252 | def body_end_position(self) -> Position | None:
253 | location = self.symbol_root.get("location")
254 | if location:
255 | range_info = location.get("range")
256 | if range_info:
257 | end_pos = range_info.get("end")
258 | if end_pos:
259 | return end_pos
260 | return None
261 |
262 | def get_body_start_position(self) -> PositionInFile | None:
263 | start_pos = self.body_start_position
264 | if start_pos is None:
265 | return None
266 | return PositionInFile(line=start_pos["line"], col=start_pos["character"])
267 |
268 | def get_body_end_position(self) -> PositionInFile | None:
269 | end_pos = self.body_end_position
270 | if end_pos is None:
271 | return None
272 | return PositionInFile(line=end_pos["line"], col=end_pos["character"])
273 |
274 | def get_body_line_numbers(self) -> tuple[int | None, int | None]:
275 | start_pos = self.body_start_position
276 | end_pos = self.body_end_position
277 | start_line = start_pos["line"] if start_pos else None
278 | end_line = end_pos["line"] if end_pos else None
279 | return start_line, end_line
280 |
281 | @property
282 | def line(self) -> int | None:
283 | """
284 | :return: the line in which the symbol identifier is defined.
285 | """
286 | if "selectionRange" in self.symbol_root:
287 | return self.symbol_root["selectionRange"]["start"]["line"]
288 | else:
289 | # line is expected to be undefined for some types of symbols (e.g. SymbolKind.File)
290 | return None
291 |
292 | @property
293 | def column(self) -> int | None:
294 | if "selectionRange" in self.symbol_root:
295 | return self.symbol_root["selectionRange"]["start"]["character"]
296 | else:
297 | # precise location is expected to be undefined for some types of symbols (e.g. SymbolKind.File)
298 | return None
299 |
300 | @property
301 | def body(self) -> str | None:
302 | return self.symbol_root.get("body")
303 |
304 | def get_name_path(self) -> str:
305 | """
306 | Get the name path of the symbol, e.g. "class/method/inner_function" or
307 | "class/method[1]" (overloaded method with identifying index).
308 | """
309 | name_path = NAME_PATH_SEP.join(self.get_name_path_parts())
310 | if "overload_idx" in self.symbol_root:
311 | name_path += f"[{self.symbol_root['overload_idx']}]"
312 | return name_path
313 |
314 | def get_name_path_parts(self) -> list[str]:
315 | """
316 | Get the parts of the name path of the symbol (e.g. ["class", "method", "inner_function"]).
317 | """
318 | ancestors_within_file = list(self.iter_ancestors(up_to_symbol_kind=SymbolKind.File))
319 | ancestors_within_file.reverse()
320 | return [a.name for a in ancestors_within_file] + [self.name]
321 |
322 | def iter_children(self) -> Iterator[Self]:
323 | for c in self.symbol_root["children"]:
324 | yield self.__class__(c)
325 |
326 | def iter_ancestors(self, up_to_symbol_kind: SymbolKind | None = None) -> Iterator[Self]:
327 | """
328 | Iterate over all ancestors of the symbol, starting with the parent and going up to the root or
329 | the given symbol kind.
330 |
331 | :param up_to_symbol_kind: if provided, iteration will stop *before* the first ancestor of the given kind.
332 | A typical use case is to pass `SymbolKind.File` or `SymbolKind.Package`.
333 | """
334 | parent = self.get_parent()
335 | if parent is not None:
336 | if up_to_symbol_kind is None or parent.symbol_kind != up_to_symbol_kind:
337 | yield parent
338 | yield from parent.iter_ancestors(up_to_symbol_kind=up_to_symbol_kind)
339 |
340 | def get_parent(self) -> Self | None:
341 | parent_root = self.symbol_root.get("parent")
342 | if parent_root is None:
343 | return None
344 | return self.__class__(parent_root)
345 |
346 | def find(
347 | self,
348 | name_path_pattern: str,
349 | substring_matching: bool = False,
350 | include_kinds: Sequence[SymbolKind] | None = None,
351 | exclude_kinds: Sequence[SymbolKind] | None = None,
352 | ) -> list[Self]:
353 | """
354 | Find all symbols within the symbol's subtree that match the given name path pattern.
355 |
356 | :param name_path_pattern: the name path pattern to match against (see class :class:`NamePathMatcher` for details)
357 | :param substring_matching: whether to use substring matching (as opposed to exact matching)
358 | of the last segment of `name_path` against the symbol name.
359 | :param include_kinds: an optional sequence of ints representing the LSP symbol kind.
360 | If provided, only symbols of the given kinds will be included in the result.
361 | :param exclude_kinds: If provided, symbols of the given kinds will be excluded from the result.
362 | """
363 | result = []
364 | name_path_matcher = NamePathMatcher(name_path_pattern, substring_matching)
365 |
366 | def should_include(s: "LanguageServerSymbol") -> bool:
367 | if include_kinds is not None and s.symbol_kind not in include_kinds:
368 | return False
369 | if exclude_kinds is not None and s.symbol_kind in exclude_kinds:
370 | return False
371 | return name_path_matcher.matches_ls_symbol(s)
372 |
373 | def traverse(s: "LanguageServerSymbol") -> None:
374 | if should_include(s):
375 | result.append(s)
376 | for c in s.iter_children():
377 | traverse(c)
378 |
379 | traverse(self)
380 | return result
381 |
382 | def to_dict(
383 | self,
384 | kind: bool = False,
385 | location: bool = False,
386 | depth: int = 0,
387 | include_body: bool = False,
388 | include_children_body: bool = False,
389 | include_relative_path: bool = True,
390 | child_inclusion_predicate: Callable[[Self], bool] | None = None,
391 | ) -> dict[str, Any]:
392 | """
393 | Converts the symbol to a dictionary.
394 |
395 | :param kind: whether to include the kind of the symbol
396 | :param location: whether to include the location of the symbol
397 | :param depth: the depth up to which to include child symbols (0 = do not include children)
398 | :param include_body: whether to include the body of the top-level symbol.
399 | :param include_children_body: whether to also include the body of the children.
400 | Note that the body of the children is part of the body of the parent symbol,
401 | so there is usually no need to set this to True unless you want process the output
402 | and pass the children without passing the parent body to the LM.
403 | :param include_relative_path: whether to include the relative path of the symbol in the location
404 | entry. Relative paths of the symbol's children are always excluded.
405 | :param child_inclusion_predicate: an optional predicate that decides whether a child symbol
406 | should be included.
407 | :return: a dictionary representation of the symbol
408 | """
409 | result: dict[str, Any] = {"name": self.name, "name_path": self.get_name_path()}
410 |
411 | if kind:
412 | result["kind"] = self.kind
413 |
414 | if location:
415 | result["location"] = self.location.to_dict(include_relative_path=include_relative_path)
416 | body_start_line, body_end_line = self.get_body_line_numbers()
417 | result["body_location"] = {"start_line": body_start_line, "end_line": body_end_line}
418 |
419 | if include_body:
420 | if self.body is None:
421 | log.warning("Requested body for symbol, but it is not present. The symbol might have been loaded with include_body=False.")
422 | result["body"] = self.body
423 |
424 | if child_inclusion_predicate is None:
425 | child_inclusion_predicate = lambda s: True
426 |
427 | def included_children(s: Self) -> list[dict[str, Any]]:
428 | children = []
429 | for c in s.iter_children():
430 | if not child_inclusion_predicate(c):
431 | continue
432 | children.append(
433 | c.to_dict(
434 | kind=kind,
435 | location=location,
436 | depth=depth - 1,
437 | child_inclusion_predicate=child_inclusion_predicate,
438 | include_body=include_children_body,
439 | include_children_body=include_children_body,
440 | # all children have the same relative path as the parent
441 | include_relative_path=False,
442 | )
443 | )
444 | return children
445 |
446 | if depth > 0:
447 | children = included_children(self)
448 | if len(children) > 0:
449 | result["children"] = included_children(self)
450 |
451 | return result
452 |
453 |
454 | @dataclass
455 | class ReferenceInLanguageServerSymbol(ToStringMixin):
456 | """
457 | Represents the location of a reference to another symbol within a symbol/file.
458 |
459 | The contained symbol is the symbol within which the reference is located,
460 | not the symbol that is referenced.
461 | """
462 |
463 | symbol: LanguageServerSymbol
464 | """
465 | the symbol within which the reference is located
466 | """
467 | line: int
468 | """
469 | the line number in which the reference is located (0-based)
470 | """
471 | character: int
472 | """
473 | the column number in which the reference is located (0-based)
474 | """
475 |
476 | @classmethod
477 | def from_lsp_reference(cls, reference: LSPReferenceInSymbol) -> Self:
478 | return cls(symbol=LanguageServerSymbol(reference.symbol), line=reference.line, character=reference.character)
479 |
480 | def get_relative_path(self) -> str | None:
481 | return self.symbol.location.relative_path
482 |
483 |
484 | class LanguageServerSymbolRetriever:
485 | def __init__(self, ls: SolidLanguageServer | LanguageServerManager, agent: Union["SerenaAgent", None] = None) -> None:
486 | """
487 | :param ls: the language server or language server manager to use for symbol retrieval and editing operations.
488 | :param agent: the agent to use (only needed for marking files as modified). You can pass None if you don't
489 | need an agent to be aware of file modifications performed by the symbol manager.
490 | """
491 | if isinstance(ls, SolidLanguageServer):
492 | ls_manager = LanguageServerManager({ls.language: ls})
493 | else:
494 | ls_manager = ls
495 | assert isinstance(ls_manager, LanguageServerManager)
496 | self._ls_manager: LanguageServerManager = ls_manager
497 | self.agent = agent
498 |
499 | def get_root_path(self) -> str:
500 | return self._ls_manager.get_root_path()
501 |
502 | def get_language_server(self, relative_path: str) -> SolidLanguageServer:
503 | return self._ls_manager.get_language_server(relative_path)
504 |
505 | def find(
506 | self,
507 | name_path_pattern: str,
508 | include_kinds: Sequence[SymbolKind] | None = None,
509 | exclude_kinds: Sequence[SymbolKind] | None = None,
510 | substring_matching: bool = False,
511 | within_relative_path: str | None = None,
512 | ) -> list[LanguageServerSymbol]:
513 | """
514 | Finds all symbols that match the given name path pattern (see class :class:`NamePathMatcher` for details),
515 | optionally limited to a specific file and filtered by kind.
516 | """
517 | symbols: list[LanguageServerSymbol] = []
518 | for lang_server in self._ls_manager.iter_language_servers():
519 | symbol_roots = lang_server.request_full_symbol_tree(within_relative_path=within_relative_path)
520 | for root in symbol_roots:
521 | symbols.extend(
522 | LanguageServerSymbol(root).find(
523 | name_path_pattern, include_kinds=include_kinds, exclude_kinds=exclude_kinds, substring_matching=substring_matching
524 | )
525 | )
526 | return symbols
527 |
528 | def find_unique(
529 | self,
530 | name_path_pattern: str,
531 | include_kinds: Sequence[SymbolKind] | None = None,
532 | exclude_kinds: Sequence[SymbolKind] | None = None,
533 | substring_matching: bool = False,
534 | within_relative_path: str | None = None,
535 | ) -> LanguageServerSymbol:
536 | symbol_candidates = self.find(
537 | name_path_pattern,
538 | include_kinds=include_kinds,
539 | exclude_kinds=exclude_kinds,
540 | substring_matching=substring_matching,
541 | within_relative_path=within_relative_path,
542 | )
543 | if len(symbol_candidates) == 1:
544 | return symbol_candidates[0]
545 | elif len(symbol_candidates) == 0:
546 | raise ValueError(f"No symbol matching '{name_path_pattern}' found")
547 | else:
548 | # There are multiple candidates.
549 | # If only one of the candidates has the given pattern as its exact name path, return that one
550 | exact_matches = [s for s in symbol_candidates if s.get_name_path() == name_path_pattern]
551 | if len(exact_matches) == 1:
552 | return exact_matches[0]
553 | # otherwise, raise an error
554 | include_rel_path = within_relative_path is not None
555 | raise ValueError(
556 | f"Found multiple {len(symbol_candidates)} symbols matching '{name_path_pattern}'. "
557 | "They are: \n"
558 | + json.dumps([s.to_dict(kind=True, include_relative_path=include_rel_path) for s in symbol_candidates], indent=2)
559 | )
560 |
561 | def find_by_location(self, location: LanguageServerSymbolLocation) -> LanguageServerSymbol | None:
562 | if location.relative_path is None:
563 | return None
564 | lang_server = self.get_language_server(location.relative_path)
565 | document_symbols = lang_server.request_document_symbols(location.relative_path)
566 | for symbol_dict in document_symbols.iter_symbols():
567 | symbol = LanguageServerSymbol(symbol_dict)
568 | if symbol.location == location:
569 | return symbol
570 | return None
571 |
572 | def find_referencing_symbols(
573 | self,
574 | name_path: str,
575 | relative_file_path: str,
576 | include_body: bool = False,
577 | include_kinds: Sequence[SymbolKind] | None = None,
578 | exclude_kinds: Sequence[SymbolKind] | None = None,
579 | ) -> list[ReferenceInLanguageServerSymbol]:
580 | """
581 | Find all symbols that reference the specified symbol, which is assumed to be unique.
582 |
583 | :param name_path: the name path of the symbol to find. (While this can be a matching pattern, it should
584 | usually be the full path to ensure uniqueness.)
585 | :param relative_file_path: the relative path of the file in which the referenced symbol is defined.
586 | :param include_body: whether to include the body of all symbols in the result.
587 | Not recommended, as the referencing symbols will often be files, and thus the bodies will be very long.
588 | :param include_kinds: which kinds of symbols to include in the result.
589 | :param exclude_kinds: which kinds of symbols to exclude from the result.
590 | """
591 | symbol = self.find_unique(name_path, substring_matching=False, within_relative_path=relative_file_path)
592 | return self.find_referencing_symbols_by_location(
593 | symbol.location, include_body=include_body, include_kinds=include_kinds, exclude_kinds=exclude_kinds
594 | )
595 |
596 | def find_referencing_symbols_by_location(
597 | self,
598 | symbol_location: LanguageServerSymbolLocation,
599 | include_body: bool = False,
600 | include_kinds: Sequence[SymbolKind] | None = None,
601 | exclude_kinds: Sequence[SymbolKind] | None = None,
602 | ) -> list[ReferenceInLanguageServerSymbol]:
603 | """
604 | Find all symbols that reference the symbol at the given location.
605 |
606 | :param symbol_location: the location of the symbol for which to find references.
607 | Does not need to include an end_line, as it is unused in the search.
608 | :param include_body: whether to include the body of all symbols in the result.
609 | Not recommended, as the referencing symbols will often be files, and thus the bodies will be very long.
610 | Note: you can filter out the bodies of the children if you set include_children_body=False
611 | in the to_dict method.
612 | :param include_kinds: an optional sequence of ints representing the LSP symbol kind.
613 | If provided, only symbols of the given kinds will be included in the result.
614 | :param exclude_kinds: If provided, symbols of the given kinds will be excluded from the result.
615 | Takes precedence over include_kinds.
616 | :return: a list of symbols that reference the given symbol
617 | """
618 | if not symbol_location.has_position_in_file():
619 | raise ValueError("Symbol location does not contain a valid position in a file")
620 | assert symbol_location.relative_path is not None
621 | assert symbol_location.line is not None
622 | assert symbol_location.column is not None
623 | lang_server = self.get_language_server(symbol_location.relative_path)
624 | references = lang_server.request_referencing_symbols(
625 | relative_file_path=symbol_location.relative_path,
626 | line=symbol_location.line,
627 | column=symbol_location.column,
628 | include_imports=False,
629 | include_self=False,
630 | include_body=include_body,
631 | include_file_symbols=True,
632 | )
633 |
634 | if include_kinds is not None:
635 | references = [s for s in references if s.symbol["kind"] in include_kinds]
636 |
637 | if exclude_kinds is not None:
638 | references = [s for s in references if s.symbol["kind"] not in exclude_kinds]
639 |
640 | return [ReferenceInLanguageServerSymbol.from_lsp_reference(r) for r in references]
641 |
642 | def get_symbol_overview(self, relative_path: str, depth: int = 0) -> dict[str, list[dict]]:
643 | """
644 | :param relative_path: the path of the file or directory for which to get the symbol overview
645 | :param depth: the depth up to which to include child symbols (0 = only top-level symbols)
646 | :return: a mapping from file paths to lists of symbol dictionaries.
647 | For the case where a file is passed, the mapping will contain a single entry.
648 | """
649 | lang_server = self.get_language_server(relative_path)
650 | path_to_unified_symbols = lang_server.request_overview(relative_path)
651 |
652 | def child_inclusion_predicate(s: LanguageServerSymbol) -> bool:
653 | return not s.is_low_level()
654 |
655 | result = {}
656 | for file_path, unified_symbols in path_to_unified_symbols.items():
657 | symbols_in_file = []
658 | for us in unified_symbols:
659 | symbol = LanguageServerSymbol(us)
660 | symbols_in_file.append(
661 | symbol.to_dict(
662 | depth=depth,
663 | kind=True,
664 | include_relative_path=False,
665 | location=False,
666 | child_inclusion_predicate=child_inclusion_predicate,
667 | )
668 | )
669 | result[file_path] = symbols_in_file
670 |
671 | return result
672 |
673 |
674 | class JetBrainsSymbol(Symbol):
675 | class SymbolDict(TypedDict):
676 | name_path: str
677 | relative_path: str
678 | type: str
679 | text_range: NotRequired[dict]
680 | body: NotRequired[str]
681 | children: NotRequired[list["JetBrainsSymbol.SymbolDict"]]
682 |
683 | def __init__(self, symbol_dict: SymbolDict, project: Project) -> None:
684 | """
685 | :param symbol_dict: dictionary as returned by the JetBrains plugin client.
686 | """
687 | self._project = project
688 | self._dict = symbol_dict
689 | self._cached_file_content: str | None = None
690 | self._cached_body_start_position: PositionInFile | None = None
691 | self._cached_body_end_position: PositionInFile | None = None
692 |
693 | def _tostring_includes(self) -> list[str]:
694 | return []
695 |
696 | def _tostring_additional_entries(self) -> dict[str, Any]:
697 | return dict(name_path=self.get_name_path(), relative_path=self.get_relative_path(), type=self._dict["type"])
698 |
699 | def get_name_path(self) -> str:
700 | return self._dict["name_path"]
701 |
702 | def get_relative_path(self) -> str:
703 | return self._dict["relative_path"]
704 |
705 | def get_file_content(self) -> str:
706 | if self._cached_file_content is None:
707 | path = os.path.join(self._project.project_root, self.get_relative_path())
708 | with open(path, encoding=self._project.project_config.encoding) as f:
709 | self._cached_file_content = f.read()
710 | return self._cached_file_content
711 |
712 | def is_position_in_file_available(self) -> bool:
713 | return "text_range" in self._dict
714 |
715 | def get_body_start_position(self) -> PositionInFile | None:
716 | if not self.is_position_in_file_available():
717 | return None
718 | if self._cached_body_start_position is None:
719 | pos = self._dict["text_range"]["start_pos"]
720 | line, col = pos["line"], pos["col"]
721 | self._cached_body_start_position = PositionInFile(line=line, col=col)
722 | return self._cached_body_start_position
723 |
724 | def get_body_end_position(self) -> PositionInFile | None:
725 | if not self.is_position_in_file_available():
726 | return None
727 | if self._cached_body_end_position is None:
728 | pos = self._dict["text_range"]["end_pos"]
729 | line, col = pos["line"], pos["col"]
730 | self._cached_body_end_position = PositionInFile(line=line, col=col)
731 | return self._cached_body_end_position
732 |
733 | def is_neighbouring_definition_separated_by_empty_line(self) -> bool:
734 | # NOTE: Symbol types cannot really be differentiated, because types are not handled in a language-agnostic way.
735 | return False
736 |
```
--------------------------------------------------------------------------------
/src/solidlsp/language_servers/csharp_language_server.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | CSharp Language Server using Microsoft.CodeAnalysis.LanguageServer (Official Roslyn-based LSP server)
3 | """
4 |
5 | import json
6 | import logging
7 | import os
8 | import platform
9 | import shutil
10 | import subprocess
11 | import tarfile
12 | import threading
13 | import urllib.request
14 | import zipfile
15 | from collections.abc import Iterable
16 | from pathlib import Path
17 | from typing import Any, cast
18 |
19 | from overrides import override
20 |
21 | from solidlsp.ls import SolidLanguageServer
22 | from solidlsp.ls_config import LanguageServerConfig
23 | from solidlsp.ls_exceptions import SolidLSPException
24 | from solidlsp.ls_utils import PathUtils
25 | from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams, InitializeResult
26 | from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
27 | from solidlsp.settings import SolidLSPSettings
28 | from solidlsp.util.zip import SafeZipExtractor
29 |
30 | from .common import RuntimeDependency, RuntimeDependencyCollection
31 |
32 | log = logging.getLogger(__name__)
33 |
34 | _RUNTIME_DEPENDENCIES = [
35 | RuntimeDependency(
36 | id="CSharpLanguageServer",
37 | description="Microsoft.CodeAnalysis.LanguageServer for Windows (x64)",
38 | package_name="Microsoft.CodeAnalysis.LanguageServer.win-x64",
39 | package_version="5.0.0-1.25329.6",
40 | platform_id="win-x64",
41 | archive_type="nupkg",
42 | binary_name="Microsoft.CodeAnalysis.LanguageServer.dll",
43 | extract_path="content/LanguageServer/win-x64",
44 | ),
45 | RuntimeDependency(
46 | id="CSharpLanguageServer",
47 | description="Microsoft.CodeAnalysis.LanguageServer for Windows (ARM64)",
48 | package_name="Microsoft.CodeAnalysis.LanguageServer.win-arm64",
49 | package_version="5.0.0-1.25329.6",
50 | platform_id="win-arm64",
51 | archive_type="nupkg",
52 | binary_name="Microsoft.CodeAnalysis.LanguageServer.dll",
53 | extract_path="content/LanguageServer/win-arm64",
54 | ),
55 | RuntimeDependency(
56 | id="CSharpLanguageServer",
57 | description="Microsoft.CodeAnalysis.LanguageServer for macOS (x64)",
58 | package_name="Microsoft.CodeAnalysis.LanguageServer.osx-x64",
59 | package_version="5.0.0-1.25329.6",
60 | platform_id="osx-x64",
61 | archive_type="nupkg",
62 | binary_name="Microsoft.CodeAnalysis.LanguageServer.dll",
63 | extract_path="content/LanguageServer/osx-x64",
64 | ),
65 | RuntimeDependency(
66 | id="CSharpLanguageServer",
67 | description="Microsoft.CodeAnalysis.LanguageServer for macOS (ARM64)",
68 | package_name="Microsoft.CodeAnalysis.LanguageServer.osx-arm64",
69 | package_version="5.0.0-1.25329.6",
70 | platform_id="osx-arm64",
71 | archive_type="nupkg",
72 | binary_name="Microsoft.CodeAnalysis.LanguageServer.dll",
73 | extract_path="content/LanguageServer/osx-arm64",
74 | ),
75 | RuntimeDependency(
76 | id="CSharpLanguageServer",
77 | description="Microsoft.CodeAnalysis.LanguageServer for Linux (x64)",
78 | package_name="Microsoft.CodeAnalysis.LanguageServer.linux-x64",
79 | package_version="5.0.0-1.25329.6",
80 | platform_id="linux-x64",
81 | archive_type="nupkg",
82 | binary_name="Microsoft.CodeAnalysis.LanguageServer.dll",
83 | extract_path="content/LanguageServer/linux-x64",
84 | ),
85 | RuntimeDependency(
86 | id="CSharpLanguageServer",
87 | description="Microsoft.CodeAnalysis.LanguageServer for Linux (ARM64)",
88 | package_name="Microsoft.CodeAnalysis.LanguageServer.linux-arm64",
89 | package_version="5.0.0-1.25329.6",
90 | platform_id="linux-arm64",
91 | archive_type="nupkg",
92 | binary_name="Microsoft.CodeAnalysis.LanguageServer.dll",
93 | extract_path="content/LanguageServer/linux-arm64",
94 | ),
95 | RuntimeDependency(
96 | id="DotNetRuntime",
97 | description=".NET 9 Runtime for Windows (x64)",
98 | url="https://builds.dotnet.microsoft.com/dotnet/Runtime/9.0.6/dotnet-runtime-9.0.6-win-x64.zip",
99 | platform_id="win-x64",
100 | archive_type="zip",
101 | binary_name="dotnet.exe",
102 | ),
103 | RuntimeDependency(
104 | id="DotNetRuntime",
105 | description=".NET 9 Runtime for Linux (x64)",
106 | url="https://builds.dotnet.microsoft.com/dotnet/Runtime/9.0.6/dotnet-runtime-9.0.6-linux-x64.tar.gz",
107 | platform_id="linux-x64",
108 | archive_type="tar.gz",
109 | binary_name="dotnet",
110 | ),
111 | RuntimeDependency(
112 | id="DotNetRuntime",
113 | description=".NET 9 Runtime for Linux (ARM64)",
114 | url="https://builds.dotnet.microsoft.com/dotnet/Runtime/9.0.6/dotnet-runtime-9.0.6-linux-arm64.tar.gz",
115 | platform_id="linux-arm64",
116 | archive_type="tar.gz",
117 | binary_name="dotnet",
118 | ),
119 | RuntimeDependency(
120 | id="DotNetRuntime",
121 | description=".NET 9 Runtime for macOS (x64)",
122 | url="https://builds.dotnet.microsoft.com/dotnet/Runtime/9.0.6/dotnet-runtime-9.0.6-osx-x64.tar.gz",
123 | platform_id="osx-x64",
124 | archive_type="tar.gz",
125 | binary_name="dotnet",
126 | ),
127 | RuntimeDependency(
128 | id="DotNetRuntime",
129 | description=".NET 9 Runtime for macOS (ARM64)",
130 | url="https://builds.dotnet.microsoft.com/dotnet/Runtime/9.0.6/dotnet-runtime-9.0.6-osx-arm64.tar.gz",
131 | platform_id="osx-arm64",
132 | archive_type="tar.gz",
133 | binary_name="dotnet",
134 | ),
135 | ]
136 |
137 |
138 | def breadth_first_file_scan(root_dir: str) -> Iterable[str]:
139 | """
140 | Perform a breadth-first scan of files in the given directory.
141 | Yields file paths in breadth-first order.
142 | """
143 | queue = [root_dir]
144 | while queue:
145 | current_dir = queue.pop(0)
146 | try:
147 | for item in os.listdir(current_dir):
148 | if item.startswith("."):
149 | continue
150 | item_path = os.path.join(current_dir, item)
151 | if os.path.isdir(item_path):
152 | queue.append(item_path)
153 | elif os.path.isfile(item_path):
154 | yield item_path
155 | except (PermissionError, OSError):
156 | # Skip directories we can't access
157 | pass
158 |
159 |
160 | def find_solution_or_project_file(root_dir: str) -> str | None:
161 | """
162 | Find the first .sln file in breadth-first order.
163 | If no .sln file is found, look for a .csproj file.
164 | """
165 | sln_file = None
166 | csproj_file = None
167 |
168 | for filename in breadth_first_file_scan(root_dir):
169 | if filename.endswith(".sln") and sln_file is None:
170 | sln_file = filename
171 | elif filename.endswith(".csproj") and csproj_file is None:
172 | csproj_file = filename
173 |
174 | # If we found a .sln file, return it immediately
175 | if sln_file:
176 | return sln_file
177 |
178 | # If no .sln file was found, return the first .csproj file
179 | return csproj_file
180 |
181 |
182 | class CSharpLanguageServer(SolidLanguageServer):
183 | """
184 | Provides C# specific instantiation of the LanguageServer class using `Microsoft.CodeAnalysis.LanguageServer`,
185 | the official Roslyn-based language server from Microsoft.
186 |
187 | You can pass a list of runtime dependency overrides in ls_specific_settings["csharp"]. This is a list of
188 | dicts, each containing at least the "id" key, and optionally "platform_id" to uniquely identify the dependency to override.
189 | For example, to override the URL of the .NET runtime on windows-x64, add the entry:
190 |
191 | ```
192 | {
193 | "id": "DotNetRuntime",
194 | "platform_id": "win-x64",
195 | "url": "https://example.com/custom-dotnet-runtime.zip"
196 | }
197 | ```
198 |
199 | See the `_RUNTIME_DEPENDENCIES` variable above for the available dependency ids and platform_ids.
200 | """
201 |
202 | def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
203 | """
204 | Creates a CSharpLanguageServer instance. This class is not meant to be instantiated directly.
205 | Use LanguageServer.create() instead.
206 | """
207 | dotnet_path, language_server_path = self._ensure_server_installed(config, solidlsp_settings)
208 |
209 | # Find solution or project file
210 | solution_or_project = find_solution_or_project_file(repository_root_path)
211 |
212 | # Create log directory
213 | log_dir = Path(self.ls_resources_dir(solidlsp_settings)) / "logs"
214 | log_dir.mkdir(parents=True, exist_ok=True)
215 |
216 | # Build command using dotnet directly
217 | cmd = [dotnet_path, language_server_path, "--logLevel=Information", f"--extensionLogDirectory={log_dir}", "--stdio"]
218 |
219 | # The language server will discover the solution/project from the workspace root
220 | if solution_or_project:
221 | log.info(f"Found solution/project file: {solution_or_project}")
222 | else:
223 | log.warning("No .sln or .csproj file found, language server will attempt auto-discovery")
224 |
225 | log.debug(f"Language server command: {' '.join(cmd)}")
226 |
227 | super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd=cmd, cwd=repository_root_path), "csharp", solidlsp_settings)
228 |
229 | self.initialization_complete = threading.Event()
230 |
231 | @override
232 | def is_ignored_dirname(self, dirname: str) -> bool:
233 | return super().is_ignored_dirname(dirname) or dirname in ["bin", "obj", "packages", ".vs"]
234 |
235 | @classmethod
236 | def _ensure_server_installed(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> tuple[str, str]:
237 | """
238 | Ensure .NET runtime and Microsoft.CodeAnalysis.LanguageServer are available.
239 | Returns a tuple of (dotnet_path, language_server_dll_path).
240 | """
241 | language_specific_config = solidlsp_settings.get_ls_specific_settings(cls.get_language_enum_instance())
242 | runtime_dependency_overrides = cast(list[dict[str, Any]], language_specific_config.get("runtime_dependencies", []))
243 |
244 | log.debug("Resolving runtime dependencies")
245 |
246 | runtime_dependencies = RuntimeDependencyCollection(
247 | _RUNTIME_DEPENDENCIES,
248 | overrides=runtime_dependency_overrides,
249 | )
250 |
251 | log.debug(
252 | f"Available runtime dependencies: {runtime_dependencies.get_dependencies_for_current_platform}",
253 | )
254 |
255 | # Find the dependencies for our platform
256 | lang_server_dep = runtime_dependencies.get_single_dep_for_current_platform("CSharpLanguageServer")
257 | dotnet_runtime_dep = runtime_dependencies.get_single_dep_for_current_platform("DotNetRuntime")
258 | dotnet_path = CSharpLanguageServer._ensure_dotnet_runtime(dotnet_runtime_dep, solidlsp_settings)
259 | server_dll_path = CSharpLanguageServer._ensure_language_server(lang_server_dep, solidlsp_settings)
260 |
261 | return dotnet_path, server_dll_path
262 |
263 | @classmethod
264 | def _ensure_dotnet_runtime(cls, dotnet_runtime_dep: RuntimeDependency, solidlsp_settings: SolidLSPSettings) -> str:
265 | """Ensure .NET runtime is available and return the dotnet executable path."""
266 | # TODO: use RuntimeDependency util methods instead of custom validation/download logic
267 |
268 | # Check if dotnet is already available on the system
269 | system_dotnet = shutil.which("dotnet")
270 | if system_dotnet:
271 | # Check if it's .NET 9
272 | try:
273 | result = subprocess.run([system_dotnet, "--list-runtimes"], capture_output=True, text=True, check=True)
274 | if "Microsoft.NETCore.App 9." in result.stdout:
275 | log.info("Found system .NET 9 runtime")
276 | return system_dotnet
277 | except subprocess.CalledProcessError:
278 | pass
279 |
280 | # Download .NET 9 runtime using config
281 | return cls._ensure_dotnet_runtime_from_config(dotnet_runtime_dep, solidlsp_settings)
282 |
283 | @classmethod
284 | def _ensure_language_server(cls, lang_server_dep: RuntimeDependency, solidlsp_settings: SolidLSPSettings) -> str:
285 | """Ensure language server is available and return the DLL path."""
286 | package_name = lang_server_dep.package_name
287 | package_version = lang_server_dep.package_version
288 |
289 | server_dir = Path(cls.ls_resources_dir(solidlsp_settings)) / f"{package_name}.{package_version}"
290 | assert lang_server_dep.binary_name is not None
291 | server_dll = server_dir / lang_server_dep.binary_name
292 |
293 | if server_dll.exists():
294 | log.info(f"Using cached Microsoft.CodeAnalysis.LanguageServer from {server_dll}")
295 | return str(server_dll)
296 |
297 | # Download and install the language server
298 | log.info(f"Downloading {package_name} version {package_version}...")
299 | assert package_version is not None
300 | assert package_name is not None
301 | package_path = cls._download_nuget_package_direct(package_name, package_version, solidlsp_settings)
302 |
303 | # Extract and install
304 | cls._extract_language_server(lang_server_dep, package_path, server_dir)
305 |
306 | if not server_dll.exists():
307 | raise SolidLSPException("Microsoft.CodeAnalysis.LanguageServer.dll not found after extraction")
308 |
309 | # Make executable on Unix systems
310 | if platform.system().lower() != "windows":
311 | server_dll.chmod(0o755)
312 |
313 | log.info(f"Successfully installed Microsoft.CodeAnalysis.LanguageServer to {server_dll}")
314 | return str(server_dll)
315 |
316 | @staticmethod
317 | def _extract_language_server(lang_server_dep: RuntimeDependency, package_path: Path, server_dir: Path) -> None:
318 | """Extract language server files from downloaded package."""
319 | extract_path = lang_server_dep.extract_path or "lib/net9.0"
320 | source_dir = package_path / extract_path
321 |
322 | if not source_dir.exists():
323 | # Try alternative locations
324 | for possible_dir in [
325 | package_path / "tools" / "net9.0" / "any",
326 | package_path / "lib" / "net9.0",
327 | package_path / "contentFiles" / "any" / "net9.0",
328 | ]:
329 | if possible_dir.exists():
330 | source_dir = possible_dir
331 | break
332 | else:
333 | raise SolidLSPException(f"Could not find language server files in package. Searched in {package_path}")
334 |
335 | # Copy files to cache directory
336 | server_dir.mkdir(parents=True, exist_ok=True)
337 | shutil.copytree(source_dir, server_dir, dirs_exist_ok=True)
338 |
339 | @classmethod
340 | def _download_nuget_package_direct(cls, package_name: str, package_version: str, solidlsp_settings: SolidLSPSettings) -> Path:
341 | """
342 | Download a NuGet package directly from the Azure NuGet feed.
343 | Returns the path to the extracted package directory.
344 | """
345 | azure_feed_url = "https://pkgs.dev.azure.com/azure-public/vside/_packaging/vs-impl/nuget/v3/index.json"
346 |
347 | # Create temporary directory for package download
348 | temp_dir = Path(cls.ls_resources_dir(solidlsp_settings)) / "temp_downloads"
349 | temp_dir.mkdir(parents=True, exist_ok=True)
350 |
351 | try:
352 | # First, get the service index from the Azure feed
353 | log.debug("Fetching NuGet service index from Azure feed...")
354 | with urllib.request.urlopen(azure_feed_url) as response:
355 | service_index = json.loads(response.read().decode())
356 |
357 | # Find the package base address (for downloading packages)
358 | package_base_address = None
359 | for resource in service_index.get("resources", []):
360 | if resource.get("@type") == "PackageBaseAddress/3.0.0":
361 | package_base_address = resource.get("@id")
362 | break
363 |
364 | if not package_base_address:
365 | raise SolidLSPException("Could not find package base address in Azure NuGet feed")
366 |
367 | # Construct the download URL for the specific package
368 | package_id_lower = package_name.lower()
369 | package_version_lower = package_version.lower()
370 | package_url = f"{package_base_address.rstrip('/')}/{package_id_lower}/{package_version_lower}/{package_id_lower}.{package_version_lower}.nupkg"
371 |
372 | log.debug(f"Downloading package from: {package_url}")
373 |
374 | # Download the .nupkg file
375 | nupkg_file = temp_dir / f"{package_name}.{package_version}.nupkg"
376 | urllib.request.urlretrieve(package_url, nupkg_file)
377 |
378 | # Extract the .nupkg file (it's just a zip file)
379 | package_extract_dir = temp_dir / f"{package_name}.{package_version}"
380 | package_extract_dir.mkdir(exist_ok=True)
381 |
382 | # Use SafeZipExtractor to handle long paths and skip errors
383 | extractor = SafeZipExtractor(archive_path=nupkg_file, extract_dir=package_extract_dir, verbose=False)
384 | extractor.extract_all()
385 |
386 | # Clean up the nupkg file
387 | nupkg_file.unlink()
388 |
389 | log.info(f"Successfully downloaded and extracted {package_name} version {package_version}")
390 | return package_extract_dir
391 |
392 | except Exception as e:
393 | raise SolidLSPException(
394 | f"Failed to download package {package_name} version {package_version} from Azure NuGet feed: {e}"
395 | ) from e
396 |
397 | @classmethod
398 | def _ensure_dotnet_runtime_from_config(cls, dotnet_runtime_dep: RuntimeDependency, solidlsp_settings: SolidLSPSettings) -> str:
399 | """
400 | Ensure .NET 9 runtime is available using runtime dependency configuration.
401 | Returns the path to the dotnet executable.
402 | """
403 | # TODO: use RuntimeDependency util methods instead of custom download logic
404 |
405 | # Check if dotnet is already available on the system
406 | system_dotnet = shutil.which("dotnet")
407 | if system_dotnet:
408 | # Check if it's .NET 9
409 | try:
410 | result = subprocess.run([system_dotnet, "--list-runtimes"], capture_output=True, text=True, check=True)
411 | if "Microsoft.NETCore.App 9." in result.stdout:
412 | log.info("Found system .NET 9 runtime")
413 | return system_dotnet
414 | except subprocess.CalledProcessError:
415 | pass
416 |
417 | # Download .NET 9 runtime using config
418 | dotnet_dir = Path(cls.ls_resources_dir(solidlsp_settings)) / "dotnet-runtime-9.0"
419 | assert dotnet_runtime_dep.binary_name is not None, "Runtime dependency must have a binary_name"
420 | dotnet_exe = dotnet_dir / dotnet_runtime_dep.binary_name
421 |
422 | if dotnet_exe.exists():
423 | log.info(f"Using cached .NET runtime from {dotnet_exe}")
424 | return str(dotnet_exe)
425 |
426 | # Download .NET runtime
427 | log.info("Downloading .NET 9 runtime...")
428 | dotnet_dir.mkdir(parents=True, exist_ok=True)
429 |
430 | custom_settings = solidlsp_settings.get_ls_specific_settings(cls.get_language_enum_instance())
431 | custom_dotnet_runtime_url = custom_settings.get("dotnet_runtime_url")
432 | if custom_dotnet_runtime_url is not None:
433 | log.info(f"Using custom .NET runtime url: {custom_dotnet_runtime_url}")
434 | url = custom_dotnet_runtime_url
435 | else:
436 | url = dotnet_runtime_dep.url
437 |
438 | archive_type = dotnet_runtime_dep.archive_type
439 |
440 | # Download the runtime
441 | download_path = dotnet_dir / f"dotnet-runtime.{archive_type}"
442 | try:
443 | log.debug(f"Downloading from {url}")
444 | urllib.request.urlretrieve(url, download_path)
445 |
446 | # Extract the archive
447 | if archive_type == "zip":
448 | with zipfile.ZipFile(download_path, "r") as zip_ref:
449 | zip_ref.extractall(dotnet_dir)
450 | else:
451 | # tar.gz
452 | with tarfile.open(download_path, "r:gz") as tar_ref:
453 | tar_ref.extractall(dotnet_dir)
454 |
455 | # Remove the archive
456 | download_path.unlink()
457 |
458 | # Make dotnet executable on Unix
459 | if platform.system().lower() != "windows":
460 | dotnet_exe.chmod(0o755)
461 |
462 | log.info(f"Successfully installed .NET 9 runtime to {dotnet_exe}")
463 | return str(dotnet_exe)
464 |
465 | except Exception as e:
466 | raise SolidLSPException(f"Failed to download .NET 9 runtime from {url}: {e}") from e
467 |
468 | def _get_initialize_params(self) -> InitializeParams:
469 | """
470 | Returns the initialize params for the Microsoft.CodeAnalysis.LanguageServer.
471 | """
472 | root_uri = PathUtils.path_to_uri(self.repository_root_path)
473 | root_name = os.path.basename(self.repository_root_path)
474 | return cast(
475 | InitializeParams,
476 | {
477 | "workspaceFolders": [{"uri": root_uri, "name": root_name}],
478 | "processId": os.getpid(),
479 | "rootPath": self.repository_root_path,
480 | "rootUri": root_uri,
481 | "capabilities": {
482 | "window": {
483 | "workDoneProgress": True,
484 | "showMessage": {"messageActionItem": {"additionalPropertiesSupport": True}},
485 | "showDocument": {"support": True},
486 | },
487 | "workspace": {
488 | "applyEdit": True,
489 | "workspaceEdit": {"documentChanges": True},
490 | "didChangeConfiguration": {"dynamicRegistration": True},
491 | "didChangeWatchedFiles": {"dynamicRegistration": True},
492 | "symbol": {
493 | "dynamicRegistration": True,
494 | "symbolKind": {"valueSet": list(range(1, 27))},
495 | },
496 | "executeCommand": {"dynamicRegistration": True},
497 | "configuration": True,
498 | "workspaceFolders": True,
499 | "workDoneProgress": True,
500 | },
501 | "textDocument": {
502 | "synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True},
503 | "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]},
504 | "signatureHelp": {
505 | "dynamicRegistration": True,
506 | "signatureInformation": {
507 | "documentationFormat": ["markdown", "plaintext"],
508 | "parameterInformation": {"labelOffsetSupport": True},
509 | },
510 | },
511 | "definition": {"dynamicRegistration": True},
512 | "references": {"dynamicRegistration": True},
513 | "documentSymbol": {
514 | "dynamicRegistration": True,
515 | "symbolKind": {"valueSet": list(range(1, 27))},
516 | "hierarchicalDocumentSymbolSupport": True,
517 | },
518 | },
519 | },
520 | },
521 | )
522 |
523 | def _start_server(self) -> None:
524 | def do_nothing(params: dict) -> None:
525 | return
526 |
527 | def window_log_message(msg: dict) -> None:
528 | """Log messages from the language server."""
529 | message_text = msg.get("message", "")
530 | level = msg.get("type", 4) # Default to Log level
531 |
532 | # Map LSP message types to Python logging levels
533 | level_map = {1: logging.ERROR, 2: logging.WARNING, 3: logging.INFO, 4: logging.DEBUG} # Error # Warning # Info # Log
534 |
535 | log.log(level_map.get(level, logging.DEBUG), f"LSP: {message_text}")
536 |
537 | def handle_progress(params: dict) -> None:
538 | """Handle progress notifications from the language server."""
539 | token = params.get("token", "")
540 | value = params.get("value", {})
541 |
542 | # Log raw progress for debugging
543 | log.debug(f"Progress notification received: {params}")
544 |
545 | # Handle different progress notification types
546 | kind = value.get("kind")
547 |
548 | if kind == "begin":
549 | title = value.get("title", "Operation in progress")
550 | message = value.get("message", "")
551 | percentage = value.get("percentage")
552 |
553 | if percentage is not None:
554 | log.debug(f"Progress [{token}]: {title} - {message} ({percentage}%)")
555 | else:
556 | log.info(f"Progress [{token}]: {title} - {message}")
557 |
558 | elif kind == "report":
559 | message = value.get("message", "")
560 | percentage = value.get("percentage")
561 |
562 | if percentage is not None:
563 | log.info(f"Progress [{token}]: {message} ({percentage}%)")
564 | elif message:
565 | log.info(f"Progress [{token}]: {message}")
566 |
567 | elif kind == "end":
568 | message = value.get("message", "Operation completed")
569 | log.info(f"Progress [{token}]: {message}")
570 |
571 | def handle_workspace_configuration(params: dict) -> list:
572 | """Handle workspace/configuration requests from the server."""
573 | items = params.get("items", [])
574 | result: list[Any] = []
575 |
576 | for item in items:
577 | section = item.get("section", "")
578 |
579 | # Provide default values based on the configuration section
580 | if section.startswith(("dotnet", "csharp")):
581 | # Default configuration for C# settings
582 | if "enable" in section or "show" in section or "suppress" in section or "navigate" in section:
583 | # Boolean settings
584 | result.append(False)
585 | elif "scope" in section:
586 | # Scope settings - use appropriate enum values
587 | if "analyzer_diagnostics_scope" in section:
588 | result.append("openFiles") # BackgroundAnalysisScope
589 | elif "compiler_diagnostics_scope" in section:
590 | result.append("openFiles") # CompilerDiagnosticsScope
591 | else:
592 | result.append("openFiles")
593 | elif section == "dotnet_member_insertion_location":
594 | # ImplementTypeInsertionBehavior enum
595 | result.append("with_other_members_of_the_same_kind")
596 | elif section == "dotnet_property_generation_behavior":
597 | # ImplementTypePropertyGenerationBehavior enum
598 | result.append("prefer_throwing_properties")
599 | elif "location" in section or "behavior" in section:
600 | # Other enum settings - return null to avoid parsing errors
601 | result.append(None)
602 | else:
603 | # Default for other dotnet/csharp settings
604 | result.append(None)
605 | elif section == "tab_width" or section == "indent_size":
606 | # Tab and indent settings
607 | result.append(4)
608 | elif section == "insert_final_newline":
609 | # Editor settings
610 | result.append(True)
611 | else:
612 | # Unknown configuration - return null
613 | result.append(None)
614 |
615 | return result
616 |
617 | def handle_work_done_progress_create(params: dict) -> None:
618 | """Handle work done progress create requests."""
619 | # Just acknowledge the request
620 | return
621 |
622 | def handle_register_capability(params: dict) -> None:
623 | """Handle client/registerCapability requests."""
624 | # Just acknowledge the request - we don't need to track these for now
625 | return
626 |
627 | def handle_project_needs_restore(params: dict) -> None:
628 | return
629 |
630 | def handle_workspace_indexing_complete(params: dict) -> None:
631 | self.completions_available.set()
632 |
633 | # Set up notification handlers
634 | self.server.on_notification("window/logMessage", window_log_message)
635 | self.server.on_notification("$/progress", handle_progress)
636 | self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
637 | self.server.on_notification("workspace/projectInitializationComplete", handle_workspace_indexing_complete)
638 | self.server.on_request("workspace/configuration", handle_workspace_configuration)
639 | self.server.on_request("window/workDoneProgress/create", handle_work_done_progress_create)
640 | self.server.on_request("client/registerCapability", handle_register_capability)
641 | self.server.on_request("workspace/_roslyn_projectNeedsRestore", handle_project_needs_restore)
642 |
643 | log.info("Starting Microsoft.CodeAnalysis.LanguageServer process")
644 |
645 | try:
646 | self.server.start()
647 | except Exception as e:
648 | log.info(f"Failed to start language server process: {e}", logging.ERROR)
649 | raise SolidLSPException(f"Failed to start C# language server: {e}")
650 |
651 | # Send initialization
652 | initialize_params = self._get_initialize_params()
653 |
654 | log.info("Sending initialize request to language server")
655 | try:
656 | init_response = self.server.send.initialize(initialize_params)
657 | log.info(f"Received initialize response: {init_response}")
658 | except Exception as e:
659 | raise SolidLSPException(f"Failed to initialize C# language server for {self.repository_root_path}: {e}") from e
660 |
661 | # Apply diagnostic capabilities
662 | self._force_pull_diagnostics(init_response)
663 |
664 | # Verify required capabilities
665 | capabilities = init_response.get("capabilities", {})
666 | required_capabilities = [
667 | "textDocumentSync",
668 | "definitionProvider",
669 | "referencesProvider",
670 | "documentSymbolProvider",
671 | ]
672 | missing = [cap for cap in required_capabilities if cap not in capabilities]
673 | if missing:
674 | raise RuntimeError(
675 | f"Language server is missing required capabilities: {', '.join(missing)}. "
676 | "Initialization failed. Please ensure the correct version of Microsoft.CodeAnalysis.LanguageServer is installed and the .NET runtime is working."
677 | )
678 |
679 | # Complete initialization
680 | self.server.notify.initialized({})
681 |
682 | # Open solution and project files
683 | self._open_solution_and_projects()
684 |
685 | self.initialization_complete.set()
686 |
687 | log.info(
688 | "Microsoft.CodeAnalysis.LanguageServer initialized and ready\n"
689 | "Waiting for language server to index project files...\n"
690 | "This may take a while for large projects"
691 | )
692 |
693 | if self.completions_available.wait(30): # Wait up to 30 seconds for indexing
694 | log.info("Indexing complete")
695 | else:
696 | log.warning("Timeout waiting for indexing to complete, proceeding anyway")
697 | self.completions_available.set()
698 |
699 | def _force_pull_diagnostics(self, init_response: dict | InitializeResult) -> None:
700 | """
701 | Apply the diagnostic capabilities hack.
702 | Forces the server to support pull diagnostics.
703 | """
704 | capabilities = init_response.get("capabilities", {})
705 | diagnostic_provider: Any = capabilities.get("diagnosticProvider", {})
706 |
707 | # Add the diagnostic capabilities hack
708 | if isinstance(diagnostic_provider, dict):
709 | diagnostic_provider.update(
710 | {
711 | "interFileDependencies": True,
712 | "workDoneProgress": True,
713 | "workspaceDiagnostics": True,
714 | }
715 | )
716 | log.debug("Applied diagnostic capabilities hack for better C# diagnostics")
717 |
718 | def _open_solution_and_projects(self) -> None:
719 | """
720 | Open solution and project files using notifications.
721 | """
722 | # Find solution file
723 | solution_file = None
724 | for filename in breadth_first_file_scan(self.repository_root_path):
725 | if filename.endswith(".sln"):
726 | solution_file = filename
727 | break
728 |
729 | # Send solution/open notification if solution file found
730 | if solution_file:
731 | solution_uri = PathUtils.path_to_uri(solution_file)
732 | self.server.notify.send_notification("solution/open", {"solution": solution_uri})
733 | log.debug(f"Opened solution file: {solution_file}")
734 |
735 | # Find and open project files
736 | project_files = []
737 | for filename in breadth_first_file_scan(self.repository_root_path):
738 | if filename.endswith(".csproj"):
739 | project_files.append(filename)
740 |
741 | # Send project/open notifications for each project file
742 | if project_files:
743 | project_uris = [PathUtils.path_to_uri(project_file) for project_file in project_files]
744 | self.server.notify.send_notification("project/open", {"projects": project_uris})
745 | log.debug(f"Opened project files: {project_files}")
746 |
747 | @override
748 | def _get_wait_time_for_cross_file_referencing(self) -> float:
749 | return 2
750 |
```