This is page 20 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
--------------------------------------------------------------------------------
/src/solidlsp/ls.py:
--------------------------------------------------------------------------------
```python
1 | import dataclasses
2 | import hashlib
3 | import json
4 | import logging
5 | import os
6 | import pathlib
7 | import shutil
8 | import subprocess
9 | import threading
10 | from abc import ABC, abstractmethod
11 | from collections import defaultdict
12 | from collections.abc import Hashable, Iterator
13 | from contextlib import contextmanager
14 | from copy import copy
15 | from pathlib import Path, PurePath
16 | from time import sleep
17 | from typing import Self, Union, cast
18 |
19 | import pathspec
20 | from sensai.util.pickle import getstate, load_pickle
21 |
22 | from serena.text_utils import MatchedConsecutiveLines
23 | from serena.util.file_system import match_path
24 | from solidlsp import ls_types
25 | from solidlsp.ls_config import Language, LanguageServerConfig
26 | from solidlsp.ls_exceptions import SolidLSPException
27 | from solidlsp.ls_handler import SolidLanguageServerHandler
28 | from solidlsp.ls_types import UnifiedSymbolInformation
29 | from solidlsp.ls_utils import FileUtils, PathUtils, TextUtils
30 | from solidlsp.lsp_protocol_handler import lsp_types
31 | from solidlsp.lsp_protocol_handler import lsp_types as LSPTypes
32 | from solidlsp.lsp_protocol_handler.lsp_constants import LSPConstants
33 | from solidlsp.lsp_protocol_handler.lsp_types import (
34 | Definition,
35 | DefinitionParams,
36 | DocumentSymbol,
37 | LocationLink,
38 | RenameParams,
39 | SymbolInformation,
40 | )
41 | from solidlsp.lsp_protocol_handler.server import (
42 | LSPError,
43 | ProcessLaunchInfo,
44 | StringDict,
45 | )
46 | from solidlsp.settings import SolidLSPSettings
47 | from solidlsp.util.cache import load_cache, save_cache
48 |
49 | GenericDocumentSymbol = Union[LSPTypes.DocumentSymbol, LSPTypes.SymbolInformation, ls_types.UnifiedSymbolInformation]
50 | log = logging.getLogger(__name__)
51 |
52 |
53 | @dataclasses.dataclass(kw_only=True)
54 | class ReferenceInSymbol:
55 | """A symbol retrieved when requesting reference to a symbol, together with the location of the reference"""
56 |
57 | symbol: ls_types.UnifiedSymbolInformation
58 | line: int
59 | character: int
60 |
61 |
62 | @dataclasses.dataclass
63 | class LSPFileBuffer:
64 | """
65 | This class is used to store the contents of an open LSP file in memory.
66 | """
67 |
68 | # uri of the file
69 | uri: str
70 |
71 | # The contents of the file
72 | contents: str
73 |
74 | # The version of the file
75 | version: int
76 |
77 | # The language id of the file
78 | language_id: str
79 |
80 | # reference count of the file
81 | ref_count: int
82 |
83 | content_hash: str = ""
84 |
85 | def __post_init__(self) -> None:
86 | self.content_hash = hashlib.md5(self.contents.encode("utf-8")).hexdigest()
87 |
88 | def split_lines(self) -> list[str]:
89 | """Splits the contents of the file into lines."""
90 | return self.contents.split("\n")
91 |
92 |
93 | class DocumentSymbols:
94 | # IMPORTANT: Instances of this class are persisted in the high-level document symbol cache
95 |
96 | def __init__(self, root_symbols: list[ls_types.UnifiedSymbolInformation]):
97 | self.root_symbols = root_symbols
98 | self._all_symbols: list[ls_types.UnifiedSymbolInformation] | None = None
99 |
100 | def __getstate__(self) -> dict:
101 | return getstate(DocumentSymbols, self, transient_properties=["_all_symbols"])
102 |
103 | def iter_symbols(self) -> Iterator[ls_types.UnifiedSymbolInformation]:
104 | """
105 | Iterate over all symbols in the document symbol tree.
106 | Yields symbols in a depth-first manner.
107 | """
108 | if self._all_symbols is not None:
109 | yield from self._all_symbols
110 | return
111 |
112 | def traverse(s: ls_types.UnifiedSymbolInformation) -> Iterator[ls_types.UnifiedSymbolInformation]:
113 | yield s
114 | for child in s.get("children", []):
115 | yield from traverse(child)
116 |
117 | for root_symbol in self.root_symbols:
118 | yield from traverse(root_symbol)
119 |
120 | def get_all_symbols_and_roots(self) -> tuple[list[ls_types.UnifiedSymbolInformation], list[ls_types.UnifiedSymbolInformation]]:
121 | """
122 | This function returns all symbols in the document as a flat list and the root symbols.
123 | It exists to facilitate migration from previous versions, where this was the return interface of
124 | the LS method that obtained document symbols.
125 |
126 | :return: A tuple containing a list of all symbols in the document and a list of root symbols.
127 | """
128 | if self._all_symbols is None:
129 | self._all_symbols = list(self.iter_symbols())
130 | return self._all_symbols, self.root_symbols
131 |
132 |
133 | class SolidLanguageServer(ABC):
134 | """
135 | The LanguageServer class provides a language agnostic interface to the Language Server Protocol.
136 | It is used to communicate with Language Servers of different programming languages.
137 | """
138 |
139 | CACHE_FOLDER_NAME = "cache"
140 | RAW_DOCUMENT_SYMBOLS_CACHE_VERSION = 1
141 | """
142 | global version identifier for raw symbol caches; an LS-specific version is defined separately and combined with this.
143 | This should be incremented whenever there is a change in the way raw document symbols are stored.
144 | If the result of a language server changes in a way that affects the raw document symbols,
145 | the LS-specific version should be incremented instead.
146 | """
147 | RAW_DOCUMENT_SYMBOL_CACHE_FILENAME = "raw_document_symbols.pkl"
148 | RAW_DOCUMENT_SYMBOL_CACHE_FILENAME_LEGACY_FALLBACK = "document_symbols_cache_v23-06-25.pkl"
149 | DOCUMENT_SYMBOL_CACHE_VERSION = 3
150 | DOCUMENT_SYMBOL_CACHE_FILENAME = "document_symbols.pkl"
151 |
152 | # To be overridden and extended by subclasses
153 | def is_ignored_dirname(self, dirname: str) -> bool:
154 | """
155 | A language-specific condition for directories that should always be ignored. For example, venv
156 | in Python and node_modules in JS/TS should be ignored always.
157 | """
158 | return dirname.startswith(".")
159 |
160 | @staticmethod
161 | def _determine_log_level(line: str) -> int:
162 | """
163 | Classify a stderr line from the language server to determine appropriate logging level.
164 |
165 | Language servers may emit informational messages to stderr that contain words like "error"
166 | but are not actual errors. Subclasses can override this method to filter out known
167 | false-positive patterns specific to their language server.
168 |
169 | :param line: The stderr line to classify
170 | :return: A logging level (logging.DEBUG, logging.INFO, logging.WARNING, or logging.ERROR)
171 | """
172 | line_lower = line.lower()
173 |
174 | # Default classification: treat lines with "error" or "exception" as ERROR level
175 | if "error" in line_lower or "exception" in line_lower or line.startswith("E["):
176 | return logging.ERROR
177 | else:
178 | return logging.INFO
179 |
180 | @classmethod
181 | def get_language_enum_instance(cls) -> Language:
182 | return Language.from_ls_class(cls)
183 |
184 | @classmethod
185 | def ls_resources_dir(cls, solidlsp_settings: SolidLSPSettings, mkdir: bool = True) -> str:
186 | """
187 | Returns the directory where the language server resources are downloaded.
188 | This is used to store language server binaries, configuration files, etc.
189 | """
190 | result = os.path.join(solidlsp_settings.ls_resources_dir, cls.__name__)
191 |
192 | # Migration of previously downloaded LS resources that were downloaded to a subdir of solidlsp instead of to the user's home
193 | pre_migration_ls_resources_dir = os.path.join(os.path.dirname(__file__), "language_servers", "static", cls.__name__)
194 | if os.path.exists(pre_migration_ls_resources_dir):
195 | if os.path.exists(result):
196 | # if the directory already exists, we just remove the old resources
197 | shutil.rmtree(result, ignore_errors=True)
198 | else:
199 | # move old resources to the new location
200 | shutil.move(pre_migration_ls_resources_dir, result)
201 | if mkdir:
202 | os.makedirs(result, exist_ok=True)
203 | return result
204 |
205 | @classmethod
206 | def create(
207 | cls,
208 | config: LanguageServerConfig,
209 | repository_root_path: str,
210 | timeout: float | None = None,
211 | solidlsp_settings: SolidLSPSettings | None = None,
212 | ) -> "SolidLanguageServer":
213 | """
214 | Creates a language specific LanguageServer instance based on the given configuration, and appropriate settings for the programming language.
215 |
216 | If language is Java, then ensure that jdk-17.0.6 or higher is installed, `java` is in PATH, and JAVA_HOME is set to the installation directory.
217 | If language is JS/TS, then ensure that node (v18.16.0 or higher) is installed and in PATH.
218 |
219 | :param repository_root_path: The root path of the repository.
220 | :param config: language server configuration.
221 | :param logger: The logger to use.
222 | :param timeout: the timeout for requests to the language server. If None, no timeout will be used.
223 | :param solidlsp_settings: additional settings
224 | :return LanguageServer: A language specific LanguageServer instance.
225 | """
226 | ls: SolidLanguageServer
227 | if solidlsp_settings is None:
228 | solidlsp_settings = SolidLSPSettings()
229 |
230 | # Ensure repository_root_path is absolute to avoid issues with file URIs
231 | repository_root_path = os.path.abspath(repository_root_path)
232 |
233 | ls_class = config.code_language.get_ls_class()
234 | # For now, we assume that all language server implementations have the same signature of the constructor
235 | # (which, unfortunately, differs from the signature of the base class).
236 | # If this assumption is ever violated, we need branching logic here.
237 | ls = ls_class(config, repository_root_path, solidlsp_settings) # type: ignore
238 | ls.set_request_timeout(timeout)
239 | return ls
240 |
241 | def __init__(
242 | self,
243 | config: LanguageServerConfig,
244 | repository_root_path: str,
245 | process_launch_info: ProcessLaunchInfo,
246 | language_id: str,
247 | solidlsp_settings: SolidLSPSettings,
248 | cache_version_raw_document_symbols: Hashable = 1,
249 | ):
250 | """
251 | Initializes a LanguageServer instance.
252 |
253 | Do not instantiate this class directly. Use `LanguageServer.create` method instead.
254 |
255 | :param config: the global SolidLSP configuration.
256 | :param repository_root_path: the root path of the repository.
257 | :param process_launch_info: the command used to start the actual language server.
258 | The command must pass appropriate flags to the binary, so that it runs in the stdio mode,
259 | as opposed to HTTP, TCP modes supported by some language servers.
260 | :param cache_version_raw_document_symbols: the version, for caching, of the raw document symbols coming
261 | from this specific language server. This should be incremented by subclasses calling this constructor
262 | whenever the format of the raw document symbols changes (typically because the language server
263 | improves/fixes its output).
264 | """
265 | self._solidlsp_settings = solidlsp_settings
266 | lang = self.get_language_enum_instance()
267 | self._custom_settings = solidlsp_settings.get_ls_specific_settings(lang)
268 | log.debug(f"Custom config (LS-specific settings) for {lang}: {self._custom_settings}")
269 | self._encoding = config.encoding
270 | self.repository_root_path: str = repository_root_path
271 | log.debug(
272 | f"Creating language server instance for {repository_root_path=} with {language_id=} and process launch info: {process_launch_info}"
273 | )
274 |
275 | self.language_id = language_id
276 | self.open_file_buffers: dict[str, LSPFileBuffer] = {}
277 | self.language = Language(language_id)
278 |
279 | # initialise symbol caches
280 | self.cache_dir = (
281 | Path(self.repository_root_path) / self._solidlsp_settings.project_data_relative_path / self.CACHE_FOLDER_NAME / self.language_id
282 | )
283 | self.cache_dir.mkdir(parents=True, exist_ok=True)
284 | # * raw document symbols cache
285 | self._ls_specific_raw_document_symbols_cache_version = cache_version_raw_document_symbols
286 | self._raw_document_symbols_cache: dict[str, tuple[str, list[DocumentSymbol] | list[SymbolInformation] | None]] = {}
287 | """maps relative file paths to a tuple of (file_content_hash, raw_root_symbols)"""
288 | self._raw_document_symbols_cache_is_modified: bool = False
289 | self._load_raw_document_symbols_cache()
290 | # * high-level document symbols cache
291 | self._document_symbols_cache: dict[str, tuple[str, DocumentSymbols]] = {}
292 | """maps relative file paths to a tuple of (file_content_hash, document_symbols)"""
293 | self._document_symbols_cache_is_modified: bool = False
294 | self._load_document_symbols_cache()
295 |
296 | self.server_started = False
297 | self.completions_available = threading.Event()
298 | if config.trace_lsp_communication:
299 |
300 | def logging_fn(source: str, target: str, msg: StringDict | str) -> None:
301 | log.debug(f"LSP: {source} -> {target}: {msg!s}")
302 |
303 | else:
304 | logging_fn = None # type: ignore
305 |
306 | # cmd is obtained from the child classes, which provide the language specific command to start the language server
307 | # LanguageServerHandler provides the functionality to start the language server and communicate with it
308 | log.debug(f"Creating language server instance with {language_id=} and process launch info: {process_launch_info}")
309 | self.server = SolidLanguageServerHandler(
310 | process_launch_info,
311 | language=self.language,
312 | determine_log_level=self._determine_log_level,
313 | logger=logging_fn,
314 | start_independent_lsp_process=config.start_independent_lsp_process,
315 | )
316 |
317 | # Set up the pathspec matcher for the ignored paths
318 | # for all absolute paths in ignored_paths, convert them to relative paths
319 | processed_patterns = []
320 | for pattern in set(config.ignored_paths):
321 | # Normalize separators (pathspec expects forward slashes)
322 | pattern = pattern.replace(os.path.sep, "/")
323 | processed_patterns.append(pattern)
324 | log.debug(f"Processing {len(processed_patterns)} ignored paths from the config")
325 |
326 | # Create a pathspec matcher from the processed patterns
327 | self._ignore_spec = pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, processed_patterns)
328 |
329 | self._request_timeout: float | None = None
330 |
331 | self._has_waited_for_cross_file_references = False
332 |
333 | def _get_wait_time_for_cross_file_referencing(self) -> float:
334 | """Meant to be overridden by subclasses for LS that don't have a reliable "finished initializing" signal.
335 |
336 | LS may return incomplete results on calls to `request_references` (only references found in the same file),
337 | if the LS is not fully initialized yet.
338 | """
339 | return 2
340 |
341 | def set_request_timeout(self, timeout: float | None) -> None:
342 | """
343 | :param timeout: the timeout, in seconds, for requests to the language server.
344 | """
345 | self.server.set_request_timeout(timeout)
346 |
347 | def get_ignore_spec(self) -> pathspec.PathSpec:
348 | """
349 | Returns the pathspec matcher for the paths that were configured to be ignored through
350 | the language server configuration.
351 |
352 | This is a subset of the full language-specific ignore spec that determines
353 | which files are relevant for the language server.
354 |
355 | This matcher is useful for operations outside of the language server,
356 | such as when searching for relevant non-language files in the project.
357 | """
358 | return self._ignore_spec
359 |
360 | def is_ignored_path(self, relative_path: str, ignore_unsupported_files: bool = True) -> bool:
361 | """
362 | Determine if a path should be ignored based on file type
363 | and ignore patterns.
364 |
365 | :param relative_path: Relative path to check
366 | :param ignore_unsupported_files: whether files that are not supported source files should be ignored
367 |
368 | :return: True if the path should be ignored, False otherwise
369 | """
370 | abs_path = os.path.join(self.repository_root_path, relative_path)
371 | if not os.path.exists(abs_path):
372 | raise FileNotFoundError(f"File {abs_path} not found, the ignore check cannot be performed")
373 |
374 | # Check file extension if it's a file
375 | is_file = os.path.isfile(abs_path)
376 | if is_file and ignore_unsupported_files:
377 | fn_matcher = self.language.get_source_fn_matcher()
378 | if not fn_matcher.is_relevant_filename(abs_path):
379 | return True
380 |
381 | # Create normalized path for consistent handling
382 | rel_path = Path(relative_path)
383 |
384 | # Check each part of the path against always fulfilled ignore conditions
385 | dir_parts = rel_path.parts
386 | if is_file:
387 | dir_parts = dir_parts[:-1]
388 | for part in dir_parts:
389 | if not part: # Skip empty parts (e.g., from leading '/')
390 | continue
391 | if self.is_ignored_dirname(part):
392 | return True
393 |
394 | return match_path(relative_path, self.get_ignore_spec(), root_path=self.repository_root_path)
395 |
396 | def _shutdown(self, timeout: float = 5.0) -> None:
397 | """
398 | A robust shutdown process designed to terminate cleanly on all platforms, including Windows,
399 | by explicitly closing all I/O pipes.
400 | """
401 | if not self.server.is_running():
402 | log.debug("Server process not running, skipping shutdown.")
403 | return
404 |
405 | log.info(f"Initiating final robust shutdown with a {timeout}s timeout...")
406 | process = self.server.process
407 | if process is None:
408 | log.debug("Server process is None, cannot shutdown.")
409 | return
410 |
411 | # --- Main Shutdown Logic ---
412 | # Stage 1: Graceful Termination Request
413 | # Send LSP shutdown and close stdin to signal no more input.
414 | try:
415 | log.debug("Sending LSP shutdown request...")
416 | # Use a thread to timeout the LSP shutdown call since it can hang
417 | shutdown_thread = threading.Thread(target=self.server.shutdown)
418 | shutdown_thread.daemon = True
419 | shutdown_thread.start()
420 | shutdown_thread.join(timeout=2.0) # 2 second timeout for LSP shutdown
421 |
422 | if shutdown_thread.is_alive():
423 | log.debug("LSP shutdown request timed out, proceeding to terminate...")
424 | else:
425 | log.debug("LSP shutdown request completed.")
426 |
427 | if process.stdin and not process.stdin.closed:
428 | process.stdin.close()
429 | log.debug("Stage 1 shutdown complete.")
430 | except Exception as e:
431 | log.debug(f"Exception during graceful shutdown: {e}")
432 | # Ignore errors here, we are proceeding to terminate anyway.
433 |
434 | # Stage 2: Terminate and Wait for Process to Exit
435 | log.debug(f"Terminating process {process.pid}, current status: {process.poll()}")
436 | process.terminate()
437 |
438 | # Stage 3: Wait for process termination with timeout
439 | try:
440 | log.debug(f"Waiting for process {process.pid} to terminate...")
441 | exit_code = process.wait(timeout=timeout)
442 | log.info(f"Language server process terminated successfully with exit code {exit_code}.")
443 | except subprocess.TimeoutExpired:
444 | # If termination failed, forcefully kill the process
445 | log.warning(f"Process {process.pid} termination timed out, killing process forcefully...")
446 | process.kill()
447 | try:
448 | exit_code = process.wait(timeout=2.0)
449 | log.info(f"Language server process killed successfully with exit code {exit_code}.")
450 | except subprocess.TimeoutExpired:
451 | log.error(f"Process {process.pid} could not be killed within timeout.")
452 | except Exception as e:
453 | log.error(f"Error during process shutdown: {e}")
454 |
455 | @contextmanager
456 | def start_server(self) -> Iterator["SolidLanguageServer"]:
457 | self.start()
458 | yield self
459 | self.stop()
460 |
461 | def _start_server_process(self) -> None:
462 | self.server_started = True
463 | self._start_server()
464 |
465 | @abstractmethod
466 | def _start_server(self) -> None:
467 | pass
468 |
469 | def _get_language_id_for_file(self, relative_file_path: str) -> str:
470 | """Return the language ID for a file.
471 |
472 | Override in subclasses to return file-specific language IDs.
473 | Default implementation returns self.language_id.
474 | """
475 | return self.language_id
476 |
477 | @contextmanager
478 | def open_file(self, relative_file_path: str) -> Iterator[LSPFileBuffer]:
479 | """
480 | Open a file in the Language Server. This is required before making any requests to the Language Server.
481 |
482 | :param relative_file_path: The relative path of the file to open.
483 | """
484 | if not self.server_started:
485 | log.error("open_file called before Language Server started")
486 | raise SolidLSPException("Language Server not started")
487 |
488 | absolute_file_path = str(PurePath(self.repository_root_path, relative_file_path))
489 | uri = pathlib.Path(absolute_file_path).as_uri()
490 |
491 | if uri in self.open_file_buffers:
492 | assert self.open_file_buffers[uri].uri == uri
493 | assert self.open_file_buffers[uri].ref_count >= 1
494 |
495 | self.open_file_buffers[uri].ref_count += 1
496 | yield self.open_file_buffers[uri]
497 | self.open_file_buffers[uri].ref_count -= 1
498 | else:
499 | contents = FileUtils.read_file(absolute_file_path, self._encoding)
500 |
501 | version = 0
502 | language_id = self._get_language_id_for_file(relative_file_path)
503 | self.open_file_buffers[uri] = LSPFileBuffer(uri, contents, version, language_id, 1)
504 |
505 | self.server.notify.did_open_text_document(
506 | {
507 | LSPConstants.TEXT_DOCUMENT: { # type: ignore
508 | LSPConstants.URI: uri,
509 | LSPConstants.LANGUAGE_ID: language_id,
510 | LSPConstants.VERSION: 0,
511 | LSPConstants.TEXT: contents,
512 | }
513 | }
514 | )
515 | yield self.open_file_buffers[uri]
516 | self.open_file_buffers[uri].ref_count -= 1
517 |
518 | if self.open_file_buffers[uri].ref_count == 0:
519 | self.server.notify.did_close_text_document(
520 | {
521 | LSPConstants.TEXT_DOCUMENT: { # type: ignore
522 | LSPConstants.URI: uri,
523 | }
524 | }
525 | )
526 | del self.open_file_buffers[uri]
527 |
528 | @contextmanager
529 | def _open_file_context(self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None) -> Iterator[LSPFileBuffer]:
530 | """
531 | Internal context manager to open a file, optionally reusing an existing file buffer.
532 |
533 | :param relative_file_path: the relative path of the file to open.
534 | :param file_buffer: an optional existing file buffer to reuse.
535 | """
536 | if file_buffer is not None:
537 | yield file_buffer
538 | else:
539 | with self.open_file(relative_file_path) as fb:
540 | yield fb
541 |
542 | def insert_text_at_position(self, relative_file_path: str, line: int, column: int, text_to_be_inserted: str) -> ls_types.Position:
543 | """
544 | Insert text at the given line and column in the given file and return
545 | the updated cursor position after inserting the text.
546 |
547 | :param relative_file_path: The relative path of the file to open.
548 | :param line: The line number at which text should be inserted.
549 | :param column: The column number at which text should be inserted.
550 | :param text_to_be_inserted: The text to insert.
551 | """
552 | if not self.server_started:
553 | log.error("insert_text_at_position called before Language Server started")
554 | raise SolidLSPException("Language Server not started")
555 |
556 | absolute_file_path = str(PurePath(self.repository_root_path, relative_file_path))
557 | uri = pathlib.Path(absolute_file_path).as_uri()
558 |
559 | # Ensure the file is open
560 | assert uri in self.open_file_buffers
561 |
562 | file_buffer = self.open_file_buffers[uri]
563 | file_buffer.version += 1
564 |
565 | new_contents, new_l, new_c = TextUtils.insert_text_at_position(file_buffer.contents, line, column, text_to_be_inserted)
566 | file_buffer.contents = new_contents
567 | self.server.notify.did_change_text_document(
568 | {
569 | LSPConstants.TEXT_DOCUMENT: { # type: ignore
570 | LSPConstants.VERSION: file_buffer.version,
571 | LSPConstants.URI: file_buffer.uri,
572 | },
573 | LSPConstants.CONTENT_CHANGES: [
574 | {
575 | LSPConstants.RANGE: {
576 | "start": {"line": line, "character": column},
577 | "end": {"line": line, "character": column},
578 | },
579 | "text": text_to_be_inserted,
580 | }
581 | ],
582 | }
583 | )
584 | return ls_types.Position(line=new_l, character=new_c)
585 |
586 | def delete_text_between_positions(
587 | self,
588 | relative_file_path: str,
589 | start: ls_types.Position,
590 | end: ls_types.Position,
591 | ) -> str:
592 | """
593 | Delete text between the given start and end positions in the given file and return the deleted text.
594 | """
595 | if not self.server_started:
596 | log.error("insert_text_at_position called before Language Server started")
597 | raise SolidLSPException("Language Server not started")
598 |
599 | absolute_file_path = str(PurePath(self.repository_root_path, relative_file_path))
600 | uri = pathlib.Path(absolute_file_path).as_uri()
601 |
602 | # Ensure the file is open
603 | assert uri in self.open_file_buffers
604 |
605 | file_buffer = self.open_file_buffers[uri]
606 | file_buffer.version += 1
607 | new_contents, deleted_text = TextUtils.delete_text_between_positions(
608 | file_buffer.contents, start_line=start["line"], start_col=start["character"], end_line=end["line"], end_col=end["character"]
609 | )
610 | file_buffer.contents = new_contents
611 | self.server.notify.did_change_text_document(
612 | {
613 | LSPConstants.TEXT_DOCUMENT: { # type: ignore
614 | LSPConstants.VERSION: file_buffer.version,
615 | LSPConstants.URI: file_buffer.uri,
616 | },
617 | LSPConstants.CONTENT_CHANGES: [{LSPConstants.RANGE: {"start": start, "end": end}, "text": ""}],
618 | }
619 | )
620 | return deleted_text
621 |
622 | def _send_definition_request(self, definition_params: DefinitionParams) -> Definition | list[LocationLink] | None:
623 | return self.server.send.definition(definition_params)
624 |
625 | def request_definition(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]:
626 | """
627 | Raise a [textDocument/definition](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_definition) request to the Language Server
628 | for the symbol at the given line and column in the given file. Wait for the response and return the result.
629 |
630 | :param relative_file_path: The relative path of the file that has the symbol for which definition should be looked up
631 | :param line: The line number of the symbol
632 | :param column: The column number of the symbol
633 |
634 | :return: the list of locations where the symbol is defined
635 | """
636 | if not self.server_started:
637 | log.error("request_definition called before language server started")
638 | raise SolidLSPException("Language Server not started")
639 |
640 | if not self._has_waited_for_cross_file_references:
641 | # Some LS require waiting for a while before they can return cross-file definitions.
642 | # This is a workaround for such LS that don't have a reliable "finished initializing" signal.
643 | sleep(self._get_wait_time_for_cross_file_referencing())
644 | self._has_waited_for_cross_file_references = True
645 |
646 | with self.open_file(relative_file_path):
647 | # sending request to the language server and waiting for response
648 | definition_params = cast(
649 | DefinitionParams,
650 | {
651 | LSPConstants.TEXT_DOCUMENT: {
652 | LSPConstants.URI: pathlib.Path(str(PurePath(self.repository_root_path, relative_file_path))).as_uri()
653 | },
654 | LSPConstants.POSITION: {
655 | LSPConstants.LINE: line,
656 | LSPConstants.CHARACTER: column,
657 | },
658 | },
659 | )
660 | response = self._send_definition_request(definition_params)
661 |
662 | ret: list[ls_types.Location] = []
663 | if isinstance(response, list):
664 | # response is either of type Location[] or LocationLink[]
665 | for item in response:
666 | assert isinstance(item, dict)
667 | if LSPConstants.URI in item and LSPConstants.RANGE in item:
668 | new_item: dict = {}
669 | new_item.update(item)
670 | new_item["absolutePath"] = PathUtils.uri_to_path(new_item["uri"])
671 | new_item["relativePath"] = PathUtils.get_relative_path(new_item["absolutePath"], self.repository_root_path)
672 | ret.append(ls_types.Location(**new_item)) # type: ignore
673 | elif LSPConstants.TARGET_URI in item and LSPConstants.TARGET_RANGE in item and LSPConstants.TARGET_SELECTION_RANGE in item:
674 | new_item: dict = {} # type: ignore
675 | new_item["uri"] = item[LSPConstants.TARGET_URI] # type: ignore
676 | new_item["absolutePath"] = PathUtils.uri_to_path(new_item["uri"])
677 | new_item["relativePath"] = PathUtils.get_relative_path(new_item["absolutePath"], self.repository_root_path)
678 | new_item["range"] = item[LSPConstants.TARGET_SELECTION_RANGE] # type: ignore
679 | ret.append(ls_types.Location(**new_item)) # type: ignore
680 | else:
681 | assert False, f"Unexpected response from Language Server: {item}"
682 | elif isinstance(response, dict):
683 | # response is of type Location
684 | assert LSPConstants.URI in response
685 | assert LSPConstants.RANGE in response
686 |
687 | new_item: dict = {} # type: ignore
688 | new_item.update(response)
689 | new_item["absolutePath"] = PathUtils.uri_to_path(new_item["uri"])
690 | new_item["relativePath"] = PathUtils.get_relative_path(new_item["absolutePath"], self.repository_root_path)
691 | ret.append(ls_types.Location(**new_item)) # type: ignore
692 | elif response is None:
693 | # Some language servers return None when they cannot find a definition
694 | # This is expected for certain symbol types like generics or types with incomplete information
695 | log.warning(f"Language server returned None for definition request at {relative_file_path}:{line}:{column}")
696 | else:
697 | assert False, f"Unexpected response from Language Server: {response}"
698 |
699 | return ret
700 |
701 | # Some LS cause problems with this, so the call is isolated from the rest to allow overriding in subclasses
702 | def _send_references_request(self, relative_file_path: str, line: int, column: int) -> list[lsp_types.Location] | None:
703 | return self.server.send.references(
704 | {
705 | "textDocument": {"uri": PathUtils.path_to_uri(os.path.join(self.repository_root_path, relative_file_path))},
706 | "position": {"line": line, "character": column},
707 | "context": {"includeDeclaration": False},
708 | }
709 | )
710 |
711 | def request_references(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]:
712 | """
713 | Raise a [textDocument/references](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_references) request to the Language Server
714 | to find references to the symbol at the given line and column in the given file. Wait for the response and return the result.
715 | Filters out references located in ignored directories.
716 |
717 | :param relative_file_path: The relative path of the file that has the symbol for which references should be looked up
718 | :param line: The line number of the symbol
719 | :param column: The column number of the symbol
720 |
721 | :return: A list of locations where the symbol is referenced (excluding ignored directories)
722 | """
723 | if not self.server_started:
724 | log.error("request_references called before Language Server started")
725 | raise SolidLSPException("Language Server not started")
726 |
727 | if not self._has_waited_for_cross_file_references:
728 | # Some LS require waiting for a while before they can return cross-file references.
729 | # This is a workaround for such LS that don't have a reliable "finished initializing" signal.
730 | sleep(self._get_wait_time_for_cross_file_referencing())
731 | self._has_waited_for_cross_file_references = True
732 |
733 | with self.open_file(relative_file_path):
734 | try:
735 | response = self._send_references_request(relative_file_path, line=line, column=column)
736 | except Exception as e:
737 | # Catch LSP internal error (-32603) and raise a more informative exception
738 | if isinstance(e, LSPError) and getattr(e, "code", None) == -32603:
739 | raise RuntimeError(
740 | f"LSP internal error (-32603) when requesting references for {relative_file_path}:{line}:{column}. "
741 | "This often occurs when requesting references for a symbol not referenced in the expected way. "
742 | ) from e
743 | raise
744 | if response is None:
745 | return []
746 |
747 | ret: list[ls_types.Location] = []
748 | assert isinstance(response, list), f"Unexpected response from Language Server (expected list, got {type(response)}): {response}"
749 | for item in response:
750 | assert isinstance(item, dict), f"Unexpected response from Language Server (expected dict, got {type(item)}): {item}"
751 | assert LSPConstants.URI in item
752 | assert LSPConstants.RANGE in item
753 |
754 | abs_path = PathUtils.uri_to_path(item[LSPConstants.URI]) # type: ignore
755 | if not Path(abs_path).is_relative_to(self.repository_root_path):
756 | log.warning(
757 | "Found a reference in a path outside the repository, probably the LS is parsing things in installed packages or in the standardlib! "
758 | f"Path: {abs_path}. This is a bug but we currently simply skip these references."
759 | )
760 | continue
761 |
762 | rel_path = Path(abs_path).relative_to(self.repository_root_path)
763 | if self.is_ignored_path(str(rel_path)):
764 | log.debug("Ignoring reference in %s since it should be ignored", rel_path)
765 | continue
766 |
767 | new_item: dict = {}
768 | new_item.update(item)
769 | new_item["absolutePath"] = str(abs_path)
770 | new_item["relativePath"] = str(rel_path)
771 | ret.append(ls_types.Location(**new_item)) # type: ignore
772 |
773 | return ret
774 |
775 | def request_text_document_diagnostics(self, relative_file_path: str) -> list[ls_types.Diagnostic]:
776 | """
777 | Raise a [textDocument/diagnostic](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_diagnostic) request to the Language Server
778 | to find diagnostics for the given file. Wait for the response and return the result.
779 |
780 | :param relative_file_path: The relative path of the file to retrieve diagnostics for
781 |
782 | :return: A list of diagnostics for the file
783 | """
784 | if not self.server_started:
785 | log.error("request_text_document_diagnostics called before Language Server started")
786 | raise SolidLSPException("Language Server not started")
787 |
788 | with self.open_file(relative_file_path):
789 | response = self.server.send.text_document_diagnostic(
790 | {
791 | LSPConstants.TEXT_DOCUMENT: { # type: ignore
792 | LSPConstants.URI: pathlib.Path(str(PurePath(self.repository_root_path, relative_file_path))).as_uri()
793 | }
794 | }
795 | )
796 |
797 | if response is None:
798 | return [] # type: ignore
799 |
800 | assert isinstance(response, dict), f"Unexpected response from Language Server (expected list, got {type(response)}): {response}"
801 | ret: list[ls_types.Diagnostic] = []
802 | for item in response["items"]: # type: ignore
803 | new_item: ls_types.Diagnostic = {
804 | "uri": pathlib.Path(str(PurePath(self.repository_root_path, relative_file_path))).as_uri(),
805 | "severity": item["severity"],
806 | "message": item["message"],
807 | "range": item["range"],
808 | "code": item["code"], # type: ignore
809 | }
810 | ret.append(ls_types.Diagnostic(**new_item))
811 |
812 | return ret
813 |
814 | def retrieve_full_file_content(self, file_path: str) -> str:
815 | """
816 | Retrieve the full content of the given file.
817 | """
818 | if os.path.isabs(file_path):
819 | file_path = os.path.relpath(file_path, self.repository_root_path)
820 | with self.open_file(file_path) as file_data:
821 | return file_data.contents
822 |
823 | def retrieve_content_around_line(
824 | self, relative_file_path: str, line: int, context_lines_before: int = 0, context_lines_after: int = 0
825 | ) -> MatchedConsecutiveLines:
826 | """
827 | Retrieve the content of the given file around the given line.
828 |
829 | :param relative_file_path: The relative path of the file to retrieve the content from
830 | :param line: The line number to retrieve the content around
831 | :param context_lines_before: The number of lines to retrieve before the given line
832 | :param context_lines_after: The number of lines to retrieve after the given line
833 |
834 | :return MatchedConsecutiveLines: A container with the desired lines.
835 | """
836 | with self.open_file(relative_file_path) as file_data:
837 | file_contents = file_data.contents
838 | return MatchedConsecutiveLines.from_file_contents(
839 | file_contents,
840 | line=line,
841 | context_lines_before=context_lines_before,
842 | context_lines_after=context_lines_after,
843 | source_file_path=relative_file_path,
844 | )
845 |
846 | def request_completions(
847 | self, relative_file_path: str, line: int, column: int, allow_incomplete: bool = False
848 | ) -> list[ls_types.CompletionItem]:
849 | """
850 | Raise a [textDocument/completion](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_completion) request to the Language Server
851 | to find completions at the given line and column in the given file. Wait for the response and return the result.
852 |
853 | :param relative_file_path: The relative path of the file that has the symbol for which completions should be looked up
854 | :param line: The line number of the symbol
855 | :param column: The column number of the symbol
856 |
857 | :return: A list of completions
858 | """
859 | with self.open_file(relative_file_path):
860 | open_file_buffer = self.open_file_buffers[pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)).as_uri()]
861 | completion_params: LSPTypes.CompletionParams = {
862 | "position": {"line": line, "character": column},
863 | "textDocument": {"uri": open_file_buffer.uri},
864 | "context": {"triggerKind": LSPTypes.CompletionTriggerKind.Invoked},
865 | }
866 | response: list[LSPTypes.CompletionItem] | LSPTypes.CompletionList | None = None
867 |
868 | num_retries = 0
869 | while response is None or (response["isIncomplete"] and num_retries < 30): # type: ignore
870 | self.completions_available.wait()
871 | response = self.server.send.completion(completion_params)
872 | if isinstance(response, list):
873 | response = {"items": response, "isIncomplete": False}
874 | num_retries += 1
875 |
876 | # TODO: Understand how to appropriately handle `isIncomplete`
877 | if response is None or (response["isIncomplete"] and not allow_incomplete): # type: ignore
878 | return []
879 |
880 | if "items" in response:
881 | response = response["items"] # type: ignore
882 |
883 | response = cast(list[LSPTypes.CompletionItem], response)
884 |
885 | # TODO: Handle the case when the completion is a keyword
886 | items = [item for item in response if item["kind"] != LSPTypes.CompletionItemKind.Keyword]
887 |
888 | completions_list: list[ls_types.CompletionItem] = []
889 |
890 | for item in items:
891 | assert "insertText" in item or "textEdit" in item
892 | assert "kind" in item
893 | completion_item = {}
894 | if "detail" in item:
895 | completion_item["detail"] = item["detail"]
896 |
897 | if "label" in item:
898 | completion_item["completionText"] = item["label"]
899 | completion_item["kind"] = item["kind"] # type: ignore
900 | elif "insertText" in item: # type: ignore
901 | completion_item["completionText"] = item["insertText"]
902 | completion_item["kind"] = item["kind"]
903 | elif "textEdit" in item and "newText" in item["textEdit"]:
904 | completion_item["completionText"] = item["textEdit"]["newText"]
905 | completion_item["kind"] = item["kind"]
906 | elif "textEdit" in item and "range" in item["textEdit"]:
907 | new_dot_lineno, new_dot_colno = (
908 | completion_params["position"]["line"],
909 | completion_params["position"]["character"],
910 | )
911 | assert all(
912 | (
913 | item["textEdit"]["range"]["start"]["line"] == new_dot_lineno,
914 | item["textEdit"]["range"]["start"]["character"] == new_dot_colno,
915 | item["textEdit"]["range"]["start"]["line"] == item["textEdit"]["range"]["end"]["line"],
916 | item["textEdit"]["range"]["start"]["character"] == item["textEdit"]["range"]["end"]["character"],
917 | )
918 | )
919 |
920 | completion_item["completionText"] = item["textEdit"]["newText"]
921 | completion_item["kind"] = item["kind"]
922 | elif "textEdit" in item and "insert" in item["textEdit"]:
923 | assert False
924 | else:
925 | assert False
926 |
927 | completion_item = ls_types.CompletionItem(**completion_item) # type: ignore
928 | completions_list.append(completion_item)
929 |
930 | return [json.loads(json_repr) for json_repr in set(json.dumps(item, sort_keys=True) for item in completions_list)]
931 |
932 | def _request_document_symbols(
933 | self, relative_file_path: str, file_data: LSPFileBuffer | None
934 | ) -> list[SymbolInformation] | list[DocumentSymbol] | None:
935 | """
936 | Sends a [documentSymbol](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_documentSymbol)
937 | request to the language server to find symbols in the given file - or returns a cached result if available.
938 |
939 | :param relative_file_path: the relative path of the file that has the symbols.
940 | :param file_data: the file data buffer, if already opened. If None, the file will be opened in this method.
941 | :return: the list of root symbols in the file.
942 | """
943 |
944 | def get_cached_raw_document_symbols(cache_key: str, fd: LSPFileBuffer) -> list[SymbolInformation] | list[DocumentSymbol] | None:
945 | file_hash_and_result = self._raw_document_symbols_cache.get(cache_key)
946 | if file_hash_and_result is not None:
947 | file_hash, result = file_hash_and_result
948 | if file_hash == fd.content_hash:
949 | log.debug("Returning cached raw document symbols for %s", relative_file_path)
950 | return result
951 | else:
952 | log.debug("Document content for %s has changed (raw symbol cache is not up-to-date)", relative_file_path)
953 | else:
954 | log.debug("No cache hit for raw document symbols symbols in %s", relative_file_path)
955 | return None
956 |
957 | def get_raw_document_symbols(fd: LSPFileBuffer) -> list[SymbolInformation] | list[DocumentSymbol] | None:
958 | # check for cached result
959 | cache_key = relative_file_path
960 | response = get_cached_raw_document_symbols(cache_key, fd)
961 | if response is not None:
962 | return response
963 |
964 | # no cached result, query language server
965 | log.debug(f"Requesting document symbols for {relative_file_path} from the Language Server")
966 | response = self.server.send.document_symbol(
967 | {"textDocument": {"uri": pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)).as_uri()}}
968 | )
969 |
970 | # update cache
971 | self._raw_document_symbols_cache[cache_key] = (fd.content_hash, response)
972 | self._raw_document_symbols_cache_is_modified = True
973 |
974 | return response
975 |
976 | if file_data is not None:
977 | return get_raw_document_symbols(file_data)
978 | else:
979 | with self.open_file(relative_file_path) as opened_file_data:
980 | return get_raw_document_symbols(opened_file_data)
981 |
982 | def request_document_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None) -> DocumentSymbols:
983 | """
984 | Retrieves the collection of symbols in the given file
985 |
986 | :param relative_file_path: The relative path of the file that has the symbols
987 | :param file_buffer: an optional file buffer if the file is already opened.
988 | :return: the collection of symbols in the file.
989 | All contained symbols will have a location, children, and a parent attribute,
990 | where the parent attribute is None for root symbols.
991 | Note that this is slightly different from the call to request_full_symbol_tree,
992 | where the parent attribute will be the file symbol which in turn may have a package symbol as parent.
993 | If you need a symbol tree that contains file symbols as well, you should use `request_full_symbol_tree` instead.
994 | """
995 | with self._open_file_context(relative_file_path, file_buffer) as file_data:
996 | # check if the desired result is cached
997 | cache_key = relative_file_path
998 | file_hash_and_result = self._document_symbols_cache.get(cache_key)
999 | if file_hash_and_result is not None:
1000 | file_hash, document_symbols = file_hash_and_result
1001 | if file_hash == file_data.content_hash:
1002 | log.debug("Returning cached document symbols for %s", relative_file_path)
1003 | return document_symbols
1004 | else:
1005 | log.debug("Cached document symbol content for %s has changed", relative_file_path)
1006 | else:
1007 | log.debug("No cache hit for document symbols in %s", relative_file_path)
1008 |
1009 | # no cached result: request the root symbols from the language server
1010 | root_symbols = self._request_document_symbols(relative_file_path, file_data)
1011 |
1012 | if root_symbols is None:
1013 | log.warning(
1014 | f"Received None response from the Language Server for document symbols in {relative_file_path}. "
1015 | f"This means the language server can't understand this file (possibly due to syntax errors). It may also be due to a bug or misconfiguration of the LS. "
1016 | f"Returning empty list",
1017 | )
1018 | return DocumentSymbols([])
1019 |
1020 | assert isinstance(root_symbols, list), f"Unexpected response from Language Server: {root_symbols}"
1021 | log.debug("Received %d root symbols for %s from the language server", len(root_symbols), relative_file_path)
1022 |
1023 | file_lines = file_data.split_lines()
1024 |
1025 | def convert_to_unified_symbol(original_symbol_dict: GenericDocumentSymbol) -> ls_types.UnifiedSymbolInformation:
1026 | """
1027 | Converts the given symbol dictionary to the unified representation, ensuring
1028 | that all required fields are present (except 'children' which is handled separately).
1029 |
1030 | :param original_symbol_dict: the item to augment
1031 | :return: the augmented item (new object)
1032 | """
1033 | # noinspection PyInvalidCast
1034 | item = cast(ls_types.UnifiedSymbolInformation, dict(original_symbol_dict))
1035 | absolute_path = os.path.join(self.repository_root_path, relative_file_path)
1036 |
1037 | # handle missing location and path entries
1038 | if "location" not in item:
1039 | uri = pathlib.Path(absolute_path).as_uri()
1040 | assert "range" in item
1041 | tree_location = ls_types.Location(
1042 | uri=uri,
1043 | range=item["range"],
1044 | absolutePath=absolute_path,
1045 | relativePath=relative_file_path,
1046 | )
1047 | item["location"] = tree_location
1048 | location = item["location"]
1049 | if "absolutePath" not in location:
1050 | location["absolutePath"] = absolute_path # type: ignore
1051 | if "relativePath" not in location:
1052 | location["relativePath"] = relative_file_path # type: ignore
1053 |
1054 | if "body" not in item:
1055 | item["body"] = self.retrieve_symbol_body(item, file_lines=file_lines)
1056 |
1057 | # handle missing selectionRange
1058 | if "selectionRange" not in item:
1059 | if "range" in item:
1060 | item["selectionRange"] = item["range"]
1061 | else:
1062 | item["selectionRange"] = item["location"]["range"]
1063 |
1064 | return item
1065 |
1066 | def convert_symbols_with_common_parent(
1067 | symbols: list[DocumentSymbol] | list[SymbolInformation] | list[UnifiedSymbolInformation],
1068 | parent: ls_types.UnifiedSymbolInformation | None,
1069 | ) -> list[ls_types.UnifiedSymbolInformation]:
1070 | """
1071 | Converts the given symbols into UnifiedSymbolInformation with proper parent-child relationships,
1072 | adding overload indices for symbols with the same name under the same parent.
1073 | """
1074 | total_name_counts: dict[str, int] = defaultdict(lambda: 0)
1075 | for symbol in symbols:
1076 | total_name_counts[symbol["name"]] += 1
1077 | name_counts: dict[str, int] = defaultdict(lambda: 0)
1078 | unified_symbols = []
1079 | for symbol in symbols:
1080 | usymbol = convert_to_unified_symbol(symbol)
1081 | if total_name_counts[usymbol["name"]] > 1:
1082 | usymbol["overload_idx"] = name_counts[usymbol["name"]]
1083 | name_counts[usymbol["name"]] += 1
1084 | usymbol["parent"] = parent
1085 | if "children" in usymbol:
1086 | usymbol["children"] = convert_symbols_with_common_parent(usymbol["children"], usymbol) # type: ignore
1087 | else:
1088 | usymbol["children"] = [] # type: ignore
1089 | unified_symbols.append(usymbol)
1090 | return unified_symbols
1091 |
1092 | unified_root_symbols = convert_symbols_with_common_parent(root_symbols, None)
1093 | document_symbols = DocumentSymbols(unified_root_symbols)
1094 |
1095 | # update cache
1096 | log.debug("Updating cached document symbols for %s", relative_file_path)
1097 | self._document_symbols_cache[cache_key] = (file_data.content_hash, document_symbols)
1098 | self._document_symbols_cache_is_modified = True
1099 |
1100 | return document_symbols
1101 |
1102 | def request_full_symbol_tree(self, within_relative_path: str | None = None) -> list[ls_types.UnifiedSymbolInformation]:
1103 | """
1104 | Will go through all files in the project or within a relative path and build a tree of symbols.
1105 | Note: this may be slow the first time it is called, especially if `within_relative_path` is not used to restrict the search.
1106 |
1107 | For each file, a symbol of kind File (2) will be created. For directories, a symbol of kind Package (4) will be created.
1108 | All symbols will have a children attribute, thereby representing the tree structure of all symbols in the project
1109 | that are within the repository.
1110 | All symbols except the root packages will have a parent attribute.
1111 | Will ignore directories starting with '.', language-specific defaults
1112 | and user-configured directories (e.g. from .gitignore).
1113 |
1114 | :param within_relative_path: pass a relative path to only consider symbols within this path.
1115 | If a file is passed, only the symbols within this file will be considered.
1116 | If a directory is passed, all files within this directory will be considered.
1117 | :return: A list of root symbols representing the top-level packages/modules in the project.
1118 | """
1119 | if within_relative_path is not None:
1120 | within_abs_path = os.path.join(self.repository_root_path, within_relative_path)
1121 | if not os.path.exists(within_abs_path):
1122 | raise FileNotFoundError(f"File or directory not found: {within_abs_path}")
1123 | if os.path.isfile(within_abs_path):
1124 | if self.is_ignored_path(within_relative_path):
1125 | log.error("You passed a file explicitly, but it is ignored. This is probably an error. File: %s", within_relative_path)
1126 | return []
1127 | else:
1128 | root_nodes = self.request_document_symbols(within_relative_path).root_symbols
1129 | return root_nodes
1130 |
1131 | # Helper function to recursively process directories
1132 | def process_directory(rel_dir_path: str) -> list[ls_types.UnifiedSymbolInformation]:
1133 | abs_dir_path = self.repository_root_path if rel_dir_path == "." else os.path.join(self.repository_root_path, rel_dir_path)
1134 | abs_dir_path = os.path.realpath(abs_dir_path)
1135 |
1136 | if self.is_ignored_path(str(Path(abs_dir_path).relative_to(self.repository_root_path))):
1137 | log.debug("Skipping directory: %s (because it should be ignored)", rel_dir_path)
1138 | return []
1139 |
1140 | result = []
1141 | try:
1142 | contained_dir_or_file_names = os.listdir(abs_dir_path)
1143 | except OSError:
1144 | return []
1145 |
1146 | # Create package symbol for directory
1147 | package_symbol = ls_types.UnifiedSymbolInformation( # type: ignore
1148 | name=os.path.basename(abs_dir_path),
1149 | kind=ls_types.SymbolKind.Package,
1150 | location=ls_types.Location(
1151 | uri=str(pathlib.Path(abs_dir_path).as_uri()),
1152 | range={"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}},
1153 | absolutePath=str(abs_dir_path),
1154 | relativePath=str(Path(abs_dir_path).resolve().relative_to(self.repository_root_path)),
1155 | ),
1156 | children=[],
1157 | )
1158 | result.append(package_symbol)
1159 |
1160 | for contained_dir_or_file_name in contained_dir_or_file_names:
1161 | contained_dir_or_file_abs_path = os.path.join(abs_dir_path, contained_dir_or_file_name)
1162 |
1163 | # obtain relative path
1164 | try:
1165 | contained_dir_or_file_rel_path = str(
1166 | Path(contained_dir_or_file_abs_path).resolve().relative_to(self.repository_root_path)
1167 | )
1168 | except ValueError as e:
1169 | # Typically happens when the path is not under the repository root (e.g., symlink pointing outside)
1170 | log.warning(
1171 | "Skipping path %s; likely outside of the repository root %s [cause: %s]",
1172 | contained_dir_or_file_abs_path,
1173 | self.repository_root_path,
1174 | e,
1175 | )
1176 | continue
1177 |
1178 | if self.is_ignored_path(contained_dir_or_file_rel_path):
1179 | log.debug("Skipping item: %s (because it should be ignored)", contained_dir_or_file_rel_path)
1180 | continue
1181 |
1182 | if os.path.isdir(contained_dir_or_file_abs_path):
1183 | child_symbols = process_directory(contained_dir_or_file_rel_path)
1184 | package_symbol["children"].extend(child_symbols)
1185 | for child in child_symbols:
1186 | child["parent"] = package_symbol
1187 |
1188 | elif os.path.isfile(contained_dir_or_file_abs_path):
1189 | with self._open_file_context(contained_dir_or_file_rel_path) as file_data:
1190 | document_symbols = self.request_document_symbols(contained_dir_or_file_rel_path, file_data)
1191 | file_root_nodes = document_symbols.root_symbols
1192 |
1193 | # Create file symbol, link with children
1194 | file_range = self._get_range_from_file_content(file_data.contents)
1195 | file_symbol = ls_types.UnifiedSymbolInformation( # type: ignore
1196 | name=os.path.splitext(contained_dir_or_file_name)[0],
1197 | kind=ls_types.SymbolKind.File,
1198 | range=file_range,
1199 | selectionRange=file_range,
1200 | location=ls_types.Location(
1201 | uri=str(pathlib.Path(contained_dir_or_file_abs_path).as_uri()),
1202 | range=file_range,
1203 | absolutePath=str(contained_dir_or_file_abs_path),
1204 | relativePath=str(Path(contained_dir_or_file_abs_path).resolve().relative_to(self.repository_root_path)),
1205 | ),
1206 | children=file_root_nodes,
1207 | parent=package_symbol,
1208 | )
1209 | for child in file_root_nodes:
1210 | child["parent"] = file_symbol
1211 |
1212 | # Link file symbol with package
1213 | package_symbol["children"].append(file_symbol)
1214 |
1215 | # TODO: Not sure if this is actually still needed given recent changes to relative path handling
1216 | def fix_relative_path(nodes: list[ls_types.UnifiedSymbolInformation]) -> None:
1217 | for node in nodes:
1218 | if "location" in node and "relativePath" in node["location"]:
1219 | path = Path(node["location"]["relativePath"]) # type: ignore
1220 | if path.is_absolute():
1221 | try:
1222 | path = path.relative_to(self.repository_root_path)
1223 | node["location"]["relativePath"] = str(path)
1224 | except Exception:
1225 | pass
1226 | if "children" in node:
1227 | fix_relative_path(node["children"])
1228 |
1229 | fix_relative_path(file_root_nodes)
1230 |
1231 | return result
1232 |
1233 | # Start from the root or the specified directory
1234 | start_rel_path = within_relative_path or "."
1235 | return process_directory(start_rel_path)
1236 |
1237 | @staticmethod
1238 | def _get_range_from_file_content(file_content: str) -> ls_types.Range:
1239 | """
1240 | Get the range for the given file.
1241 | """
1242 | lines = file_content.split("\n")
1243 | end_line = len(lines)
1244 | end_column = len(lines[-1])
1245 | return ls_types.Range(start=ls_types.Position(line=0, character=0), end=ls_types.Position(line=end_line, character=end_column))
1246 |
1247 | def request_dir_overview(self, relative_dir_path: str) -> dict[str, list[UnifiedSymbolInformation]]:
1248 | """
1249 | :return: A mapping of all relative paths analyzed to lists of top-level symbols in the corresponding file.
1250 | """
1251 | symbol_tree = self.request_full_symbol_tree(relative_dir_path)
1252 | # Initialize result dictionary
1253 | result: dict[str, list[UnifiedSymbolInformation]] = defaultdict(list)
1254 |
1255 | # Helper function to process a symbol and its children
1256 | def process_symbol(symbol: ls_types.UnifiedSymbolInformation) -> None:
1257 | if symbol["kind"] == ls_types.SymbolKind.File:
1258 | # For file symbols, process their children (top-level symbols)
1259 | for child in symbol["children"]:
1260 | # Handle cross-platform path resolution (fixes Docker/macOS path issues)
1261 | absolute_path = Path(child["location"]["absolutePath"]).resolve()
1262 | repository_root = Path(self.repository_root_path).resolve()
1263 |
1264 | # Try pathlib first, fallback to alternative approach if paths are incompatible
1265 | try:
1266 | path = absolute_path.relative_to(repository_root)
1267 | except ValueError:
1268 | # If paths are from different roots (e.g., /workspaces vs /Users),
1269 | # use the relativePath from location if available, or extract from absolutePath
1270 | if "relativePath" in child["location"] and child["location"]["relativePath"]:
1271 | path = Path(child["location"]["relativePath"])
1272 | else:
1273 | # Extract relative path by finding common structure
1274 | # Example: /workspaces/.../test_repo/file.py -> test_repo/file.py
1275 | path_parts = absolute_path.parts
1276 |
1277 | # Find the last common part or use a fallback
1278 | if "test_repo" in path_parts:
1279 | test_repo_idx = path_parts.index("test_repo")
1280 | path = Path(*path_parts[test_repo_idx:])
1281 | else:
1282 | # Last resort: use filename only
1283 | path = Path(absolute_path.name)
1284 | result[str(path)].append(child)
1285 | # For package/directory symbols, process their children
1286 | for child in symbol["children"]:
1287 | process_symbol(child)
1288 |
1289 | # Process each root symbol
1290 | for root in symbol_tree:
1291 | process_symbol(root)
1292 | return result
1293 |
1294 | def request_document_overview(self, relative_file_path: str) -> list[UnifiedSymbolInformation]:
1295 | """
1296 | :return: the top-level symbols in the given file.
1297 | """
1298 | return self.request_document_symbols(relative_file_path).root_symbols
1299 |
1300 | def request_overview(self, within_relative_path: str) -> dict[str, list[UnifiedSymbolInformation]]:
1301 | """
1302 | An overview of all symbols in the given file or directory.
1303 |
1304 | :param within_relative_path: the relative path to the file or directory to get the overview of.
1305 | :return: A mapping of all relative paths analyzed to lists of top-level symbols in the corresponding file.
1306 | """
1307 | abs_path = (Path(self.repository_root_path) / within_relative_path).resolve()
1308 | if not abs_path.exists():
1309 | raise FileNotFoundError(f"File or directory not found: {abs_path}")
1310 |
1311 | if abs_path.is_file():
1312 | symbols_overview = self.request_document_overview(within_relative_path)
1313 | return {within_relative_path: symbols_overview}
1314 | else:
1315 | return self.request_dir_overview(within_relative_path)
1316 |
1317 | def request_hover(self, relative_file_path: str, line: int, column: int) -> ls_types.Hover | None:
1318 | """
1319 | Raise a [textDocument/hover](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_hover) request to the Language Server
1320 | to find the hover information at the given line and column in the given file. Wait for the response and return the result.
1321 |
1322 | :param relative_file_path: The relative path of the file that has the hover information
1323 | :param line: The line number of the symbol
1324 | :param column: The column number of the symbol
1325 |
1326 | :return None
1327 | """
1328 | with self.open_file(relative_file_path):
1329 | response = self.server.send.hover(
1330 | {
1331 | "textDocument": {"uri": pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)).as_uri()},
1332 | "position": {
1333 | "line": line,
1334 | "character": column,
1335 | },
1336 | }
1337 | )
1338 |
1339 | if response is None:
1340 | return None
1341 |
1342 | assert isinstance(response, dict)
1343 |
1344 | return ls_types.Hover(**response) # type: ignore
1345 |
1346 | def retrieve_symbol_body(
1347 | self,
1348 | symbol: ls_types.UnifiedSymbolInformation | LSPTypes.SymbolInformation,
1349 | file_lines: list[str] | None = None,
1350 | file_buffer: LSPFileBuffer | None = None,
1351 | ) -> str:
1352 | """
1353 | Load the body of the given symbol. If the body is already contained in the symbol, just return it.
1354 | """
1355 | existing_body = symbol.get("body", None)
1356 | if existing_body:
1357 | return str(existing_body)
1358 |
1359 | assert "location" in symbol
1360 | symbol_start_line = symbol["location"]["range"]["start"]["line"]
1361 | symbol_end_line = symbol["location"]["range"]["end"]["line"]
1362 | assert "relativePath" in symbol["location"]
1363 | if file_lines is None:
1364 | with self._open_file_context(symbol["location"]["relativePath"], file_buffer) as f: # type: ignore
1365 | file_lines = f.split_lines()
1366 | symbol_body = "\n".join(file_lines[symbol_start_line : symbol_end_line + 1])
1367 |
1368 | # remove leading indentation
1369 | symbol_start_column = symbol["location"]["range"]["start"]["character"] # type: ignore
1370 | symbol_body = symbol_body[symbol_start_column:]
1371 | return symbol_body
1372 |
1373 | def request_referencing_symbols(
1374 | self,
1375 | relative_file_path: str,
1376 | line: int,
1377 | column: int,
1378 | include_imports: bool = True,
1379 | include_self: bool = False,
1380 | include_body: bool = False,
1381 | include_file_symbols: bool = False,
1382 | ) -> list[ReferenceInSymbol]:
1383 | """
1384 | Finds all symbols that reference the symbol at the given location.
1385 | This is similar to request_references but filters to only include symbols
1386 | (functions, methods, classes, etc.) that reference the target symbol.
1387 |
1388 | :param relative_file_path: The relative path to the file.
1389 | :param line: The 0-indexed line number.
1390 | :param column: The 0-indexed column number.
1391 | :param include_imports: whether to also include imports as references.
1392 | Unfortunately, the LSP does not have an import type, so the references corresponding to imports
1393 | will not be easily distinguishable from definitions.
1394 | :param include_self: whether to include the references that is the "input symbol" itself.
1395 | Only has an effect if the relative_file_path, line and column point to a symbol, for example a definition.
1396 | :param include_body: whether to include the body of the symbols in the result.
1397 | :param include_file_symbols: whether to include references that are file symbols. This
1398 | is often a fallback mechanism for when the reference cannot be resolved to a symbol.
1399 | :return: List of objects containing the symbol and the location of the reference.
1400 | """
1401 | if not self.server_started:
1402 | log.error("request_referencing_symbols called before Language Server started")
1403 | raise SolidLSPException("Language Server not started")
1404 |
1405 | # First, get all references to the symbol
1406 | references = self.request_references(relative_file_path, line, column)
1407 | if not references:
1408 | return []
1409 |
1410 | # For each reference, find the containing symbol
1411 | result = []
1412 | incoming_symbol = None
1413 | for ref in references:
1414 | ref_path = ref["relativePath"]
1415 | assert ref_path is not None
1416 | ref_line = ref["range"]["start"]["line"]
1417 | ref_col = ref["range"]["start"]["character"]
1418 |
1419 | with self.open_file(ref_path) as file_data:
1420 | # Get the containing symbol for this reference
1421 | containing_symbol = self.request_containing_symbol(ref_path, ref_line, ref_col, include_body=include_body)
1422 | if containing_symbol is None:
1423 | # TODO: HORRIBLE HACK! I don't know how to do it better for now...
1424 | # THIS IS BOUND TO BREAK IN MANY CASES! IT IS ALSO SPECIFIC TO PYTHON!
1425 | # Background:
1426 | # When a variable is used to change something, like
1427 | #
1428 | # instance = MyClass()
1429 | # instance.status = "new status"
1430 | #
1431 | # we can't find the containing symbol for the reference to `status`
1432 | # since there is no container on the line of the reference
1433 | # The hack is to try to find a variable symbol in the containing module
1434 | # by using the text of the reference to find the variable name (In a very heuristic way)
1435 | # and then look for a symbol with that name and kind Variable
1436 | ref_text = file_data.contents.split("\n")[ref_line]
1437 | if "." in ref_text:
1438 | containing_symbol_name = ref_text.split(".")[0]
1439 | document_symbols = self.request_document_symbols(ref_path)
1440 | for symbol in document_symbols.iter_symbols():
1441 | if symbol["name"] == containing_symbol_name and symbol["kind"] == ls_types.SymbolKind.Variable:
1442 | containing_symbol = copy(symbol)
1443 | containing_symbol["location"] = ref
1444 | containing_symbol["range"] = ref["range"]
1445 | break
1446 |
1447 | # We failed retrieving the symbol, falling back to creating a file symbol
1448 | if containing_symbol is None and include_file_symbols:
1449 | log.warning(f"Could not find containing symbol for {ref_path}:{ref_line}:{ref_col}. Returning file symbol instead")
1450 | fileRange = self._get_range_from_file_content(file_data.contents)
1451 | location = ls_types.Location(
1452 | uri=str(pathlib.Path(os.path.join(self.repository_root_path, ref_path)).as_uri()),
1453 | range=fileRange,
1454 | absolutePath=str(os.path.join(self.repository_root_path, ref_path)),
1455 | relativePath=ref_path,
1456 | )
1457 | name = os.path.splitext(os.path.basename(ref_path))[0]
1458 |
1459 | if include_body:
1460 | body = self.retrieve_full_file_content(ref_path)
1461 | else:
1462 | body = ""
1463 |
1464 | containing_symbol = ls_types.UnifiedSymbolInformation(
1465 | kind=ls_types.SymbolKind.File,
1466 | range=fileRange,
1467 | selectionRange=fileRange,
1468 | location=location,
1469 | name=name,
1470 | children=[],
1471 | body=body,
1472 | )
1473 | if containing_symbol is None or (not include_file_symbols and containing_symbol["kind"] == ls_types.SymbolKind.File):
1474 | continue
1475 |
1476 | assert "location" in containing_symbol
1477 | assert "selectionRange" in containing_symbol
1478 |
1479 | # Checking for self-reference
1480 | if (
1481 | containing_symbol["location"]["relativePath"] == relative_file_path
1482 | and containing_symbol["selectionRange"]["start"]["line"] == ref_line
1483 | and containing_symbol["selectionRange"]["start"]["character"] == ref_col
1484 | ):
1485 | incoming_symbol = containing_symbol
1486 | if include_self:
1487 | result.append(ReferenceInSymbol(symbol=containing_symbol, line=ref_line, character=ref_col))
1488 | continue
1489 | log.debug(f"Found self-reference for {incoming_symbol['name']}, skipping it since {include_self=}")
1490 | continue
1491 |
1492 | # checking whether reference is an import
1493 | # This is neither really safe nor elegant, but if we don't do it,
1494 | # there is no way to distinguish between definitions and imports as import is not a symbol-type
1495 | # and we get the type referenced symbol resulting from imports...
1496 | if (
1497 | not include_imports
1498 | and incoming_symbol is not None
1499 | and containing_symbol["name"] == incoming_symbol["name"]
1500 | and containing_symbol["kind"] == incoming_symbol["kind"]
1501 | ):
1502 | log.debug(
1503 | f"Found import of referenced symbol {incoming_symbol['name']}"
1504 | f"in {containing_symbol['location']['relativePath']}, skipping"
1505 | )
1506 | continue
1507 |
1508 | result.append(ReferenceInSymbol(symbol=containing_symbol, line=ref_line, character=ref_col))
1509 |
1510 | return result
1511 |
1512 | def request_containing_symbol(
1513 | self,
1514 | relative_file_path: str,
1515 | line: int,
1516 | column: int | None = None,
1517 | strict: bool = False,
1518 | include_body: bool = False,
1519 | ) -> ls_types.UnifiedSymbolInformation | None:
1520 | """
1521 | Finds the first symbol containing the position for the given file.
1522 | For Python, container symbols are considered to be those with kinds corresponding to
1523 | functions, methods, or classes (typically: Function (12), Method (6), Class (5)).
1524 |
1525 | The method operates as follows:
1526 | - Request the document symbols for the file.
1527 | - Filter symbols to those that start at or before the given line.
1528 | - From these, first look for symbols whose range contains the (line, column).
1529 | - If one or more symbols contain the position, return the one with the greatest starting position
1530 | (i.e. the innermost container).
1531 | - If none (strictly) contain the position, return the symbol with the greatest starting position
1532 | among those above the given line.
1533 | - If no container candidates are found, return None.
1534 |
1535 | :param relative_file_path: The relative path to the Python file.
1536 | :param line: The 0-indexed line number.
1537 | :param column: The 0-indexed column (also called character). If not passed, the lookup will be based
1538 | only on the line.
1539 | :param strict: If True, the position must be strictly within the range of the symbol.
1540 | Setting to True is useful for example for finding the parent of a symbol, as with strict=False,
1541 | and the line pointing to a symbol itself, the containing symbol will be the symbol itself
1542 | (and not the parent).
1543 | :param include_body: Whether to include the body of the symbol in the result.
1544 | :return: The container symbol (if found) or None.
1545 | """
1546 | # checking if the line is empty, unfortunately ugly and duplicating code, but I don't want to refactor
1547 | with self.open_file(relative_file_path):
1548 | absolute_file_path = str(PurePath(self.repository_root_path, relative_file_path))
1549 | content = FileUtils.read_file(absolute_file_path, self._encoding)
1550 | if content.split("\n")[line].strip() == "":
1551 | log.error(f"Passing empty lines to request_container_symbol is currently not supported, {relative_file_path=}, {line=}")
1552 | return None
1553 |
1554 | document_symbols = self.request_document_symbols(relative_file_path)
1555 |
1556 | # make jedi and pyright api compatible
1557 | # the former has no location, the later has no range
1558 | # we will just always add location of the desired format to all symbols
1559 | for symbol in document_symbols.iter_symbols():
1560 | if "location" not in symbol:
1561 | range = symbol["range"]
1562 | location = ls_types.Location(
1563 | uri=f"file:/{absolute_file_path}",
1564 | range=range,
1565 | absolutePath=absolute_file_path,
1566 | relativePath=relative_file_path,
1567 | )
1568 | symbol["location"] = location
1569 | else:
1570 | location = symbol["location"]
1571 | assert "range" in location
1572 | location["absolutePath"] = absolute_file_path
1573 | location["relativePath"] = relative_file_path
1574 | location["uri"] = Path(absolute_file_path).as_uri()
1575 |
1576 | # Allowed container kinds, currently only for Python
1577 | container_symbol_kinds = {ls_types.SymbolKind.Method, ls_types.SymbolKind.Function, ls_types.SymbolKind.Class}
1578 |
1579 | def is_position_in_range(line: int, range_d: ls_types.Range) -> bool:
1580 | start = range_d["start"]
1581 | end = range_d["end"]
1582 |
1583 | column_condition = True
1584 | if strict:
1585 | line_condition = end["line"] >= line > start["line"]
1586 | if column is not None and line == start["line"]:
1587 | column_condition = column > start["character"]
1588 | else:
1589 | line_condition = end["line"] >= line >= start["line"]
1590 | if column is not None and line == start["line"]:
1591 | column_condition = column >= start["character"]
1592 | return line_condition and column_condition
1593 |
1594 | # Only consider containers that are not one-liners (otherwise we may get imports)
1595 | candidate_containers = [
1596 | s
1597 | for s in document_symbols.iter_symbols()
1598 | if s["kind"] in container_symbol_kinds and s["location"]["range"]["start"]["line"] != s["location"]["range"]["end"]["line"]
1599 | ]
1600 | var_containers = [s for s in document_symbols.iter_symbols() if s["kind"] == ls_types.SymbolKind.Variable]
1601 | candidate_containers.extend(var_containers)
1602 |
1603 | if not candidate_containers:
1604 | return None
1605 |
1606 | # From the candidates, find those whose range contains the given position.
1607 | containing_symbols = []
1608 | for symbol in candidate_containers:
1609 | s_range = symbol["location"]["range"]
1610 | if not is_position_in_range(line, s_range):
1611 | continue
1612 | containing_symbols.append(symbol)
1613 |
1614 | if containing_symbols:
1615 | # Return the one with the greatest starting position (i.e. the innermost container).
1616 | containing_symbol = max(containing_symbols, key=lambda s: s["location"]["range"]["start"]["line"])
1617 | if include_body:
1618 | containing_symbol["body"] = self.retrieve_symbol_body(containing_symbol)
1619 | return containing_symbol
1620 | else:
1621 | return None
1622 |
1623 | def request_container_of_symbol(
1624 | self, symbol: ls_types.UnifiedSymbolInformation, include_body: bool = False
1625 | ) -> ls_types.UnifiedSymbolInformation | None:
1626 | """
1627 | Finds the container of the given symbol if there is one. If the parent attribute is present, the parent is returned
1628 | without further searching.
1629 |
1630 | :param symbol: The symbol to find the container of.
1631 | :param include_body: whether to include the body of the symbol in the result.
1632 | :return: The container of the given symbol or None if no container is found.
1633 | """
1634 | if "parent" in symbol:
1635 | return symbol["parent"]
1636 | assert "location" in symbol, f"Symbol {symbol} has no location and no parent attribute"
1637 | return self.request_containing_symbol(
1638 | symbol["location"]["relativePath"], # type: ignore
1639 | symbol["location"]["range"]["start"]["line"],
1640 | symbol["location"]["range"]["start"]["character"],
1641 | strict=True,
1642 | include_body=include_body,
1643 | )
1644 |
1645 | def _get_preferred_definition(self, definitions: list[ls_types.Location]) -> ls_types.Location:
1646 | """
1647 | Select the preferred definition from a list of definitions.
1648 |
1649 | When multiple definitions are returned (e.g., both source and type definitions),
1650 | this method determines which one to use. The base implementation simply returns
1651 | the first definition.
1652 |
1653 | Subclasses can override this method to implement language-specific preferences.
1654 | For example, TypeScript/Vue servers may prefer source files over .d.ts type
1655 | definition files.
1656 |
1657 | :param definitions: A non-empty list of definition locations.
1658 | :return: The preferred definition location.
1659 | """
1660 | return definitions[0]
1661 |
1662 | def request_defining_symbol(
1663 | self,
1664 | relative_file_path: str,
1665 | line: int,
1666 | column: int,
1667 | include_body: bool = False,
1668 | ) -> ls_types.UnifiedSymbolInformation | None:
1669 | """
1670 | Finds the symbol that defines the symbol at the given location.
1671 |
1672 | This method first finds the definition of the symbol at the given position,
1673 | then retrieves the full symbol information for that definition.
1674 |
1675 | :param relative_file_path: The relative path to the file.
1676 | :param line: The 0-indexed line number.
1677 | :param column: The 0-indexed column number.
1678 | :param include_body: whether to include the body of the symbol in the result.
1679 | :return: The symbol information for the definition, or None if not found.
1680 | """
1681 | if not self.server_started:
1682 | log.error("request_defining_symbol called before language server started")
1683 | raise SolidLSPException("Language Server not started")
1684 |
1685 | # Get the definition location(s)
1686 | definitions = self.request_definition(relative_file_path, line, column)
1687 | if not definitions:
1688 | return None
1689 |
1690 | # Select the preferred definition (subclasses can override _get_preferred_definition)
1691 | definition = self._get_preferred_definition(definitions)
1692 | def_path = definition["relativePath"]
1693 | assert def_path is not None
1694 | def_line = definition["range"]["start"]["line"]
1695 | def_col = definition["range"]["start"]["character"]
1696 |
1697 | # Find the symbol at or containing this location
1698 | defining_symbol = self.request_containing_symbol(def_path, def_line, def_col, strict=False, include_body=include_body)
1699 |
1700 | return defining_symbol
1701 |
1702 | def _save_raw_document_symbols_cache(self) -> None:
1703 | cache_file = self.cache_dir / self.RAW_DOCUMENT_SYMBOL_CACHE_FILENAME
1704 |
1705 | if not self._raw_document_symbols_cache_is_modified:
1706 | log.debug("No changes to raw document symbols cache, skipping save")
1707 | return
1708 |
1709 | log.info("Saving updated raw document symbols cache to %s", cache_file)
1710 | try:
1711 | save_cache(str(cache_file), self._raw_document_symbols_cache_version(), self._raw_document_symbols_cache)
1712 | self._raw_document_symbols_cache_is_modified = False
1713 | except Exception as e:
1714 | log.error(
1715 | "Failed to save raw document symbols cache to %s: %s. Note: this may have resulted in a corrupted cache file.",
1716 | cache_file,
1717 | e,
1718 | )
1719 |
1720 | def _raw_document_symbols_cache_version(self) -> tuple[int, Hashable]:
1721 | return (self.RAW_DOCUMENT_SYMBOLS_CACHE_VERSION, self._ls_specific_raw_document_symbols_cache_version)
1722 |
1723 | def _load_raw_document_symbols_cache(self) -> None:
1724 | cache_file = self.cache_dir / self.RAW_DOCUMENT_SYMBOL_CACHE_FILENAME
1725 |
1726 | if not cache_file.exists():
1727 | # check for legacy cache to load to migrate
1728 | legacy_cache_file = self.cache_dir / self.RAW_DOCUMENT_SYMBOL_CACHE_FILENAME_LEGACY_FALLBACK
1729 | if legacy_cache_file.exists():
1730 | try:
1731 | legacy_cache: dict[
1732 | str, tuple[str, tuple[list[ls_types.UnifiedSymbolInformation], list[ls_types.UnifiedSymbolInformation]]]
1733 | ] = load_pickle(legacy_cache_file)
1734 | log.info("Migrating legacy document symbols cache with %d entries", len(legacy_cache))
1735 | num_symbols_migrated = 0
1736 | migrated_cache = {}
1737 | for cache_key, (file_hash, (all_symbols, root_symbols)) in legacy_cache.items():
1738 | if cache_key.endswith("-True"): # include_body=True
1739 | new_cache_key = cache_key[:-5]
1740 | migrated_cache[new_cache_key] = (file_hash, root_symbols)
1741 | num_symbols_migrated += len(all_symbols)
1742 | log.info("Migrated %d document symbols from legacy cache", num_symbols_migrated)
1743 | self._raw_document_symbols_cache = migrated_cache # type: ignore
1744 | self._raw_document_symbols_cache_is_modified = True
1745 | self._save_raw_document_symbols_cache()
1746 | legacy_cache_file.unlink()
1747 | return
1748 | except Exception as e:
1749 | log.error("Error during cache migration: %s", e)
1750 | return
1751 |
1752 | # load existing cache (if any)
1753 | if cache_file.exists():
1754 | log.info("Loading document symbols cache from %s", cache_file)
1755 | try:
1756 | saved_cache = load_cache(str(cache_file), self._raw_document_symbols_cache_version())
1757 | if saved_cache is not None:
1758 | self._raw_document_symbols_cache = saved_cache
1759 | log.info(f"Loaded {len(self._raw_document_symbols_cache)} entries from raw document symbols cache.")
1760 | except Exception as e:
1761 | # cache can become corrupt, so just skip loading it
1762 | log.warning(
1763 | "Failed to load raw document symbols cache from %s (%s); Ignoring cache.",
1764 | cache_file,
1765 | e,
1766 | )
1767 |
1768 | def _save_document_symbols_cache(self) -> None:
1769 | cache_file = self.cache_dir / self.DOCUMENT_SYMBOL_CACHE_FILENAME
1770 |
1771 | if not self._document_symbols_cache_is_modified:
1772 | log.debug("No changes to document symbols cache, skipping save")
1773 | return
1774 |
1775 | log.info("Saving updated document symbols cache to %s", cache_file)
1776 | try:
1777 | save_cache(str(cache_file), self.DOCUMENT_SYMBOL_CACHE_VERSION, self._document_symbols_cache)
1778 | self._document_symbols_cache_is_modified = False
1779 | except Exception as e:
1780 | log.error(
1781 | "Failed to save document symbols cache to %s: %s. Note: this may have resulted in a corrupted cache file.",
1782 | cache_file,
1783 | e,
1784 | )
1785 |
1786 | def _load_document_symbols_cache(self) -> None:
1787 | cache_file = self.cache_dir / self.DOCUMENT_SYMBOL_CACHE_FILENAME
1788 | if cache_file.exists():
1789 | log.info("Loading document symbols cache from %s", cache_file)
1790 | try:
1791 | saved_cache = load_cache(str(cache_file), self.DOCUMENT_SYMBOL_CACHE_VERSION)
1792 | if saved_cache is not None:
1793 | self._document_symbols_cache = saved_cache
1794 | log.info(f"Loaded {len(self._document_symbols_cache)} entries from document symbols cache.")
1795 | except Exception as e:
1796 | # cache can become corrupt, so just skip loading it
1797 | log.warning(
1798 | "Failed to load document symbols cache from %s (%s); Ignoring cache.",
1799 | cache_file,
1800 | e,
1801 | )
1802 |
1803 | def save_cache(self) -> None:
1804 | self._save_raw_document_symbols_cache()
1805 | self._save_document_symbols_cache()
1806 |
1807 | def request_workspace_symbol(self, query: str) -> list[ls_types.UnifiedSymbolInformation] | None:
1808 | """
1809 | Raise a [workspace/symbol](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_symbol) request to the Language Server
1810 | to find symbols across the whole workspace. Wait for the response and return the result.
1811 |
1812 | :param query: The query string to filter symbols by
1813 |
1814 | :return: A list of matching symbols
1815 | """
1816 | response = self.server.send.workspace_symbol({"query": query})
1817 | if response is None:
1818 | return None
1819 |
1820 | assert isinstance(response, list)
1821 |
1822 | ret: list[ls_types.UnifiedSymbolInformation] = []
1823 | for item in response:
1824 | assert isinstance(item, dict)
1825 |
1826 | assert LSPConstants.NAME in item
1827 | assert LSPConstants.KIND in item
1828 | assert LSPConstants.LOCATION in item
1829 |
1830 | ret.append(ls_types.UnifiedSymbolInformation(**item)) # type: ignore
1831 |
1832 | return ret
1833 |
1834 | def request_rename_symbol_edit(
1835 | self,
1836 | relative_file_path: str,
1837 | line: int,
1838 | column: int,
1839 | new_name: str,
1840 | ) -> ls_types.WorkspaceEdit | None:
1841 | """
1842 | Retrieve a WorkspaceEdit for renaming the symbol at the given location to the new name.
1843 | Does not apply the edit, just retrieves it. In order to actually rename the symbol, call apply_workspace_edit.
1844 |
1845 | :param relative_file_path: The relative path to the file containing the symbol
1846 | :param line: The 0-indexed line number of the symbol
1847 | :param column: The 0-indexed column number of the symbol
1848 | :param new_name: The new name for the symbol
1849 | :return: A WorkspaceEdit containing the changes needed to rename the symbol, or None if rename is not supported
1850 | """
1851 | params = RenameParams(
1852 | textDocument=ls_types.TextDocumentIdentifier(
1853 | uri=pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)).as_uri()
1854 | ),
1855 | position=ls_types.Position(line=line, character=column),
1856 | newName=new_name,
1857 | )
1858 |
1859 | return self.server.send.rename(params)
1860 |
1861 | def apply_text_edits_to_file(self, relative_path: str, edits: list[ls_types.TextEdit]) -> None:
1862 | """
1863 | Apply a list of text edits to a file.
1864 |
1865 | :param relative_path: The relative path of the file to edit
1866 | :param edits: List of TextEdit dictionaries to apply
1867 | """
1868 | with self.open_file(relative_path):
1869 | # Sort edits by position (latest first) to avoid position shifts
1870 | sorted_edits = sorted(edits, key=lambda e: (e["range"]["start"]["line"], e["range"]["start"]["character"]), reverse=True)
1871 |
1872 | for edit in sorted_edits:
1873 | start_pos = ls_types.Position(line=edit["range"]["start"]["line"], character=edit["range"]["start"]["character"])
1874 | end_pos = ls_types.Position(line=edit["range"]["end"]["line"], character=edit["range"]["end"]["character"])
1875 |
1876 | # Delete the old text and insert the new text
1877 | self.delete_text_between_positions(relative_path, start_pos, end_pos)
1878 | self.insert_text_at_position(relative_path, start_pos["line"], start_pos["character"], edit["newText"])
1879 |
1880 | def start(self) -> "SolidLanguageServer":
1881 | """
1882 | Starts the language server process and connects to it. Call shutdown when ready.
1883 |
1884 | :return: self for method chaining
1885 | """
1886 | log.info(f"Starting language server with language {self.language_server.language} for {self.language_server.repository_root_path}")
1887 | self._start_server_process()
1888 | return self
1889 |
1890 | def stop(self, shutdown_timeout: float = 2.0) -> None:
1891 | """
1892 | Stops the language server process.
1893 | This function never raises an exception (any exceptions during shutdown are logged).
1894 |
1895 | :param shutdown_timeout: time, in seconds, to wait for the server to shutdown gracefully before killing it
1896 | """
1897 | try:
1898 | self._shutdown(timeout=shutdown_timeout)
1899 | except Exception as e:
1900 | log.warning(f"Exception while shutting down language server: {e}")
1901 |
1902 | @property
1903 | def language_server(self) -> Self:
1904 | return self
1905 |
1906 | @property
1907 | def handler(self) -> SolidLanguageServerHandler:
1908 | """Access the underlying language server handler.
1909 |
1910 | Useful for advanced operations like sending custom commands
1911 | or registering notification handlers.
1912 | """
1913 | return self.server
1914 |
1915 | def is_running(self) -> bool:
1916 | return self.server.is_running()
1917 |
```