This is page 13 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/language_servers/ruby_lsp.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Ruby LSP Language Server implementation using Shopify's ruby-lsp.
3 | Provides modern Ruby language server capabilities with improved performance.
4 | """
5 |
6 | import json
7 | import logging
8 | import os
9 | import pathlib
10 | import shutil
11 | import subprocess
12 | import threading
13 |
14 | from overrides import override
15 |
16 | from solidlsp.ls import SolidLanguageServer
17 | from solidlsp.ls_config import LanguageServerConfig
18 | from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams, InitializeResult
19 | from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
20 | from solidlsp.settings import SolidLSPSettings
21 |
22 | log = logging.getLogger(__name__)
23 |
24 |
25 | class RubyLsp(SolidLanguageServer):
26 | """
27 | Provides Ruby specific instantiation of the LanguageServer class using ruby-lsp.
28 | Contains various configurations and settings specific to Ruby with modern LSP features.
29 | """
30 |
31 | def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
32 | """
33 | Creates a RubyLsp instance. This class is not meant to be instantiated directly.
34 | Use LanguageServer.create() instead.
35 | """
36 | ruby_lsp_executable = self._setup_runtime_dependencies(config, repository_root_path)
37 | super().__init__(
38 | config, repository_root_path, ProcessLaunchInfo(cmd=ruby_lsp_executable, cwd=repository_root_path), "ruby", solidlsp_settings
39 | )
40 | self.analysis_complete = threading.Event()
41 | self.service_ready_event = threading.Event()
42 |
43 | # Set timeout for ruby-lsp requests - ruby-lsp is fast
44 | self.set_request_timeout(30.0) # 30 seconds for initialization and requests
45 |
46 | @override
47 | def is_ignored_dirname(self, dirname: str) -> bool:
48 | """Override to ignore Ruby-specific directories that cause performance issues."""
49 | ruby_ignored_dirs = [
50 | "vendor", # Ruby vendor directory
51 | ".bundle", # Bundler cache
52 | "tmp", # Temporary files
53 | "log", # Log files
54 | "coverage", # Test coverage reports
55 | ".yardoc", # YARD documentation cache
56 | "doc", # Generated documentation
57 | "node_modules", # Node modules (for Rails with JS)
58 | "storage", # Active Storage files (Rails)
59 | "public/packs", # Webpacker output
60 | "public/webpack", # Webpack output
61 | "public/assets", # Rails compiled assets
62 | ]
63 | return super().is_ignored_dirname(dirname) or dirname in ruby_ignored_dirs
64 |
65 | @override
66 | def _get_wait_time_for_cross_file_referencing(self) -> float:
67 | """Override to provide optimal wait time for ruby-lsp cross-file reference resolution.
68 |
69 | ruby-lsp typically initializes quickly, but may need a brief moment
70 | for cross-file analysis in larger projects.
71 | """
72 | return 0.5 # 500ms should be sufficient for ruby-lsp
73 |
74 | @staticmethod
75 | def _find_executable_with_extensions(executable_name: str) -> str | None:
76 | """
77 | Find executable with Windows-specific extensions (.bat, .cmd, .exe) if on Windows.
78 | Returns the full path to the executable or None if not found.
79 | """
80 | import platform
81 |
82 | if platform.system() == "Windows":
83 | # Try Windows-specific extensions first
84 | for ext in [".bat", ".cmd", ".exe"]:
85 | path = shutil.which(f"{executable_name}{ext}")
86 | if path:
87 | return path
88 | # Fall back to default search
89 | return shutil.which(executable_name)
90 | else:
91 | # Unix systems
92 | return shutil.which(executable_name)
93 |
94 | @staticmethod
95 | def _setup_runtime_dependencies(config: LanguageServerConfig, repository_root_path: str) -> list[str]:
96 | """
97 | Setup runtime dependencies for ruby-lsp and return the command list to start the server.
98 | Installation strategy: Bundler project > global ruby-lsp > gem install ruby-lsp
99 | """
100 | # Detect rbenv-managed Ruby environment
101 | # When .ruby-version exists, it indicates the project uses rbenv for version management.
102 | # rbenv automatically reads .ruby-version to determine which Ruby version to use.
103 | # Using "rbenv exec" ensures commands run with the correct Ruby version and its gems.
104 | #
105 | # Why rbenv is preferred over system Ruby:
106 | # - Respects project-specific Ruby versions
107 | # - Avoids bundler version mismatches between system and project
108 | # - Ensures consistent environment across developers
109 | #
110 | # Fallback behavior:
111 | # If .ruby-version doesn't exist or rbenv isn't installed, we fall back to system Ruby.
112 | # This may cause issues if:
113 | # - System Ruby version differs from what the project expects
114 | # - System bundler version is incompatible with Gemfile.lock
115 | # - Project gems aren't installed in system Ruby
116 | ruby_version_file = os.path.join(repository_root_path, ".ruby-version")
117 | use_rbenv = os.path.exists(ruby_version_file) and shutil.which("rbenv") is not None
118 |
119 | if use_rbenv:
120 | ruby_cmd = ["rbenv", "exec", "ruby"]
121 | bundle_cmd = ["rbenv", "exec", "bundle"]
122 | log.info(f"Using rbenv-managed Ruby (found {ruby_version_file})")
123 | else:
124 | ruby_cmd = ["ruby"]
125 | bundle_cmd = ["bundle"]
126 | if os.path.exists(ruby_version_file):
127 | log.warning(
128 | f"Found {ruby_version_file} but rbenv is not installed. "
129 | "Using system Ruby. Consider installing rbenv for better version management: https://github.com/rbenv/rbenv",
130 | )
131 | else:
132 | log.info("No .ruby-version file found, using system Ruby")
133 |
134 | # Check if Ruby is installed
135 | try:
136 | result = subprocess.run(ruby_cmd + ["--version"], check=True, capture_output=True, cwd=repository_root_path, text=True)
137 | ruby_version = result.stdout.strip()
138 | log.info(f"Ruby version: {ruby_version}")
139 |
140 | # Extract version number for compatibility checks
141 | import re
142 |
143 | version_match = re.search(r"ruby (\d+)\.(\d+)\.(\d+)", ruby_version)
144 | if version_match:
145 | major, minor, patch = map(int, version_match.groups())
146 | if major < 2 or (major == 2 and minor < 6):
147 | log.warning(f"Warning: Ruby {major}.{minor}.{patch} detected. ruby-lsp works best with Ruby 2.6+")
148 |
149 | except subprocess.CalledProcessError as e:
150 | error_msg = e.stderr if isinstance(e.stderr, str) else e.stderr.decode() if e.stderr else "Unknown error"
151 | raise RuntimeError(
152 | f"Error checking Ruby installation: {error_msg}. Please ensure Ruby is properly installed and in PATH."
153 | ) from e
154 | except FileNotFoundError as e:
155 | raise RuntimeError(
156 | "Ruby is not installed or not found in PATH. Please install Ruby using one of these methods:\n"
157 | " - Using rbenv: rbenv install 3.0.0 && rbenv global 3.0.0\n"
158 | " - Using RVM: rvm install 3.0.0 && rvm use 3.0.0 --default\n"
159 | " - Using asdf: asdf install ruby 3.0.0 && asdf global ruby 3.0.0\n"
160 | " - System package manager (brew install ruby, apt install ruby, etc.)"
161 | ) from e
162 |
163 | # Check for Bundler project (Gemfile exists)
164 | gemfile_path = os.path.join(repository_root_path, "Gemfile")
165 | gemfile_lock_path = os.path.join(repository_root_path, "Gemfile.lock")
166 | is_bundler_project = os.path.exists(gemfile_path)
167 |
168 | if is_bundler_project:
169 | log.info("Detected Bundler project (Gemfile found)")
170 |
171 | # Check if bundle command is available using Windows-compatible search
172 | bundle_path = RubyLsp._find_executable_with_extensions(bundle_cmd[0] if len(bundle_cmd) == 1 else "bundle")
173 | if not bundle_path:
174 | # Try common bundle executables
175 | for bundle_executable in ["bin/bundle", "bundle"]:
176 | bundle_full_path: str | None
177 | if bundle_executable.startswith("bin/"):
178 | bundle_full_path = os.path.join(repository_root_path, bundle_executable)
179 | else:
180 | bundle_full_path = RubyLsp._find_executable_with_extensions(bundle_executable)
181 | if bundle_full_path and os.path.exists(bundle_full_path):
182 | bundle_path = bundle_full_path if bundle_executable.startswith("bin/") else bundle_executable
183 | break
184 |
185 | if not bundle_path:
186 | log.warning(
187 | "Bundler project detected but 'bundle' command not found. Falling back to global ruby-lsp installation.",
188 | )
189 | else:
190 | # Check if ruby-lsp is in Gemfile.lock
191 | ruby_lsp_in_bundle = False
192 | if os.path.exists(gemfile_lock_path):
193 | try:
194 | with open(gemfile_lock_path) as f:
195 | content = f.read()
196 | ruby_lsp_in_bundle = "ruby-lsp" in content.lower()
197 | except Exception as e:
198 | log.warning(f"Warning: Could not read Gemfile.lock: {e}")
199 |
200 | if ruby_lsp_in_bundle:
201 | log.info("Found ruby-lsp in Gemfile.lock")
202 | return bundle_cmd + ["exec", "ruby-lsp"]
203 | else:
204 | log.info(
205 | "ruby-lsp not found in Gemfile.lock. Consider adding 'gem \"ruby-lsp\"' to your Gemfile for better compatibility.",
206 | )
207 | # Fall through to global installation check
208 |
209 | # Check if ruby-lsp is available globally using Windows-compatible search
210 | ruby_lsp_path = RubyLsp._find_executable_with_extensions("ruby-lsp")
211 | if ruby_lsp_path:
212 | log.info(f"Found ruby-lsp at: {ruby_lsp_path}")
213 | return [ruby_lsp_path]
214 |
215 | # Try to install ruby-lsp globally
216 | log.info("ruby-lsp not found, attempting to install globally...")
217 | try:
218 | subprocess.run(["gem", "install", "ruby-lsp"], check=True, capture_output=True, cwd=repository_root_path)
219 | log.info("Successfully installed ruby-lsp globally")
220 | # Find the newly installed ruby-lsp executable
221 | ruby_lsp_path = RubyLsp._find_executable_with_extensions("ruby-lsp")
222 | return [ruby_lsp_path] if ruby_lsp_path else ["ruby-lsp"]
223 | except subprocess.CalledProcessError as e:
224 | error_msg = e.stderr if isinstance(e.stderr, str) else e.stderr.decode() if e.stderr else str(e)
225 | if is_bundler_project:
226 | raise RuntimeError(
227 | f"Failed to install ruby-lsp globally: {error_msg}\n"
228 | "For Bundler projects, please add 'gem \"ruby-lsp\"' to your Gemfile and run 'bundle install'.\n"
229 | "Alternatively, install globally: gem install ruby-lsp"
230 | ) from e
231 | raise RuntimeError(f"Failed to install ruby-lsp: {error_msg}\nPlease try installing manually: gem install ruby-lsp") from e
232 |
233 | @staticmethod
234 | def _detect_rails_project(repository_root_path: str) -> bool:
235 | """
236 | Detect if this is a Rails project by checking for Rails-specific files.
237 | """
238 | rails_indicators = [
239 | "config/application.rb",
240 | "config/environment.rb",
241 | "app/controllers/application_controller.rb",
242 | "Rakefile",
243 | ]
244 |
245 | for indicator in rails_indicators:
246 | if os.path.exists(os.path.join(repository_root_path, indicator)):
247 | return True
248 |
249 | # Check for Rails in Gemfile
250 | gemfile_path = os.path.join(repository_root_path, "Gemfile")
251 | if os.path.exists(gemfile_path):
252 | try:
253 | with open(gemfile_path) as f:
254 | content = f.read().lower()
255 | if "gem 'rails'" in content or 'gem "rails"' in content:
256 | return True
257 | except Exception:
258 | pass
259 |
260 | return False
261 |
262 | @staticmethod
263 | def _get_ruby_exclude_patterns(repository_root_path: str) -> list[str]:
264 | """
265 | Get Ruby and Rails-specific exclude patterns for better performance.
266 | """
267 | base_patterns = [
268 | "**/vendor/**", # Ruby vendor directory
269 | "**/.bundle/**", # Bundler cache
270 | "**/tmp/**", # Temporary files
271 | "**/log/**", # Log files
272 | "**/coverage/**", # Test coverage reports
273 | "**/.yardoc/**", # YARD documentation cache
274 | "**/doc/**", # Generated documentation
275 | "**/.git/**", # Git directory
276 | "**/node_modules/**", # Node modules (for Rails with JS)
277 | "**/public/assets/**", # Rails compiled assets
278 | ]
279 |
280 | # Add Rails-specific patterns if this is a Rails project
281 | if RubyLsp._detect_rails_project(repository_root_path):
282 | base_patterns.extend(
283 | [
284 | "**/app/assets/builds/**", # Rails 7+ CSS builds
285 | "**/storage/**", # Active Storage
286 | "**/public/packs/**", # Webpacker
287 | "**/public/webpack/**", # Webpack
288 | ]
289 | )
290 |
291 | return base_patterns
292 |
293 | def _get_initialize_params(self) -> InitializeParams:
294 | """
295 | Returns ruby-lsp specific initialization parameters.
296 | """
297 | exclude_patterns = self._get_ruby_exclude_patterns(self.repository_root_path)
298 |
299 | initialize_params = {
300 | "processId": os.getpid(),
301 | "rootPath": self.repository_root_path,
302 | "rootUri": pathlib.Path(self.repository_root_path).as_uri(),
303 | "capabilities": {
304 | "workspace": {
305 | "workspaceEdit": {"documentChanges": True},
306 | "configuration": True,
307 | },
308 | "window": {
309 | "workDoneProgress": True,
310 | },
311 | "textDocument": {
312 | "documentSymbol": {
313 | "hierarchicalDocumentSymbolSupport": True,
314 | "symbolKind": {"valueSet": list(range(1, 27))},
315 | },
316 | "completion": {
317 | "completionItem": {
318 | "snippetSupport": True,
319 | "commitCharactersSupport": True,
320 | }
321 | },
322 | },
323 | },
324 | "initializationOptions": {
325 | # ruby-lsp enables all features by default, so we don't need to specify enabledFeatures
326 | "experimentalFeaturesEnabled": False,
327 | "featuresConfiguration": {},
328 | "indexing": {
329 | "includedPatterns": ["**/*.rb", "**/*.rake", "**/*.ru", "**/*.erb"],
330 | "excludedPatterns": exclude_patterns,
331 | },
332 | },
333 | }
334 |
335 | return initialize_params # type: ignore
336 |
337 | def _start_server(self) -> None:
338 | """
339 | Starts the ruby-lsp Language Server for Ruby
340 | """
341 |
342 | def register_capability_handler(params: dict) -> None:
343 | assert "registrations" in params
344 | for registration in params["registrations"]:
345 | log.info(f"Registered capability: {registration['method']}")
346 | return
347 |
348 | def lang_status_handler(params: dict) -> None:
349 | log.info(f"LSP: language/status: {params}")
350 | if params.get("type") == "ready":
351 | log.info("ruby-lsp service is ready.")
352 | self.analysis_complete.set()
353 | self.completions_available.set()
354 |
355 | def execute_client_command_handler(params: dict) -> list:
356 | return []
357 |
358 | def do_nothing(params: dict) -> None:
359 | return
360 |
361 | def window_log_message(msg: dict) -> None:
362 | log.info(f"LSP: window/logMessage: {msg}")
363 |
364 | def progress_handler(params: dict) -> None:
365 | # ruby-lsp sends progress notifications during indexing
366 | log.debug(f"LSP: $/progress: {params}")
367 | if "value" in params:
368 | value = params["value"]
369 | # Check for completion indicators
370 | if value.get("kind") == "end":
371 | log.info("ruby-lsp indexing complete ($/progress end)")
372 | self.analysis_complete.set()
373 | self.completions_available.set()
374 | elif value.get("kind") == "begin":
375 | log.info("ruby-lsp indexing started ($/progress begin)")
376 | elif "percentage" in value:
377 | percentage = value.get("percentage", 0)
378 | log.debug(f"ruby-lsp indexing progress: {percentage}%")
379 | # Handle direct progress format (fallback)
380 | elif "token" in params and "value" in params:
381 | token = params.get("token")
382 | if isinstance(token, str) and "indexing" in token.lower():
383 | value = params.get("value", {})
384 | if value.get("kind") == "end" or value.get("percentage") == 100:
385 | log.info("ruby-lsp indexing complete (token progress)")
386 | self.analysis_complete.set()
387 | self.completions_available.set()
388 |
389 | def window_work_done_progress_create(params: dict) -> dict:
390 | """Handle workDoneProgress/create requests from ruby-lsp"""
391 | log.debug(f"LSP: window/workDoneProgress/create: {params}")
392 | return {}
393 |
394 | self.server.on_request("client/registerCapability", register_capability_handler)
395 | self.server.on_notification("language/status", lang_status_handler)
396 | self.server.on_notification("window/logMessage", window_log_message)
397 | self.server.on_request("workspace/executeClientCommand", execute_client_command_handler)
398 | self.server.on_notification("$/progress", progress_handler)
399 | self.server.on_request("window/workDoneProgress/create", window_work_done_progress_create)
400 | self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
401 |
402 | log.info("Starting ruby-lsp server process")
403 | self.server.start()
404 | initialize_params = self._get_initialize_params()
405 |
406 | log.info("Sending initialize request from LSP client to LSP server and awaiting response")
407 | log.info(f"Sending init params: {json.dumps(initialize_params, indent=4)}")
408 | init_response = self.server.send.initialize(initialize_params)
409 | log.info(f"Received init response: {init_response}")
410 |
411 | # Verify expected capabilities
412 | # Note: ruby-lsp may return textDocumentSync in different formats (number or object)
413 | text_document_sync = init_response["capabilities"].get("textDocumentSync")
414 | if isinstance(text_document_sync, int):
415 | assert text_document_sync in [1, 2], f"Unexpected textDocumentSync value: {text_document_sync}"
416 | elif isinstance(text_document_sync, dict):
417 | # ruby-lsp returns an object with change property
418 | assert "change" in text_document_sync, "textDocumentSync object should have 'change' property"
419 |
420 | assert "completionProvider" in init_response["capabilities"]
421 |
422 | self.server.notify.initialized({})
423 | # Wait for ruby-lsp to complete its initial indexing
424 | # ruby-lsp has fast indexing
425 | log.info("Waiting for ruby-lsp to complete initial indexing...")
426 | if self.analysis_complete.wait(timeout=30.0):
427 | log.info("ruby-lsp initial indexing complete, server ready")
428 | else:
429 | log.warning("Timeout waiting for ruby-lsp indexing completion, proceeding anyway")
430 | # Fallback: assume indexing is complete after timeout
431 | self.analysis_complete.set()
432 | self.completions_available.set()
433 |
434 | def _handle_initialization_response(self, init_response: InitializeResult) -> None:
435 | """
436 | Handle the initialization response from ruby-lsp and validate capabilities.
437 | """
438 | if "capabilities" in init_response:
439 | capabilities = init_response["capabilities"]
440 |
441 | # Validate textDocumentSync (ruby-lsp may return different formats)
442 | text_document_sync = capabilities.get("textDocumentSync")
443 | if isinstance(text_document_sync, int):
444 | assert text_document_sync in [1, 2], f"Unexpected textDocumentSync value: {text_document_sync}"
445 | elif isinstance(text_document_sync, dict):
446 | # ruby-lsp returns an object with change property
447 | assert "change" in text_document_sync, "textDocumentSync object should have 'change' property"
448 |
449 | # Log important capabilities
450 | important_capabilities = [
451 | "completionProvider",
452 | "hoverProvider",
453 | "definitionProvider",
454 | "referencesProvider",
455 | "documentSymbolProvider",
456 | "codeActionProvider",
457 | "documentFormattingProvider",
458 | "semanticTokensProvider",
459 | ]
460 |
461 | for cap in important_capabilities:
462 | if cap in capabilities:
463 | log.debug(f"ruby-lsp {cap}: available")
464 |
465 | # Signal that the service is ready
466 | self.service_ready_event.set()
467 |
```
--------------------------------------------------------------------------------
/test/solidlsp/vue/test_vue_error_cases.py:
--------------------------------------------------------------------------------
```python
1 | import os
2 | import sys
3 |
4 | import pytest
5 |
6 | from solidlsp import SolidLanguageServer
7 | from solidlsp.ls_config import Language
8 |
9 | pytestmark = pytest.mark.vue
10 |
11 | IS_WINDOWS = sys.platform == "win32"
12 |
13 |
14 | class TypeScriptServerBehavior:
15 | """Platform-specific TypeScript language server behavior for invalid positions.
16 |
17 | On Windows: TS server returns empty results for invalid positions
18 | On macOS/Linux: TS server raises exceptions with "Bad line number" or "Debug Failure"
19 | """
20 |
21 | @staticmethod
22 | def raises_on_invalid_position() -> bool:
23 | return not IS_WINDOWS
24 |
25 | @staticmethod
26 | def returns_empty_on_invalid_position() -> bool:
27 | return IS_WINDOWS
28 |
29 |
30 | class TestVueInvalidPositions:
31 | @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True)
32 | def test_negative_line_number(self, language_server: SolidLanguageServer) -> None:
33 | file_path = os.path.join("src", "components", "CalculatorInput.vue")
34 |
35 | result = language_server.request_containing_symbol(file_path, -1, 0)
36 |
37 | assert result is None or result == {}, f"Negative line number should return None or empty dict, got: {result}"
38 |
39 | @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True)
40 | def test_negative_character_number(self, language_server: SolidLanguageServer) -> None:
41 | """Test requesting containing symbol with negative character number.
42 |
43 | Expected behavior: Should return None or empty dict, not crash.
44 | """
45 | file_path = os.path.join("src", "components", "CalculatorInput.vue")
46 |
47 | # Request containing symbol at invalid negative character
48 | result = language_server.request_containing_symbol(file_path, 10, -1)
49 |
50 | # Should handle gracefully - return None or empty dict
51 | assert result is None or result == {}, f"Negative character number should return None or empty dict, got: {result}"
52 |
53 | @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True)
54 | def test_line_number_beyond_file_length(self, language_server: SolidLanguageServer) -> None:
55 | """Test requesting containing symbol beyond file length.
56 |
57 | Expected behavior: Raises IndexError when trying to access line beyond file bounds.
58 | This happens in the wrapper code before even reaching the language server.
59 | """
60 | file_path = os.path.join("src", "components", "CalculatorInput.vue")
61 |
62 | # Request containing symbol at line 99999 (way beyond file length)
63 | # The wrapper code will raise an IndexError when checking if the line is empty
64 | with pytest.raises(IndexError) as exc_info:
65 | language_server.request_containing_symbol(file_path, 99999, 0)
66 |
67 | # Verify it's an index error for list access
68 | assert "list index out of range" in str(exc_info.value), f"Expected 'list index out of range' error, got: {exc_info.value}"
69 |
70 | @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True)
71 | def test_character_number_beyond_line_length(self, language_server: SolidLanguageServer) -> None:
72 | """Test requesting containing symbol beyond line length.
73 |
74 | Expected behavior: Should return None or empty dict, not crash.
75 | """
76 | file_path = os.path.join("src", "components", "CalculatorInput.vue")
77 |
78 | # Request containing symbol at character 99999 (way beyond line length)
79 | result = language_server.request_containing_symbol(file_path, 10, 99999)
80 |
81 | # Should handle gracefully - return None or empty dict
82 | assert result is None or result == {}, f"Character beyond line length should return None or empty dict, got: {result}"
83 |
84 | @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True)
85 | def test_references_at_negative_line(self, language_server: SolidLanguageServer) -> None:
86 | """Test requesting references with negative line number."""
87 | from solidlsp.ls_exceptions import SolidLSPException
88 |
89 | file_path = os.path.join("src", "components", "CalculatorInput.vue")
90 |
91 | if TypeScriptServerBehavior.returns_empty_on_invalid_position():
92 | result = language_server.request_references(file_path, -1, 0)
93 | assert result == [], f"Expected empty list on Windows, got: {result}"
94 | else:
95 | with pytest.raises(SolidLSPException) as exc_info:
96 | language_server.request_references(file_path, -1, 0)
97 | assert "Bad line number" in str(exc_info.value) or "Debug Failure" in str(exc_info.value)
98 |
99 | @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True)
100 | def test_definition_at_invalid_position(self, language_server: SolidLanguageServer) -> None:
101 | """Test requesting definition at invalid position."""
102 | from solidlsp.ls_exceptions import SolidLSPException
103 |
104 | file_path = os.path.join("src", "components", "CalculatorInput.vue")
105 |
106 | if TypeScriptServerBehavior.returns_empty_on_invalid_position():
107 | result = language_server.request_definition(file_path, -1, 0)
108 | assert result == [], f"Expected empty list on Windows, got: {result}"
109 | else:
110 | with pytest.raises(SolidLSPException) as exc_info:
111 | language_server.request_definition(file_path, -1, 0)
112 | assert "Bad line number" in str(exc_info.value) or "Debug Failure" in str(exc_info.value)
113 |
114 |
115 | class TestVueNonExistentFiles:
116 | """Tests for handling non-existent files."""
117 |
118 | @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True)
119 | def test_document_symbols_nonexistent_file(self, language_server: SolidLanguageServer) -> None:
120 | """Test requesting document symbols from non-existent file.
121 |
122 | Expected behavior: Should raise FileNotFoundError or return empty result.
123 | """
124 | nonexistent_file = os.path.join("src", "components", "NonExistent.vue")
125 |
126 | # Should raise an appropriate exception or return empty result
127 | try:
128 | result = language_server.request_document_symbols(nonexistent_file)
129 | # If no exception, verify result is empty or indicates file not found
130 | symbols = result.get_all_symbols_and_roots()
131 | assert len(symbols[0]) == 0, f"Non-existent file should return empty symbols, got {len(symbols[0])} symbols"
132 | except (FileNotFoundError, Exception) as e:
133 | # Expected - file doesn't exist
134 | assert True, f"Appropriately raised exception for non-existent file: {e}"
135 |
136 | @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True)
137 | def test_containing_symbol_nonexistent_file(self, language_server: SolidLanguageServer) -> None:
138 | """Test requesting containing symbol from non-existent file.
139 |
140 | Expected behavior: Should raise FileNotFoundError or return None.
141 | """
142 | nonexistent_file = os.path.join("src", "components", "NonExistent.vue")
143 |
144 | # Should raise an appropriate exception or return None
145 | try:
146 | result = language_server.request_containing_symbol(nonexistent_file, 10, 10)
147 | # If no exception, verify result indicates file not found
148 | assert result is None or result == {}, f"Non-existent file should return None or empty dict, got: {result}"
149 | except (FileNotFoundError, Exception) as e:
150 | # Expected - file doesn't exist
151 | assert True, f"Appropriately raised exception for non-existent file: {e}"
152 |
153 | @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True)
154 | def test_references_nonexistent_file(self, language_server: SolidLanguageServer) -> None:
155 | """Test requesting references from non-existent file.
156 |
157 | Expected behavior: Should raise FileNotFoundError or return empty list.
158 | """
159 | nonexistent_file = os.path.join("src", "components", "NonExistent.vue")
160 |
161 | # Should raise an appropriate exception or return empty list
162 | try:
163 | result = language_server.request_references(nonexistent_file, 10, 10)
164 | # If no exception, verify result is empty
165 | assert result is None or isinstance(result, list), f"Non-existent file should return None or list, got: {result}"
166 | if isinstance(result, list):
167 | assert len(result) == 0, f"Non-existent file should return empty list, got {len(result)} references"
168 | except (FileNotFoundError, Exception) as e:
169 | # Expected - file doesn't exist
170 | assert True, f"Appropriately raised exception for non-existent file: {e}"
171 |
172 | @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True)
173 | def test_definition_nonexistent_file(self, language_server: SolidLanguageServer) -> None:
174 | """Test requesting definition from non-existent file.
175 |
176 | Expected behavior: Should raise FileNotFoundError or return empty list.
177 | """
178 | nonexistent_file = os.path.join("src", "components", "NonExistent.vue")
179 |
180 | # Should raise an appropriate exception or return empty list
181 | try:
182 | result = language_server.request_definition(nonexistent_file, 10, 10)
183 | # If no exception, verify result is empty
184 | assert isinstance(result, list), f"request_definition should return a list, got: {type(result)}"
185 | assert len(result) == 0, f"Non-existent file should return empty list, got {len(result)} definitions"
186 | except (FileNotFoundError, Exception) as e:
187 | # Expected - file doesn't exist
188 | assert True, f"Appropriately raised exception for non-existent file: {e}"
189 |
190 |
191 | class TestVueUndefinedSymbols:
192 | """Tests for handling undefined or unreferenced symbols."""
193 |
194 | @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True)
195 | def test_references_for_unreferenced_symbol(self, language_server: SolidLanguageServer) -> None:
196 | """Test requesting references for a symbol that has no references.
197 |
198 | Expected behavior: Should return empty list (only the definition itself if include_self=True).
199 | """
200 | # Find a symbol that likely has no external references
201 | file_path = os.path.join("src", "components", "CalculatorButton.vue")
202 |
203 | # Get document symbols
204 | symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots()
205 |
206 | # Find pressCount - this is exposed but may not be referenced elsewhere
207 | press_count_symbol = next((s for s in symbols[0] if s.get("name") == "pressCount"), None)
208 |
209 | if not press_count_symbol or "selectionRange" not in press_count_symbol:
210 | pytest.skip("pressCount symbol not found - test fixture may need updating")
211 |
212 | # Request references without include_self
213 | sel_start = press_count_symbol["selectionRange"]["start"]
214 | refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"])
215 |
216 | # Should return a list (may be empty or contain only definition)
217 | assert isinstance(refs, list), f"request_references should return a list, got {type(refs)}"
218 |
219 | # For an unreferenced symbol, should have 0-1 references (0 without include_self, 1 with)
220 | # The exact count depends on the language server implementation
221 | assert len(refs) <= 5, (
222 | f"pressCount should have few or no external references. "
223 | f"Got {len(refs)} references. This is not necessarily an error, just documenting behavior."
224 | )
225 |
226 | @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True)
227 | def test_containing_symbol_at_whitespace_only_line(self, language_server: SolidLanguageServer) -> None:
228 | """Test requesting containing symbol at a whitespace-only line.
229 |
230 | Expected behavior: Should return None, empty dict, or the parent symbol.
231 | """
232 | file_path = os.path.join("src", "components", "CalculatorInput.vue")
233 |
234 | # Try position at line 1 (typically a blank line or template start in Vue SFCs)
235 | result = language_server.request_containing_symbol(file_path, 1, 0)
236 |
237 | # Should handle gracefully - return None, empty dict, or a valid parent symbol
238 | assert (
239 | result is None or result == {} or isinstance(result, dict)
240 | ), f"Whitespace line should return None, empty dict, or valid symbol. Got: {result}"
241 |
242 | @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True)
243 | def test_definition_at_keyword_position(self, language_server: SolidLanguageServer) -> None:
244 | """Test requesting definition at language keyword position.
245 |
246 | Expected behavior: Should return empty list or handle gracefully.
247 | """
248 | file_path = os.path.join("src", "components", "CalculatorInput.vue")
249 |
250 | # Try to get definition at a keyword like "const", "import", etc.
251 | # Line 2 typically has "import" statement - try position on "import" keyword
252 | result = language_server.request_definition(file_path, 2, 0)
253 |
254 | # Should handle gracefully - return empty list or valid definitions
255 | assert isinstance(result, list), f"request_definition should return a list, got {type(result)}"
256 |
257 |
258 | class TestVueEdgeCasePositions:
259 | """Tests for edge case positions (0,0 and file boundaries)."""
260 |
261 | @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True)
262 | def test_containing_symbol_at_file_start(self, language_server: SolidLanguageServer) -> None:
263 | """Test requesting containing symbol at position (0,0).
264 |
265 | Expected behavior: Should return None, empty dict, or a valid symbol.
266 | This position typically corresponds to the start of the file (e.g., <template> tag).
267 | """
268 | file_path = os.path.join("src", "components", "CalculatorInput.vue")
269 |
270 | # Request containing symbol at position 0,0 (file start)
271 | result = language_server.request_containing_symbol(file_path, 0, 0)
272 |
273 | # Should handle gracefully
274 | assert (
275 | result is None or result == {} or isinstance(result, dict)
276 | ), f"Position 0,0 should return None, empty dict, or valid symbol. Got: {result}"
277 |
278 | @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True)
279 | def test_references_at_file_start(self, language_server: SolidLanguageServer) -> None:
280 | """Test requesting references at position (0,0).
281 |
282 | Expected behavior: Should return None or empty list.
283 | """
284 | file_path = os.path.join("src", "components", "CalculatorInput.vue")
285 |
286 | # Request references at position 0,0 (file start)
287 | result = language_server.request_references(file_path, 0, 0)
288 |
289 | # Should handle gracefully
290 | assert result is None or isinstance(result, list), f"Position 0,0 should return None or list. Got: {type(result)}"
291 |
292 | @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True)
293 | def test_definition_at_file_start(self, language_server: SolidLanguageServer) -> None:
294 | """Test requesting definition at position (0,0).
295 |
296 | Expected behavior: Should return empty list.
297 | """
298 | file_path = os.path.join("src", "components", "CalculatorInput.vue")
299 |
300 | # Request definition at position 0,0 (file start)
301 | result = language_server.request_definition(file_path, 0, 0)
302 |
303 | # Should handle gracefully
304 | assert isinstance(result, list), f"request_definition should return a list. Got: {type(result)}"
305 |
306 | @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True)
307 | def test_containing_symbol_in_template_section(self, language_server: SolidLanguageServer) -> None:
308 | """Test requesting containing symbol in the template section.
309 |
310 | Expected behavior: Template positions typically have no containing symbol (return None or empty).
311 | The Vue language server may not track template symbols the same way as script symbols.
312 | """
313 | file_path = os.path.join("src", "components", "CalculatorInput.vue")
314 |
315 | # Position likely in template section (early in file, before <script setup>)
316 | # Exact line depends on file structure, but line 5-10 is often template
317 | result = language_server.request_containing_symbol(file_path, 5, 10)
318 |
319 | # Should handle gracefully - template doesn't have containing symbols in the same way
320 | assert (
321 | result is None or result == {} or isinstance(result, dict)
322 | ), f"Template position should return None, empty dict, or valid symbol. Got: {result}"
323 |
324 | @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True)
325 | def test_zero_character_positions(self, language_server: SolidLanguageServer) -> None:
326 | """Test requesting symbols at character position 0 (start of lines).
327 |
328 | Expected behavior: Should handle gracefully, may or may not find symbols.
329 | """
330 | file_path = os.path.join("src", "components", "CalculatorInput.vue")
331 |
332 | # Test multiple lines at character 0
333 | for line in [0, 10, 20, 30]:
334 | result = language_server.request_containing_symbol(file_path, line, 0)
335 |
336 | # Should handle gracefully
337 | assert (
338 | result is None or result == {} or isinstance(result, dict)
339 | ), f"Line {line}, character 0 should return None, empty dict, or valid symbol. Got: {result}"
340 |
341 |
342 | class TestVueTypescriptFileErrors:
343 | """Tests for error handling in TypeScript files within Vue projects."""
344 |
345 | @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True)
346 | def test_typescript_file_invalid_position(self, language_server: SolidLanguageServer) -> None:
347 | """Test requesting symbols from TypeScript file at invalid position.
348 |
349 | Expected behavior: Should handle gracefully.
350 | """
351 | file_path = os.path.join("src", "stores", "calculator.ts")
352 |
353 | # Request containing symbol at invalid position
354 | result = language_server.request_containing_symbol(file_path, -1, -1)
355 |
356 | # Should handle gracefully
357 | assert result is None or result == {}, f"Invalid position in .ts file should return None or empty dict. Got: {result}"
358 |
359 | @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True)
360 | def test_typescript_file_beyond_bounds(self, language_server: SolidLanguageServer) -> None:
361 | """Test requesting symbols from TypeScript file beyond file bounds.
362 |
363 | Expected behavior: Raises IndexError when trying to access line beyond file bounds.
364 | """
365 | file_path = os.path.join("src", "stores", "calculator.ts")
366 |
367 | # Request containing symbol beyond file bounds
368 | # The wrapper code will raise an IndexError when checking if the line is empty
369 | with pytest.raises(IndexError) as exc_info:
370 | language_server.request_containing_symbol(file_path, 99999, 99999)
371 |
372 | # Verify it's an index error for list access
373 | assert "list index out of range" in str(exc_info.value), f"Expected 'list index out of range' error, got: {exc_info.value}"
374 |
375 |
376 | class TestVueReferenceEdgeCases:
377 | """Tests for edge cases in reference finding."""
378 |
379 | @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True)
380 | def test_referencing_symbols_at_invalid_position(self, language_server: SolidLanguageServer) -> None:
381 | """Test requesting referencing symbols at invalid position."""
382 | from solidlsp.ls_exceptions import SolidLSPException
383 |
384 | file_path = os.path.join("src", "stores", "calculator.ts")
385 |
386 | if TypeScriptServerBehavior.returns_empty_on_invalid_position():
387 | result = list(language_server.request_referencing_symbols(file_path, -1, -1, include_self=False))
388 | assert result == [], f"Expected empty list on Windows, got: {result}"
389 | else:
390 | with pytest.raises(SolidLSPException) as exc_info:
391 | list(language_server.request_referencing_symbols(file_path, -1, -1, include_self=False))
392 | assert "Bad line number" in str(exc_info.value) or "Debug Failure" in str(exc_info.value)
393 |
394 | @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True)
395 | def test_defining_symbol_at_invalid_position(self, language_server: SolidLanguageServer) -> None:
396 | """Test requesting defining symbol at invalid position."""
397 | from solidlsp.ls_exceptions import SolidLSPException
398 |
399 | file_path = os.path.join("src", "components", "CalculatorInput.vue")
400 |
401 | if TypeScriptServerBehavior.returns_empty_on_invalid_position():
402 | result = language_server.request_defining_symbol(file_path, -1, -1)
403 | assert result is None, f"Expected None on Windows, got: {result}"
404 | else:
405 | with pytest.raises(SolidLSPException) as exc_info:
406 | language_server.request_defining_symbol(file_path, -1, -1)
407 | assert "Bad line number" in str(exc_info.value) or "Debug Failure" in str(exc_info.value)
408 |
409 | @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True)
410 | def test_referencing_symbols_beyond_file_bounds(self, language_server: SolidLanguageServer) -> None:
411 | """Test requesting referencing symbols beyond file bounds."""
412 | from solidlsp.ls_exceptions import SolidLSPException
413 |
414 | file_path = os.path.join("src", "stores", "calculator.ts")
415 |
416 | if TypeScriptServerBehavior.returns_empty_on_invalid_position():
417 | result = list(language_server.request_referencing_symbols(file_path, 99999, 99999, include_self=False))
418 | assert result == [], f"Expected empty list on Windows, got: {result}"
419 | else:
420 | with pytest.raises(SolidLSPException) as exc_info:
421 | list(language_server.request_referencing_symbols(file_path, 99999, 99999, include_self=False))
422 | assert "Bad line number" in str(exc_info.value) or "Debug Failure" in str(exc_info.value)
423 |
```
--------------------------------------------------------------------------------
/test/serena/test_symbol_editing.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Snapshot tests using the (awesome) syrupy pytest plugin https://github.com/syrupy-project/syrupy.
3 | Recreate the snapshots with `pytest --snapshot-update`.
4 | """
5 |
6 | import logging
7 | import os
8 | import shutil
9 | import sys
10 | import tempfile
11 | import time
12 | from abc import ABC, abstractmethod
13 | from collections.abc import Iterator
14 | from contextlib import contextmanager
15 | from dataclasses import dataclass, field
16 | from difflib import SequenceMatcher
17 | from pathlib import Path
18 | from typing import Literal, NamedTuple
19 |
20 | import pytest
21 | from overrides import overrides
22 | from syrupy import SnapshotAssertion
23 |
24 | from serena.code_editor import CodeEditor, LanguageServerCodeEditor
25 | from solidlsp.ls_config import Language
26 | from src.serena.symbol import LanguageServerSymbolRetriever
27 | from test.conftest import get_repo_path, start_ls_context
28 |
29 | pytestmark = pytest.mark.snapshot
30 |
31 | log = logging.getLogger(__name__)
32 |
33 |
34 | class LineChange(NamedTuple):
35 | """Represents a change to a specific line or range of lines."""
36 |
37 | operation: Literal["insert", "delete", "replace"]
38 | original_start: int
39 | original_end: int
40 | modified_start: int
41 | modified_end: int
42 | original_lines: list[str]
43 | modified_lines: list[str]
44 |
45 |
46 | @dataclass
47 | class CodeDiff:
48 | """
49 | Represents the difference between original and modified code.
50 | Provides object-oriented access to diff information including line numbers.
51 | """
52 |
53 | relative_path: str
54 | original_content: str
55 | modified_content: str
56 | _line_changes: list[LineChange] = field(init=False)
57 |
58 | def __post_init__(self) -> None:
59 | """Compute the diff using difflib's SequenceMatcher."""
60 | original_lines = self.original_content.splitlines(keepends=True)
61 | modified_lines = self.modified_content.splitlines(keepends=True)
62 |
63 | matcher = SequenceMatcher(None, original_lines, modified_lines)
64 | self._line_changes = []
65 |
66 | for tag, orig_start, orig_end, mod_start, mod_end in matcher.get_opcodes():
67 | if tag == "equal":
68 | continue
69 | if tag == "insert":
70 | self._line_changes.append(
71 | LineChange(
72 | operation="insert",
73 | original_start=orig_start,
74 | original_end=orig_start,
75 | modified_start=mod_start,
76 | modified_end=mod_end,
77 | original_lines=[],
78 | modified_lines=modified_lines[mod_start:mod_end],
79 | )
80 | )
81 | elif tag == "delete":
82 | self._line_changes.append(
83 | LineChange(
84 | operation="delete",
85 | original_start=orig_start,
86 | original_end=orig_end,
87 | modified_start=mod_start,
88 | modified_end=mod_start,
89 | original_lines=original_lines[orig_start:orig_end],
90 | modified_lines=[],
91 | )
92 | )
93 | elif tag == "replace":
94 | self._line_changes.append(
95 | LineChange(
96 | operation="replace",
97 | original_start=orig_start,
98 | original_end=orig_end,
99 | modified_start=mod_start,
100 | modified_end=mod_end,
101 | original_lines=original_lines[orig_start:orig_end],
102 | modified_lines=modified_lines[mod_start:mod_end],
103 | )
104 | )
105 |
106 | @property
107 | def line_changes(self) -> list[LineChange]:
108 | """Get all line changes in the diff."""
109 | return self._line_changes
110 |
111 | @property
112 | def has_changes(self) -> bool:
113 | """Check if there are any changes."""
114 | return len(self._line_changes) > 0
115 |
116 | @property
117 | def added_lines(self) -> list[tuple[int, str]]:
118 | """Get all added lines with their line numbers (0-based) in the modified file."""
119 | result = []
120 | for change in self._line_changes:
121 | if change.operation in ("insert", "replace"):
122 | for i, line in enumerate(change.modified_lines):
123 | result.append((change.modified_start + i, line))
124 | return result
125 |
126 | @property
127 | def deleted_lines(self) -> list[tuple[int, str]]:
128 | """Get all deleted lines with their line numbers (0-based) in the original file."""
129 | result = []
130 | for change in self._line_changes:
131 | if change.operation in ("delete", "replace"):
132 | for i, line in enumerate(change.original_lines):
133 | result.append((change.original_start + i, line))
134 | return result
135 |
136 | @property
137 | def modified_line_numbers(self) -> list[int]:
138 | """Get all line numbers (0-based) that were modified in the modified file."""
139 | line_nums: set[int] = set()
140 | for change in self._line_changes:
141 | if change.operation in ("insert", "replace"):
142 | line_nums.update(range(change.modified_start, change.modified_end))
143 | return sorted(line_nums)
144 |
145 | @property
146 | def affected_original_line_numbers(self) -> list[int]:
147 | """Get all line numbers (0-based) that were affected in the original file."""
148 | line_nums: set[int] = set()
149 | for change in self._line_changes:
150 | if change.operation in ("delete", "replace"):
151 | line_nums.update(range(change.original_start, change.original_end))
152 | return sorted(line_nums)
153 |
154 | def get_unified_diff(self, context_lines: int = 3) -> str:
155 | """Get the unified diff as a string."""
156 | import difflib
157 |
158 | original_lines = self.original_content.splitlines(keepends=True)
159 | modified_lines = self.modified_content.splitlines(keepends=True)
160 |
161 | diff = difflib.unified_diff(
162 | original_lines, modified_lines, fromfile=f"a/{self.relative_path}", tofile=f"b/{self.relative_path}", n=context_lines
163 | )
164 | return "".join(diff)
165 |
166 | def get_context_diff(self, context_lines: int = 3) -> str:
167 | """Get the context diff as a string."""
168 | import difflib
169 |
170 | original_lines = self.original_content.splitlines(keepends=True)
171 | modified_lines = self.modified_content.splitlines(keepends=True)
172 |
173 | diff = difflib.context_diff(
174 | original_lines, modified_lines, fromfile=f"a/{self.relative_path}", tofile=f"b/{self.relative_path}", n=context_lines
175 | )
176 | return "".join(diff)
177 |
178 |
179 | class EditingTest(ABC):
180 | def __init__(self, language: Language, rel_path: str):
181 | """
182 | :param language: the language
183 | :param rel_path: the relative path of the edited file
184 | """
185 | self.rel_path = rel_path
186 | self.language = language
187 | self.original_repo_path = get_repo_path(language)
188 | self.repo_path: Path | None = None
189 |
190 | @contextmanager
191 | def _setup(self) -> Iterator[LanguageServerSymbolRetriever]:
192 | """Context manager for setup/teardown with a temporary directory, providing the symbol manager."""
193 | temp_dir = Path(tempfile.mkdtemp())
194 | self.repo_path = temp_dir / self.original_repo_path.name
195 | language_server = None # Initialize language_server
196 | try:
197 | print(f"Copying repo from {self.original_repo_path} to {self.repo_path}")
198 | shutil.copytree(self.original_repo_path, self.repo_path)
199 | # prevent deadlock on Windows due to file locks caused by antivirus or some other external software
200 | # wait for a long time here
201 | if os.name == "nt":
202 | time.sleep(0.1)
203 | log.info(f"Creating language server for {self.language} {self.rel_path}")
204 | with start_ls_context(self.language, str(self.repo_path)) as language_server:
205 | yield LanguageServerSymbolRetriever(ls=language_server)
206 | finally:
207 | # prevent deadlock on Windows due to lingering file locks
208 | if os.name == "nt":
209 | time.sleep(0.1)
210 | log.info(f"Removing temp directory {temp_dir}")
211 | shutil.rmtree(temp_dir, ignore_errors=True)
212 | log.info(f"Temp directory {temp_dir} removed")
213 |
214 | def _read_file(self, rel_path: str) -> str:
215 | """Read the content of a file in the test repository."""
216 | assert self.repo_path is not None
217 | file_path = self.repo_path / rel_path
218 | with open(file_path, encoding="utf-8") as f:
219 | return f.read()
220 |
221 | def run_test(self, content_after_ground_truth: str | SnapshotAssertion) -> None:
222 | with self._setup() as symbol_retriever:
223 | content_before = self._read_file(self.rel_path)
224 | code_editor = LanguageServerCodeEditor(symbol_retriever)
225 | self._apply_edit(code_editor)
226 | content_after = self._read_file(self.rel_path)
227 | code_diff = CodeDiff(self.rel_path, original_content=content_before, modified_content=content_after)
228 | self._test_diff(code_diff, content_after_ground_truth)
229 |
230 | @abstractmethod
231 | def _apply_edit(self, code_editor: CodeEditor) -> None:
232 | pass
233 |
234 | def _test_diff(self, code_diff: CodeDiff, snapshot: SnapshotAssertion) -> None:
235 | assert code_diff.has_changes, f"Sanity check failed: No changes detected in {code_diff.relative_path}"
236 | assert code_diff.modified_content == snapshot
237 |
238 |
239 | # Python test file path
240 | PYTHON_TEST_REL_FILE_PATH = os.path.join("test_repo", "variables.py")
241 |
242 | # TypeScript test file path
243 | TYPESCRIPT_TEST_FILE = "index.ts"
244 |
245 |
246 | class DeleteSymbolTest(EditingTest):
247 | def __init__(self, language: Language, rel_path: str, deleted_symbol: str):
248 | super().__init__(language, rel_path)
249 | self.deleted_symbol = deleted_symbol
250 | self.rel_path = rel_path
251 |
252 | def _apply_edit(self, code_editor: CodeEditor) -> None:
253 | code_editor.delete_symbol(self.deleted_symbol, self.rel_path)
254 |
255 |
256 | @pytest.mark.parametrize(
257 | "test_case",
258 | [
259 | pytest.param(
260 | DeleteSymbolTest(
261 | Language.PYTHON,
262 | PYTHON_TEST_REL_FILE_PATH,
263 | "VariableContainer",
264 | ),
265 | marks=pytest.mark.python,
266 | ),
267 | pytest.param(
268 | DeleteSymbolTest(
269 | Language.TYPESCRIPT,
270 | TYPESCRIPT_TEST_FILE,
271 | "DemoClass",
272 | ),
273 | marks=pytest.mark.typescript,
274 | ),
275 | ],
276 | )
277 | def test_delete_symbol(test_case, snapshot: SnapshotAssertion):
278 | test_case.run_test(content_after_ground_truth=snapshot)
279 |
280 |
281 | NEW_PYTHON_FUNCTION = """def new_inserted_function():
282 | print("This is a new function inserted before another.")"""
283 |
284 | NEW_PYTHON_CLASS_WITH_LEADING_NEWLINES = """
285 |
286 | class NewInsertedClass:
287 | pass
288 | """
289 |
290 | NEW_PYTHON_CLASS_WITH_TRAILING_NEWLINES = """class NewInsertedClass:
291 | pass
292 |
293 |
294 | """
295 |
296 | NEW_TYPESCRIPT_FUNCTION = """function newInsertedFunction(): void {
297 | console.log("This is a new function inserted before another.");
298 | }"""
299 |
300 |
301 | NEW_PYTHON_VARIABLE = 'new_module_var = "Inserted after typed_module_var"'
302 |
303 | NEW_TYPESCRIPT_FUNCTION_AFTER = """function newFunctionAfterClass(): void {
304 | console.log("This function is after DemoClass.");
305 | }"""
306 |
307 |
308 | class InsertInRelToSymbolTest(EditingTest):
309 | def __init__(
310 | self, language: Language, rel_path: str, symbol_name: str, new_content: str, mode: Literal["before", "after"] | None = None
311 | ):
312 | super().__init__(language, rel_path)
313 | self.symbol_name = symbol_name
314 | self.new_content = new_content
315 | self.mode: Literal["before", "after"] | None = mode
316 |
317 | def set_mode(self, mode: Literal["before", "after"]):
318 | self.mode = mode
319 |
320 | def _apply_edit(self, code_editor: CodeEditor) -> None:
321 | assert self.mode is not None
322 | if self.mode == "before":
323 | code_editor.insert_before_symbol(self.symbol_name, self.rel_path, self.new_content)
324 | elif self.mode == "after":
325 | code_editor.insert_after_symbol(self.symbol_name, self.rel_path, self.new_content)
326 |
327 |
328 | @pytest.mark.parametrize("mode", ["before", "after"])
329 | @pytest.mark.parametrize(
330 | "test_case",
331 | [
332 | pytest.param(
333 | InsertInRelToSymbolTest(
334 | Language.PYTHON,
335 | PYTHON_TEST_REL_FILE_PATH,
336 | "typed_module_var",
337 | NEW_PYTHON_VARIABLE,
338 | ),
339 | marks=pytest.mark.python,
340 | ),
341 | pytest.param(
342 | InsertInRelToSymbolTest(
343 | Language.PYTHON,
344 | PYTHON_TEST_REL_FILE_PATH,
345 | "use_module_variables",
346 | NEW_PYTHON_FUNCTION,
347 | ),
348 | marks=pytest.mark.python,
349 | ),
350 | pytest.param(
351 | InsertInRelToSymbolTest(
352 | Language.TYPESCRIPT,
353 | TYPESCRIPT_TEST_FILE,
354 | "DemoClass",
355 | NEW_TYPESCRIPT_FUNCTION_AFTER,
356 | ),
357 | marks=pytest.mark.typescript,
358 | ),
359 | pytest.param(
360 | InsertInRelToSymbolTest(
361 | Language.TYPESCRIPT,
362 | TYPESCRIPT_TEST_FILE,
363 | "helperFunction",
364 | NEW_TYPESCRIPT_FUNCTION,
365 | ),
366 | marks=pytest.mark.typescript,
367 | ),
368 | ],
369 | )
370 | def test_insert_in_rel_to_symbol(test_case: InsertInRelToSymbolTest, mode: Literal["before", "after"], snapshot: SnapshotAssertion):
371 | test_case.set_mode(mode)
372 | test_case.run_test(content_after_ground_truth=snapshot)
373 |
374 |
375 | @pytest.mark.python
376 | def test_insert_python_class_before(snapshot: SnapshotAssertion):
377 | InsertInRelToSymbolTest(
378 | Language.PYTHON,
379 | PYTHON_TEST_REL_FILE_PATH,
380 | "VariableDataclass",
381 | NEW_PYTHON_CLASS_WITH_TRAILING_NEWLINES,
382 | mode="before",
383 | ).run_test(snapshot)
384 |
385 |
386 | @pytest.mark.python
387 | def test_insert_python_class_after(snapshot: SnapshotAssertion):
388 | InsertInRelToSymbolTest(
389 | Language.PYTHON,
390 | PYTHON_TEST_REL_FILE_PATH,
391 | "VariableDataclass",
392 | NEW_PYTHON_CLASS_WITH_LEADING_NEWLINES,
393 | mode="after",
394 | ).run_test(snapshot)
395 |
396 |
397 | PYTHON_REPLACED_BODY = """def modify_instance_var(self):
398 | # This body has been replaced
399 | self.instance_var = "Replaced!"
400 | self.reassignable_instance_var = 999
401 | """
402 |
403 | TYPESCRIPT_REPLACED_BODY = """function printValue() {
404 | // This body has been replaced
405 | console.warn("New value: " + this.value);
406 | }
407 | """
408 |
409 |
410 | class ReplaceBodyTest(EditingTest):
411 | def __init__(self, language: Language, rel_path: str, symbol_name: str, new_body: str):
412 | super().__init__(language, rel_path)
413 | self.symbol_name = symbol_name
414 | self.new_body = new_body
415 |
416 | def _apply_edit(self, code_editor: CodeEditor) -> None:
417 | code_editor.replace_body(self.symbol_name, self.rel_path, self.new_body)
418 |
419 |
420 | @pytest.mark.parametrize(
421 | "test_case",
422 | [
423 | pytest.param(
424 | ReplaceBodyTest(
425 | Language.PYTHON,
426 | PYTHON_TEST_REL_FILE_PATH,
427 | "VariableContainer/modify_instance_var",
428 | PYTHON_REPLACED_BODY,
429 | ),
430 | marks=pytest.mark.python,
431 | ),
432 | pytest.param(
433 | ReplaceBodyTest(
434 | Language.TYPESCRIPT,
435 | TYPESCRIPT_TEST_FILE,
436 | "DemoClass/printValue",
437 | TYPESCRIPT_REPLACED_BODY,
438 | ),
439 | marks=pytest.mark.typescript,
440 | ),
441 | ],
442 | )
443 | def test_replace_body(test_case: ReplaceBodyTest, snapshot: SnapshotAssertion):
444 | # assert "a" in snapshot
445 | test_case.run_test(content_after_ground_truth=snapshot)
446 |
447 |
448 | NIX_ATTR_REPLACEMENT = """c = 3;"""
449 |
450 |
451 | class NixAttrReplacementTest(EditingTest):
452 | """Test for replacing individual attributes in Nix that should NOT result in double semicolons."""
453 |
454 | def __init__(self, language: Language, rel_path: str, symbol_name: str, new_body: str):
455 | super().__init__(language, rel_path)
456 | self.symbol_name = symbol_name
457 | self.new_body = new_body
458 |
459 | def _apply_edit(self, code_editor: CodeEditor) -> None:
460 | code_editor.replace_body(self.symbol_name, self.rel_path, self.new_body)
461 |
462 |
463 | @pytest.mark.nix
464 | @pytest.mark.skipif(sys.platform == "win32", reason="nixd language server doesn't run on Windows")
465 | def test_nix_symbol_replacement_no_double_semicolon(snapshot: SnapshotAssertion):
466 | """
467 | Test that replacing a Nix attribute does not result in double semicolons.
468 |
469 | This test exercises the bug where:
470 | - Original: users.users.example = { isSystemUser = true; group = "example"; description = "Example service user"; };
471 | - Replacement: c = 3;
472 | - Bug result would be: c = 3;; (double semicolon)
473 | - Correct result should be: c = 3; (single semicolon)
474 |
475 | The replacement body includes a semicolon, but the language server's range extension
476 | logic should prevent double semicolons.
477 | """
478 | test_case = NixAttrReplacementTest(
479 | Language.NIX,
480 | "default.nix",
481 | "testUser", # Simple attrset with multiple key-value pairs
482 | NIX_ATTR_REPLACEMENT,
483 | )
484 | test_case.run_test(content_after_ground_truth=snapshot)
485 |
486 |
487 | class RenameSymbolTest(EditingTest):
488 | def __init__(self, language: Language, rel_path: str, symbol_name: str, new_name: str):
489 | super().__init__(language, rel_path)
490 | self.symbol_name = symbol_name
491 | self.new_name = new_name
492 |
493 | def _apply_edit(self, code_editor: CodeEditor) -> None:
494 | code_editor.rename_symbol(self.symbol_name, self.rel_path, self.new_name)
495 |
496 | @overrides
497 | def _test_diff(self, code_diff: CodeDiff, snapshot: SnapshotAssertion) -> None:
498 | # sanity check (e.g., for newly generated snapshots) that the new name is actually in the modified content
499 | assert self.new_name in code_diff.modified_content, f"New name '{self.new_name}' not found in modified content."
500 | return super()._test_diff(code_diff, snapshot)
501 |
502 |
503 | @pytest.mark.python
504 | def test_rename_symbol(snapshot: SnapshotAssertion):
505 | test_case = RenameSymbolTest(
506 | Language.PYTHON,
507 | PYTHON_TEST_REL_FILE_PATH,
508 | "typed_module_var",
509 | "renamed_typed_module_var",
510 | )
511 | test_case.run_test(content_after_ground_truth=snapshot)
512 |
513 |
514 | # ===== VUE WRITE OPERATIONS TESTS =====
515 |
516 | VUE_TEST_FILE = os.path.join("src", "components", "CalculatorButton.vue")
517 | VUE_STORE_FILE = os.path.join("src", "stores", "calculator.ts")
518 |
519 | NEW_VUE_HANDLER = """const handleDoubleClick = () => {
520 | pressCount.value++;
521 | emit('click', props.label);
522 | }"""
523 |
524 |
525 | @pytest.mark.parametrize(
526 | "test_case",
527 | [
528 | pytest.param(
529 | DeleteSymbolTest(
530 | Language.VUE,
531 | VUE_TEST_FILE,
532 | "handleMouseEnter",
533 | ),
534 | marks=pytest.mark.vue,
535 | ),
536 | ],
537 | )
538 | def test_delete_symbol_vue(test_case: DeleteSymbolTest, snapshot: SnapshotAssertion) -> None:
539 | test_case.run_test(content_after_ground_truth=snapshot)
540 |
541 |
542 | @pytest.mark.parametrize("mode", ["before", "after"])
543 | @pytest.mark.parametrize(
544 | "test_case",
545 | [
546 | pytest.param(
547 | InsertInRelToSymbolTest(
548 | Language.VUE,
549 | VUE_TEST_FILE,
550 | "handleClick",
551 | NEW_VUE_HANDLER,
552 | ),
553 | marks=pytest.mark.vue,
554 | ),
555 | ],
556 | )
557 | def test_insert_in_rel_to_symbol_vue(
558 | test_case: InsertInRelToSymbolTest,
559 | mode: Literal["before", "after"],
560 | snapshot: SnapshotAssertion,
561 | ) -> None:
562 | test_case.set_mode(mode)
563 | test_case.run_test(content_after_ground_truth=snapshot)
564 |
565 |
566 | VUE_REPLACED_HANDLECLICK_BODY = """const handleClick = () => {
567 | if (!props.disabled) {
568 | pressCount.value = 0; // Reset instead of incrementing
569 | emit('click', props.label);
570 | }
571 | }"""
572 |
573 |
574 | @pytest.mark.parametrize(
575 | "test_case",
576 | [
577 | pytest.param(
578 | ReplaceBodyTest(
579 | Language.VUE,
580 | VUE_TEST_FILE,
581 | "handleClick",
582 | VUE_REPLACED_HANDLECLICK_BODY,
583 | ),
584 | marks=pytest.mark.vue,
585 | ),
586 | ],
587 | )
588 | def test_replace_body_vue(test_case: ReplaceBodyTest, snapshot: SnapshotAssertion) -> None:
589 | test_case.run_test(content_after_ground_truth=snapshot)
590 |
591 |
592 | VUE_REPLACED_PRESSCOUNT_BODY = """const pressCount = ref(100)"""
593 |
594 |
595 | @pytest.mark.parametrize(
596 | "test_case",
597 | [
598 | pytest.param(
599 | ReplaceBodyTest(
600 | Language.VUE,
601 | VUE_TEST_FILE,
602 | "pressCount",
603 | VUE_REPLACED_PRESSCOUNT_BODY,
604 | ),
605 | marks=pytest.mark.vue,
606 | ),
607 | ],
608 | )
609 | def test_replace_body_vue_with_disambiguation(test_case: ReplaceBodyTest, snapshot: SnapshotAssertion) -> None:
610 | """Test symbol disambiguation when replacing body in Vue files.
611 |
612 | This test verifies the fix for the Vue LSP symbol duplication issue.
613 | When the LSP returns two symbols with the same name (e.g., pressCount appears both as
614 | a definition `const pressCount = ref(0)` and as a shorthand property in `defineExpose({ pressCount })`),
615 | the _find_unique_symbol method should prefer the symbol with the larger range (the definition).
616 |
617 | The test exercises this by calling replace_body on 'pressCount', which internally calls
618 | _find_unique_symbol and should correctly select the definition (line 40, 19 chars) over
619 | the reference (line 97, 10 chars).
620 | """
621 | test_case.run_test(content_after_ground_truth=snapshot)
622 |
623 |
624 | VUE_STORE_REPLACED_CLEAR_BODY = """function clear() {
625 | // Modified: Reset to initial state with a log
626 | console.log('Clearing calculator state');
627 | displayValue.value = '0';
628 | expression.value = '';
629 | operationHistory.value = [];
630 | lastResult.value = undefined;
631 | }"""
632 |
633 |
634 | @pytest.mark.parametrize(
635 | "test_case",
636 | [
637 | pytest.param(
638 | ReplaceBodyTest(
639 | Language.VUE,
640 | VUE_STORE_FILE,
641 | "clear",
642 | VUE_STORE_REPLACED_CLEAR_BODY,
643 | ),
644 | marks=pytest.mark.vue,
645 | ),
646 | ],
647 | )
648 | def test_replace_body_vue_ts_file(test_case: ReplaceBodyTest, snapshot: SnapshotAssertion) -> None:
649 | """Test that TypeScript files within Vue projects can be edited."""
650 | test_case.run_test(content_after_ground_truth=snapshot)
651 |
```
--------------------------------------------------------------------------------
/src/serena/tools/file_tools.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | File and file system-related tools, specifically for
3 | * listing directory contents
4 | * reading files
5 | * creating files
6 | * editing at the file level
7 | """
8 |
9 | import os
10 | import re
11 | from collections import defaultdict
12 | from collections.abc import Callable
13 | from fnmatch import fnmatch
14 | from pathlib import Path
15 | from typing import Literal
16 |
17 | from serena.text_utils import search_files
18 | from serena.tools import SUCCESS_RESULT, EditedFileContext, Tool, ToolMarkerCanEdit, ToolMarkerOptional
19 | from serena.util.file_system import scan_directory
20 |
21 |
22 | class ReadFileTool(Tool):
23 | """
24 | Reads a file within the project directory.
25 | """
26 |
27 | def apply(self, relative_path: str, start_line: int = 0, end_line: int | None = None, max_answer_chars: int = -1) -> str:
28 | """
29 | Reads the given file or a chunk of it. Generally, symbolic operations
30 | like find_symbol or find_referencing_symbols should be preferred if you know which symbols you are looking for.
31 |
32 | :param relative_path: the relative path to the file to read
33 | :param start_line: the 0-based index of the first line to be retrieved.
34 | :param end_line: the 0-based index of the last line to be retrieved (inclusive). If None, read until the end of the file.
35 | :param max_answer_chars: if the file (chunk) is longer than this number of characters,
36 | no content will be returned. Don't adjust unless there is really no other way to get the content
37 | required for the task.
38 | :return: the full text of the file at the given relative path
39 | """
40 | self.project.validate_relative_path(relative_path, require_not_ignored=True)
41 |
42 | result = self.project.read_file(relative_path)
43 | result_lines = result.splitlines()
44 | if end_line is None:
45 | result_lines = result_lines[start_line:]
46 | else:
47 | result_lines = result_lines[start_line : end_line + 1]
48 | result = "\n".join(result_lines)
49 |
50 | return self._limit_length(result, max_answer_chars)
51 |
52 |
53 | class CreateTextFileTool(Tool, ToolMarkerCanEdit):
54 | """
55 | Creates/overwrites a file in the project directory.
56 | """
57 |
58 | def apply(self, relative_path: str, content: str) -> str:
59 | """
60 | Write a new file or overwrite an existing file.
61 |
62 | :param relative_path: the relative path to the file to create
63 | :param content: the (appropriately encoded) content to write to the file
64 | :return: a message indicating success or failure
65 | """
66 | project_root = self.get_project_root()
67 | abs_path = (Path(project_root) / relative_path).resolve()
68 | will_overwrite_existing = abs_path.exists()
69 |
70 | if will_overwrite_existing:
71 | self.project.validate_relative_path(relative_path, require_not_ignored=True)
72 | else:
73 | assert abs_path.is_relative_to(
74 | self.get_project_root()
75 | ), f"Cannot create file outside of the project directory, got {relative_path=}"
76 |
77 | abs_path.parent.mkdir(parents=True, exist_ok=True)
78 | abs_path.write_text(content, encoding=self.project.project_config.encoding)
79 | answer = f"File created: {relative_path}."
80 | if will_overwrite_existing:
81 | answer += " Overwrote existing file."
82 | return answer
83 |
84 |
85 | class ListDirTool(Tool):
86 | """
87 | Lists files and directories in the given directory (optionally with recursion).
88 | """
89 |
90 | def apply(self, relative_path: str, recursive: bool, skip_ignored_files: bool = False, max_answer_chars: int = -1) -> str:
91 | """
92 | Lists files and directories in the given directory (optionally with recursion).
93 |
94 | :param relative_path: the relative path to the directory to list; pass "." to scan the project root
95 | :param recursive: whether to scan subdirectories recursively
96 | :param skip_ignored_files: whether to skip files and directories that are ignored
97 | :param max_answer_chars: if the output is longer than this number of characters,
98 | no content will be returned. -1 means the default value from the config will be used.
99 | Don't adjust unless there is really no other way to get the content required for the task.
100 | :return: a JSON object with the names of directories and files within the given directory
101 | """
102 | # Check if the directory exists before validation
103 | if not self.project.relative_path_exists(relative_path):
104 | error_info = {
105 | "error": f"Directory not found: {relative_path}",
106 | "project_root": self.get_project_root(),
107 | "hint": "Check if the path is correct relative to the project root",
108 | }
109 | return self._to_json(error_info)
110 |
111 | self.project.validate_relative_path(relative_path, require_not_ignored=skip_ignored_files)
112 |
113 | dirs, files = scan_directory(
114 | os.path.join(self.get_project_root(), relative_path),
115 | relative_to=self.get_project_root(),
116 | recursive=recursive,
117 | is_ignored_dir=self.project.is_ignored_path if skip_ignored_files else None,
118 | is_ignored_file=self.project.is_ignored_path if skip_ignored_files else None,
119 | )
120 |
121 | result = self._to_json({"dirs": dirs, "files": files})
122 | return self._limit_length(result, max_answer_chars)
123 |
124 |
125 | class FindFileTool(Tool):
126 | """
127 | Finds files in the given relative paths
128 | """
129 |
130 | def apply(self, file_mask: str, relative_path: str) -> str:
131 | """
132 | Finds non-gitignored files matching the given file mask within the given relative path
133 |
134 | :param file_mask: the filename or file mask (using the wildcards * or ?) to search for
135 | :param relative_path: the relative path to the directory to search in; pass "." to scan the project root
136 | :return: a JSON object with the list of matching files
137 | """
138 | self.project.validate_relative_path(relative_path, require_not_ignored=True)
139 |
140 | dir_to_scan = os.path.join(self.get_project_root(), relative_path)
141 |
142 | # find the files by ignoring everything that doesn't match
143 | def is_ignored_file(abs_path: str) -> bool:
144 | if self.project.is_ignored_path(abs_path):
145 | return True
146 | filename = os.path.basename(abs_path)
147 | return not fnmatch(filename, file_mask)
148 |
149 | _dirs, files = scan_directory(
150 | path=dir_to_scan,
151 | recursive=True,
152 | is_ignored_dir=self.project.is_ignored_path,
153 | is_ignored_file=is_ignored_file,
154 | relative_to=self.get_project_root(),
155 | )
156 |
157 | result = self._to_json({"files": files})
158 | return result
159 |
160 |
161 | class ReplaceContentTool(Tool, ToolMarkerCanEdit):
162 | """
163 | Replaces content in a file (optionally using regular expressions).
164 | """
165 |
166 | def apply(
167 | self,
168 | relative_path: str,
169 | needle: str,
170 | repl: str,
171 | mode: Literal["literal", "regex"],
172 | allow_multiple_occurrences: bool = False,
173 | ) -> str:
174 | r"""
175 | Replaces one or more occurrences of a given pattern in a file with new content.
176 |
177 | This is the preferred way to replace content in a file whenever the symbol-level
178 | tools are not appropriate.
179 |
180 | VERY IMPORTANT: The "regex" mode allows very large sections of code to be replaced without fully quoting them!
181 | Use a regex of the form "beginning.*?end-of-text-to-be-replaced" to be faster and more economical!
182 | ALWAYS try to use wildcards to avoid specifying the exact content to be replaced,
183 | especially if it spans several lines. Note that you cannot make mistakes, because if the regex should match
184 | multiple occurrences while you disabled `allow_multiple_occurrences`, an error will be returned, and you can retry
185 | with a revised regex.
186 | Therefore, using regex mode with suitable wildcards is usually the best choice!
187 |
188 | :param relative_path: the relative path to the file
189 | :param needle: the string or regex pattern to search for.
190 | If `mode` is "literal", this string will be matched exactly.
191 | If `mode` is "regex", this string will be treated as a regular expression (syntax of Python's `re` module,
192 | with flags DOTALL and MULTILINE enabled).
193 | :param repl: the replacement string (verbatim).
194 | If mode is "regex", the string can contain backreferences to matched groups in the needle regex,
195 | specified using the syntax $!1, $!2, etc. for groups 1, 2, etc.
196 | :param mode: either "literal" or "regex", specifying how the `needle` parameter is to be interpreted.
197 | :param allow_multiple_occurrences: if True, the regex may match multiple occurrences in the file
198 | and all of them will be replaced.
199 | If this is set to False and the regex matches multiple occurrences, an error will be returned
200 | (and you may retry with a revised, more specific regex).
201 | """
202 | return self.replace_content(
203 | relative_path, needle, repl, mode=mode, allow_multiple_occurrences=allow_multiple_occurrences, require_not_ignored=True
204 | )
205 |
206 | @staticmethod
207 | def _create_replacement_function(regex_pattern: str, repl_template: str, regex_flags: int) -> Callable[[re.Match], str]:
208 | """
209 | Creates a replacement function that validates for ambiguity and handles backreferences.
210 |
211 | :param regex_pattern: The regex pattern being used for matching
212 | :param repl_template: The replacement template with $!1, $!2, etc. for backreferences
213 | :param regex_flags: The flags to use when searching (e.g., re.DOTALL | re.MULTILINE)
214 | :return: A function suitable for use with re.sub() or re.subn()
215 | """
216 |
217 | def validate_and_replace(match: re.Match) -> str:
218 | matched_text = match.group(0)
219 |
220 | # For multi-line match, check if the same pattern matches again within the already-matched text,
221 | # rendering the match ambiguous. Typical pattern in the code:
222 | # <start><other-stuff><start><stuff><end>
223 | # When matching
224 | # <start>.*?<end>
225 | # this will match the entire span above, while only the suffix may have been intended.
226 | # (See test case for a practical example.)
227 | # To detect this, we check if the same pattern matches again within the matched text,
228 | if "\n" in matched_text and re.search(regex_pattern, matched_text[1:], flags=regex_flags):
229 | raise ValueError(
230 | "Match is ambiguous: the search pattern matches multiple overlapping occurrences. "
231 | "Please revise the search pattern to be more specific to avoid ambiguity."
232 | )
233 |
234 | # Handle backreferences: replace $!1, $!2, etc. with actual matched groups
235 | def expand_backreference(m: re.Match) -> str:
236 | group_num = int(m.group(1))
237 | group_value = match.group(group_num)
238 | return group_value if group_value is not None else m.group(0)
239 |
240 | result = re.sub(r"\$!(\d+)", expand_backreference, repl_template)
241 | return result
242 |
243 | return validate_and_replace
244 |
245 | def replace_content(
246 | self,
247 | relative_path: str,
248 | needle: str,
249 | repl: str,
250 | mode: Literal["literal", "regex"],
251 | allow_multiple_occurrences: bool = False,
252 | require_not_ignored: bool = True,
253 | ) -> str:
254 | """
255 | Performs the replacement, with additional options not exposed in the tool.
256 | This function can be used internally by other tools.
257 | """
258 | self.project.validate_relative_path(relative_path, require_not_ignored=require_not_ignored)
259 | with EditedFileContext(relative_path, self.create_code_editor()) as context:
260 | original_content = context.get_original_content()
261 |
262 | if mode == "literal":
263 | regex = re.escape(needle)
264 | elif mode == "regex":
265 | regex = needle
266 | else:
267 | raise ValueError(f"Invalid mode: '{mode}', expected 'literal' or 'regex'.")
268 |
269 | regex_flags = re.DOTALL | re.MULTILINE
270 |
271 | # create replacement function with validation and backreference handling
272 | repl_fn = self._create_replacement_function(regex, repl, regex_flags=regex_flags)
273 |
274 | # perform replacement
275 | updated_content, n = re.subn(regex, repl_fn, original_content, flags=regex_flags)
276 |
277 | if n == 0:
278 | raise ValueError(f"Error: No matches of search expression found in file '{relative_path}'.")
279 | if not allow_multiple_occurrences and n > 1:
280 | raise ValueError(
281 | f"Expression matches {n} occurrences in file '{relative_path}'. "
282 | "Please revise the expression to be more specific or enable allow_multiple_occurrences if this is expected."
283 | )
284 | context.set_updated_content(updated_content)
285 |
286 | return SUCCESS_RESULT
287 |
288 |
289 | class DeleteLinesTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional):
290 | """
291 | Deletes a range of lines within a file.
292 | """
293 |
294 | def apply(
295 | self,
296 | relative_path: str,
297 | start_line: int,
298 | end_line: int,
299 | ) -> str:
300 | """
301 | Deletes the given lines in the file.
302 | Requires that the same range of lines was previously read using the `read_file` tool to verify correctness
303 | of the operation.
304 |
305 | :param relative_path: the relative path to the file
306 | :param start_line: the 0-based index of the first line to be deleted
307 | :param end_line: the 0-based index of the last line to be deleted
308 | """
309 | code_editor = self.create_code_editor()
310 | code_editor.delete_lines(relative_path, start_line, end_line)
311 | return SUCCESS_RESULT
312 |
313 |
314 | class ReplaceLinesTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional):
315 | """
316 | Replaces a range of lines within a file with new content.
317 | """
318 |
319 | def apply(
320 | self,
321 | relative_path: str,
322 | start_line: int,
323 | end_line: int,
324 | content: str,
325 | ) -> str:
326 | """
327 | Replaces the given range of lines in the given file.
328 | Requires that the same range of lines was previously read using the `read_file` tool to verify correctness
329 | of the operation.
330 |
331 | :param relative_path: the relative path to the file
332 | :param start_line: the 0-based index of the first line to be deleted
333 | :param end_line: the 0-based index of the last line to be deleted
334 | :param content: the content to insert
335 | """
336 | if not content.endswith("\n"):
337 | content += "\n"
338 | result = self.agent.get_tool(DeleteLinesTool).apply(relative_path, start_line, end_line)
339 | if result != SUCCESS_RESULT:
340 | return result
341 | self.agent.get_tool(InsertAtLineTool).apply(relative_path, start_line, content)
342 | return SUCCESS_RESULT
343 |
344 |
345 | class InsertAtLineTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional):
346 | """
347 | Inserts content at a given line in a file.
348 | """
349 |
350 | def apply(
351 | self,
352 | relative_path: str,
353 | line: int,
354 | content: str,
355 | ) -> str:
356 | """
357 | Inserts the given content at the given line in the file, pushing existing content of the line down.
358 | In general, symbolic insert operations like insert_after_symbol or insert_before_symbol should be preferred if you know which
359 | symbol you are looking for.
360 | However, this can also be useful for small targeted edits of the body of a longer symbol (without replacing the entire body).
361 |
362 | :param relative_path: the relative path to the file
363 | :param line: the 0-based index of the line to insert content at
364 | :param content: the content to be inserted
365 | """
366 | if not content.endswith("\n"):
367 | content += "\n"
368 | code_editor = self.create_code_editor()
369 | code_editor.insert_at_line(relative_path, line, content)
370 | return SUCCESS_RESULT
371 |
372 |
373 | class SearchForPatternTool(Tool):
374 | """
375 | Performs a search for a pattern in the project.
376 | """
377 |
378 | def apply(
379 | self,
380 | substring_pattern: str,
381 | context_lines_before: int = 0,
382 | context_lines_after: int = 0,
383 | paths_include_glob: str = "",
384 | paths_exclude_glob: str = "",
385 | relative_path: str = "",
386 | restrict_search_to_code_files: bool = False,
387 | max_answer_chars: int = -1,
388 | ) -> str:
389 | """
390 | Offers a flexible search for arbitrary patterns in the codebase, including the
391 | possibility to search in non-code files.
392 | Generally, symbolic operations like find_symbol or find_referencing_symbols
393 | should be preferred if you know which symbols you are looking for.
394 |
395 | Pattern Matching Logic:
396 | For each match, the returned result will contain the full lines where the
397 | substring pattern is found, as well as optionally some lines before and after it. The pattern will be compiled with
398 | DOTALL, meaning that the dot will match all characters including newlines.
399 | This also means that it never makes sense to have .* at the beginning or end of the pattern,
400 | but it may make sense to have it in the middle for complex patterns.
401 | If a pattern matches multiple lines, all those lines will be part of the match.
402 | Be careful to not use greedy quantifiers unnecessarily, it is usually better to use non-greedy quantifiers like .*? to avoid
403 | matching too much content.
404 |
405 | File Selection Logic:
406 | The files in which the search is performed can be restricted very flexibly.
407 | Using `restrict_search_to_code_files` is useful if you are only interested in code symbols (i.e., those
408 | symbols that can be manipulated with symbolic tools like find_symbol).
409 | You can also restrict the search to a specific file or directory,
410 | and provide glob patterns to include or exclude certain files on top of that.
411 | The globs are matched against relative file paths from the project root (not to the `relative_path` parameter that
412 | is used to further restrict the search).
413 | Smartly combining the various restrictions allows you to perform very targeted searches.
414 |
415 |
416 | :param substring_pattern: Regular expression for a substring pattern to search for
417 | :param context_lines_before: Number of lines of context to include before each match
418 | :param context_lines_after: Number of lines of context to include after each match
419 | :param paths_include_glob: optional glob pattern specifying files to include in the search.
420 | Matches against relative file paths from the project root (e.g., "*.py", "src/**/*.ts").
421 | Supports standard glob patterns (*, ?, [seq], **, etc.) and brace expansion {a,b,c}.
422 | Only matches files, not directories. If left empty, all non-ignored files will be included.
423 | :param paths_exclude_glob: optional glob pattern specifying files to exclude from the search.
424 | Matches against relative file paths from the project root (e.g., "*test*", "**/*_generated.py").
425 | Supports standard glob patterns (*, ?, [seq], **, etc.) and brace expansion {a,b,c}.
426 | Takes precedence over paths_include_glob. Only matches files, not directories. If left empty, no files are excluded.
427 | :param relative_path: only subpaths of this path (relative to the repo root) will be analyzed. If a path to a single
428 | file is passed, only that will be searched. The path must exist, otherwise a `FileNotFoundError` is raised.
429 | :param max_answer_chars: if the output is longer than this number of characters,
430 | no content will be returned.
431 | -1 means the default value from the config will be used.
432 | Don't adjust unless there is really no other way to get the content
433 | required for the task. Instead, if the output is too long, you should
434 | make a stricter query.
435 | :param restrict_search_to_code_files: whether to restrict the search to only those files where
436 | analyzed code symbols can be found. Otherwise, will search all non-ignored files.
437 | Set this to True if your search is only meant to discover code that can be manipulated with symbolic tools.
438 | For example, for finding classes or methods from a name pattern.
439 | Setting to False is a better choice if you also want to search in non-code files, like in html or yaml files,
440 | which is why it is the default.
441 | :return: A mapping of file paths to lists of matched consecutive lines.
442 | """
443 | abs_path = os.path.join(self.get_project_root(), relative_path)
444 | if not os.path.exists(abs_path):
445 | raise FileNotFoundError(f"Relative path {relative_path} does not exist.")
446 |
447 | if restrict_search_to_code_files:
448 | matches = self.project.search_source_files_for_pattern(
449 | pattern=substring_pattern,
450 | relative_path=relative_path,
451 | context_lines_before=context_lines_before,
452 | context_lines_after=context_lines_after,
453 | paths_include_glob=paths_include_glob.strip(),
454 | paths_exclude_glob=paths_exclude_glob.strip(),
455 | )
456 | else:
457 | if os.path.isfile(abs_path):
458 | rel_paths_to_search = [relative_path]
459 | else:
460 | _dirs, rel_paths_to_search = scan_directory(
461 | path=abs_path,
462 | recursive=True,
463 | is_ignored_dir=self.project.is_ignored_path,
464 | is_ignored_file=self.project.is_ignored_path,
465 | relative_to=self.get_project_root(),
466 | )
467 | # TODO (maybe): not super efficient to walk through the files again and filter if glob patterns are provided
468 | # but it probably never matters and this version required no further refactoring
469 | matches = search_files(
470 | rel_paths_to_search,
471 | substring_pattern,
472 | file_reader=self.project.read_file,
473 | root_path=self.get_project_root(),
474 | paths_include_glob=paths_include_glob,
475 | paths_exclude_glob=paths_exclude_glob,
476 | )
477 | # group matches by file
478 | file_to_matches: dict[str, list[str]] = defaultdict(list)
479 | for match in matches:
480 | assert match.source_file_path is not None
481 | file_to_matches[match.source_file_path].append(match.to_display_string())
482 | result = self._to_json(file_to_matches)
483 | return self._limit_length(result, max_answer_chars)
484 |
```
--------------------------------------------------------------------------------
/test/solidlsp/rust/test_rust_analyzer_detection.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Tests for rust-analyzer detection logic.
3 |
4 | These tests describe the expected behavior of RustAnalyzer._ensure_rust_analyzer_installed():
5 |
6 | 1. Rustup should be checked FIRST (avoids picking up incorrect PATH aliases)
7 | 2. Common installation locations (Homebrew, cargo, Scoop) should be checked as fallback
8 | 3. System PATH should be checked last (can pick up incompatible versions)
9 | 4. Error messages should list all searched locations
10 | 5. Windows-specific paths should be checked on Windows
11 |
12 | WHY these tests matter:
13 | - Users install rust-analyzer via Homebrew, cargo, Scoop, or system packages - not just rustup
14 | - macOS Homebrew installs to /opt/homebrew/bin (Apple Silicon) or /usr/local/bin (Intel)
15 | - Windows users install via Scoop, Chocolatey, or cargo
16 | - Detection failing means Serena is unusable for Rust, even when rust-analyzer is correctly installed
17 | - Without these tests, the detection logic can silently break for non-rustup users
18 | """
19 |
20 | import os
21 | import pathlib
22 | import sys
23 | from unittest.mock import MagicMock, patch
24 |
25 | import pytest
26 |
27 | # Platform detection for skipping platform-specific tests
28 | IS_WINDOWS = sys.platform == "win32"
29 | IS_UNIX = sys.platform != "win32"
30 |
31 |
32 | class TestRustAnalyzerDetection:
33 | """Unit tests for rust-analyzer binary detection logic."""
34 |
35 | @pytest.mark.rust
36 | def test_detect_from_path_as_last_resort(self):
37 | """
38 | GIVEN rustup is not available
39 | AND rust-analyzer is NOT in common locations (Homebrew, cargo)
40 | AND rust-analyzer IS in system PATH
41 | WHEN _ensure_rust_analyzer_installed is called
42 | THEN it should return the path from shutil.which as last resort
43 |
44 | WHY: PATH is checked last to avoid picking up incorrect aliases.
45 | Users with rust-analyzer in PATH but not via rustup/common locations
46 | should still work.
47 | """
48 | from solidlsp.language_servers.rust_analyzer import RustAnalyzer
49 |
50 | # Mock rustup to be unavailable
51 | with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None):
52 | # Mock common locations to NOT exist
53 | with patch("os.path.isfile", return_value=False):
54 | # Mock PATH to have rust-analyzer
55 | with patch("shutil.which") as mock_which:
56 | mock_which.return_value = "/custom/bin/rust-analyzer"
57 | with patch("os.access", return_value=True):
58 | # Need isfile to return True for PATH result only
59 | def selective_isfile(path):
60 | return path == "/custom/bin/rust-analyzer"
61 |
62 | with patch("os.path.isfile", side_effect=selective_isfile):
63 | result = RustAnalyzer._ensure_rust_analyzer_installed()
64 |
65 | assert result == "/custom/bin/rust-analyzer"
66 | mock_which.assert_called_with("rust-analyzer")
67 |
68 | @pytest.mark.rust
69 | @pytest.mark.skipif(IS_WINDOWS, reason="Homebrew paths only apply to macOS/Linux")
70 | def test_detect_from_homebrew_apple_silicon_path(self):
71 | """
72 | GIVEN rustup is NOT available
73 | AND rust-analyzer is installed via Homebrew on Apple Silicon Mac
74 | AND it is NOT in PATH (shutil.which returns None)
75 | WHEN _ensure_rust_analyzer_installed is called
76 | THEN it should find /opt/homebrew/bin/rust-analyzer
77 |
78 | WHY: Apple Silicon Macs use /opt/homebrew/bin for Homebrew.
79 | This path should be checked as fallback when rustup is unavailable.
80 | """
81 | from solidlsp.language_servers.rust_analyzer import RustAnalyzer
82 |
83 | def mock_isfile(path):
84 | return path == "/opt/homebrew/bin/rust-analyzer"
85 |
86 | def mock_access(path, mode):
87 | return path == "/opt/homebrew/bin/rust-analyzer"
88 |
89 | with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None):
90 | with patch("shutil.which", return_value=None):
91 | with patch("os.path.isfile", side_effect=mock_isfile):
92 | with patch("os.access", side_effect=mock_access):
93 | result = RustAnalyzer._ensure_rust_analyzer_installed()
94 |
95 | assert result == "/opt/homebrew/bin/rust-analyzer"
96 |
97 | @pytest.mark.rust
98 | @pytest.mark.skipif(IS_WINDOWS, reason="Homebrew paths only apply to macOS/Linux")
99 | def test_detect_from_homebrew_intel_path(self):
100 | """
101 | GIVEN rustup is NOT available
102 | AND rust-analyzer is installed via Homebrew on Intel Mac
103 | AND it is NOT in PATH
104 | WHEN _ensure_rust_analyzer_installed is called
105 | THEN it should find /usr/local/bin/rust-analyzer
106 |
107 | WHY: Intel Macs use /usr/local/bin for Homebrew.
108 | Linux systems may also install to this location.
109 | """
110 | from solidlsp.language_servers.rust_analyzer import RustAnalyzer
111 |
112 | def mock_isfile(path):
113 | return path == "/usr/local/bin/rust-analyzer"
114 |
115 | def mock_access(path, mode):
116 | return path == "/usr/local/bin/rust-analyzer"
117 |
118 | with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None):
119 | with patch("shutil.which", return_value=None):
120 | with patch("os.path.isfile", side_effect=mock_isfile):
121 | with patch("os.access", side_effect=mock_access):
122 | result = RustAnalyzer._ensure_rust_analyzer_installed()
123 |
124 | assert result == "/usr/local/bin/rust-analyzer"
125 |
126 | @pytest.mark.rust
127 | @pytest.mark.skipif(IS_WINDOWS, reason="Unix cargo path - Windows has separate test")
128 | def test_detect_from_cargo_install_path(self):
129 | """
130 | GIVEN rustup is NOT available
131 | AND rust-analyzer is installed via `cargo install rust-analyzer`
132 | AND it is NOT in PATH or Homebrew locations
133 | WHEN _ensure_rust_analyzer_installed is called
134 | THEN it should find ~/.cargo/bin/rust-analyzer
135 |
136 | WHY: `cargo install rust-analyzer` is a common installation method.
137 | The binary lands in ~/.cargo/bin which may not be in PATH.
138 | """
139 | from solidlsp.language_servers.rust_analyzer import RustAnalyzer
140 |
141 | cargo_path = os.path.expanduser("~/.cargo/bin/rust-analyzer")
142 |
143 | def mock_isfile(path):
144 | return path == cargo_path
145 |
146 | def mock_access(path, mode):
147 | return path == cargo_path
148 |
149 | with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None):
150 | with patch("shutil.which", return_value=None):
151 | with patch("os.path.isfile", side_effect=mock_isfile):
152 | with patch("os.access", side_effect=mock_access):
153 | result = RustAnalyzer._ensure_rust_analyzer_installed()
154 |
155 | assert result == cargo_path
156 |
157 | @pytest.mark.rust
158 | def test_detect_from_rustup_when_available(self):
159 | """
160 | GIVEN rustup has rust-analyzer installed
161 | WHEN _ensure_rust_analyzer_installed is called
162 | THEN it should return the rustup path
163 |
164 | WHY: Rustup is checked FIRST to avoid picking up incorrect aliases from PATH.
165 | This ensures compatibility with the toolchain.
166 | """
167 | from solidlsp.language_servers.rust_analyzer import RustAnalyzer
168 |
169 | with patch("shutil.which", return_value=None):
170 | with patch("os.path.isfile", return_value=False):
171 | with patch.object(
172 | RustAnalyzer,
173 | "_get_rust_analyzer_via_rustup",
174 | return_value="/home/user/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin/rust-analyzer",
175 | ):
176 | result = RustAnalyzer._ensure_rust_analyzer_installed()
177 |
178 | assert "rustup" in result or ".rustup" in result
179 |
180 | @pytest.mark.rust
181 | @pytest.mark.skipif(IS_WINDOWS, reason="Unix error messages - Windows has separate test")
182 | def test_error_message_lists_searched_locations_when_not_found(self):
183 | """
184 | GIVEN rust-analyzer is NOT installed anywhere
185 | AND rustup is NOT installed
186 | WHEN _ensure_rust_analyzer_installed is called
187 | THEN it should raise RuntimeError with helpful message listing searched locations
188 |
189 | WHY: Users need to know WHERE Serena looked so they can fix their installation.
190 | The old error "Neither rust-analyzer nor rustup is installed" was unhelpful.
191 | """
192 | from solidlsp.language_servers.rust_analyzer import RustAnalyzer
193 |
194 | with patch("shutil.which", return_value=None):
195 | with patch("os.path.isfile", return_value=False):
196 | with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None):
197 | with patch.object(RustAnalyzer, "_get_rustup_version", return_value=None):
198 | with pytest.raises(RuntimeError) as exc_info:
199 | RustAnalyzer._ensure_rust_analyzer_installed()
200 |
201 | error_message = str(exc_info.value)
202 | # Error should list the locations that were searched (Unix paths)
203 | assert "/opt/homebrew/bin/rust-analyzer" in error_message or "Homebrew" in error_message
204 | assert "cargo" in error_message.lower() or ".cargo/bin" in error_message
205 | # Error should suggest installation methods
206 | assert "rustup" in error_message.lower() or "Rustup" in error_message
207 |
208 | @pytest.mark.rust
209 | def test_detection_priority_prefers_rustup_over_path_and_common_locations(self):
210 | """
211 | GIVEN rust-analyzer is available via rustup
212 | AND rust-analyzer also exists in PATH and common locations
213 | WHEN _ensure_rust_analyzer_installed is called
214 | THEN it should return the rustup version
215 |
216 | WHY: Rustup provides version management and ensures compatibility.
217 | Using PATH directly can pick up incorrect aliases or incompatible versions
218 | that cause LSP crashes (as discovered in CI failures).
219 | """
220 | from solidlsp.language_servers.rust_analyzer import RustAnalyzer
221 |
222 | rustup_path = "/home/user/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin/rust-analyzer"
223 |
224 | # Rustup has rust-analyzer, PATH also has it, common locations also exist
225 | with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=rustup_path):
226 | with patch("shutil.which", return_value="/custom/path/rust-analyzer"):
227 | with patch("os.path.isfile", return_value=True):
228 | with patch("os.access", return_value=True):
229 | result = RustAnalyzer._ensure_rust_analyzer_installed()
230 |
231 | # Should use rustup version, NOT PATH or common locations
232 | assert result == rustup_path
233 |
234 | @pytest.mark.rust
235 | @pytest.mark.skipif(IS_WINDOWS, reason="Uses Unix paths - Windows has different behavior")
236 | def test_skips_nonexecutable_files(self):
237 | """
238 | GIVEN a file exists at a detection path but is NOT executable
239 | WHEN _ensure_rust_analyzer_installed is called
240 | THEN it should skip that path and continue checking others
241 |
242 | WHY: A non-executable file (e.g., broken symlink, wrong permissions)
243 | should not be returned as a valid rust-analyzer path.
244 | """
245 | from solidlsp.language_servers.rust_analyzer import RustAnalyzer
246 |
247 | def mock_isfile(path):
248 | # File exists at Homebrew location but not executable
249 | return path == "/opt/homebrew/bin/rust-analyzer"
250 |
251 | def mock_access(path, mode):
252 | # Homebrew location exists but not executable
253 | if path == "/opt/homebrew/bin/rust-analyzer":
254 | return False
255 | # Cargo location is executable
256 | if path == os.path.expanduser("~/.cargo/bin/rust-analyzer"):
257 | return True
258 | return False
259 |
260 | def mock_isfile_for_cargo(path):
261 | return path in ["/opt/homebrew/bin/rust-analyzer", os.path.expanduser("~/.cargo/bin/rust-analyzer")]
262 |
263 | with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None):
264 | with patch("shutil.which", return_value=None):
265 | with patch("os.path.isfile", side_effect=mock_isfile_for_cargo):
266 | with patch("os.access", side_effect=mock_access):
267 | result = RustAnalyzer._ensure_rust_analyzer_installed()
268 |
269 | # Should skip non-executable Homebrew and use cargo
270 | assert result == os.path.expanduser("~/.cargo/bin/rust-analyzer")
271 |
272 | @pytest.mark.rust
273 | def test_detect_from_scoop_shims_path_on_windows(self):
274 | """
275 | GIVEN rustup is NOT available
276 | AND rust-analyzer is installed via Scoop on Windows
277 | AND it is NOT in PATH
278 | WHEN _ensure_rust_analyzer_installed is called
279 | THEN it should find ~/scoop/shims/rust-analyzer.exe
280 |
281 | WHY: Scoop is a popular package manager for Windows.
282 | The binary lands in ~/scoop/shims which may not be in PATH.
283 | """
284 | from solidlsp.language_servers.rust_analyzer import RustAnalyzer
285 |
286 | home = pathlib.Path.home()
287 | scoop_path = str(home / "scoop" / "shims" / "rust-analyzer.exe")
288 |
289 | def mock_isfile(path):
290 | return path == scoop_path
291 |
292 | def mock_access(path, mode):
293 | return path == scoop_path
294 |
295 | with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None):
296 | with patch("platform.system", return_value="Windows"):
297 | with patch("shutil.which", return_value=None):
298 | with patch("os.path.isfile", side_effect=mock_isfile):
299 | with patch("os.access", side_effect=mock_access):
300 | result = RustAnalyzer._ensure_rust_analyzer_installed()
301 |
302 | assert result == scoop_path
303 |
304 | @pytest.mark.rust
305 | def test_detect_from_cargo_path_on_windows(self):
306 | """
307 | GIVEN rustup is NOT available
308 | AND rust-analyzer is installed via cargo on Windows
309 | AND it is NOT in PATH or Scoop locations
310 | WHEN _ensure_rust_analyzer_installed is called
311 | THEN it should find ~/.cargo/bin/rust-analyzer.exe
312 |
313 | WHY: `cargo install rust-analyzer` works on Windows.
314 | The binary has .exe extension and lands in ~/.cargo/bin.
315 | """
316 | from solidlsp.language_servers.rust_analyzer import RustAnalyzer
317 |
318 | home = pathlib.Path.home()
319 | cargo_path = str(home / ".cargo" / "bin" / "rust-analyzer.exe")
320 |
321 | def mock_isfile(path):
322 | return path == cargo_path
323 |
324 | def mock_access(path, mode):
325 | return path == cargo_path
326 |
327 | with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None):
328 | with patch("platform.system", return_value="Windows"):
329 | with patch("shutil.which", return_value=None):
330 | with patch("os.path.isfile", side_effect=mock_isfile):
331 | with patch("os.access", side_effect=mock_access):
332 | result = RustAnalyzer._ensure_rust_analyzer_installed()
333 |
334 | assert result == cargo_path
335 |
336 | @pytest.mark.rust
337 | def test_windows_error_message_suggests_windows_package_managers(self):
338 | """
339 | GIVEN rust-analyzer is NOT installed anywhere on Windows
340 | AND rustup is NOT installed
341 | WHEN _ensure_rust_analyzer_installed is called
342 | THEN it should raise RuntimeError with Windows-specific installation suggestions
343 |
344 | WHY: Windows users need Windows-specific package manager suggestions
345 | (Scoop, Chocolatey) instead of Homebrew/apt.
346 | """
347 | from solidlsp.language_servers.rust_analyzer import RustAnalyzer
348 |
349 | with patch("platform.system", return_value="Windows"):
350 | with patch("shutil.which", return_value=None):
351 | with patch("os.path.isfile", return_value=False):
352 | with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None):
353 | with patch.object(RustAnalyzer, "_get_rustup_version", return_value=None):
354 | with pytest.raises(RuntimeError) as exc_info:
355 | RustAnalyzer._ensure_rust_analyzer_installed()
356 |
357 | error_message = str(exc_info.value)
358 | # Error should suggest Windows-specific package managers
359 | assert "Scoop" in error_message or "scoop" in error_message
360 | assert "Chocolatey" in error_message or "choco" in error_message
361 | # Should NOT suggest Homebrew on Windows
362 | assert "Homebrew" not in error_message and "brew" not in error_message
363 |
364 | @pytest.mark.rust
365 | def test_auto_install_via_rustup_when_not_found(self):
366 | """
367 | GIVEN rust-analyzer is NOT installed anywhere
368 | AND rustup IS installed
369 | WHEN _ensure_rust_analyzer_installed is called
370 | AND rustup component add succeeds
371 | THEN it should return the rustup-installed path
372 |
373 | WHY: Serena should auto-install rust-analyzer via rustup when possible.
374 | This matches the original behavior and enables CI to work without pre-installing.
375 | """
376 | from solidlsp.language_servers.rust_analyzer import RustAnalyzer
377 |
378 | with patch("shutil.which", return_value=None):
379 | with patch("os.path.isfile", return_value=False):
380 | with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup") as mock_rustup_path:
381 | # First call returns None (not installed), second returns path (after install)
382 | mock_rustup_path.side_effect = [None, "/home/user/.rustup/toolchains/stable/bin/rust-analyzer"]
383 | with patch.object(RustAnalyzer, "_get_rustup_version", return_value="1.70.0"):
384 | with patch("subprocess.run") as mock_run:
385 | mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
386 | result = RustAnalyzer._ensure_rust_analyzer_installed()
387 |
388 | assert result == "/home/user/.rustup/toolchains/stable/bin/rust-analyzer"
389 | mock_run.assert_called_once()
390 | assert mock_run.call_args[0][0] == ["rustup", "component", "add", "rust-analyzer"]
391 |
392 | @pytest.mark.rust
393 | def test_auto_install_failure_falls_through_to_common_paths(self):
394 | """
395 | GIVEN rust-analyzer is NOT installed anywhere
396 | AND rustup IS installed
397 | WHEN _ensure_rust_analyzer_installed is called
398 | AND rustup component add FAILS
399 | THEN it should fall through to common paths and eventually raise helpful error
400 |
401 | WHY: The new resilient behavior tries all fallback options before failing.
402 | When rustup auto-install fails, we try common paths (Homebrew, cargo, etc.)
403 | as a last resort. This is more robust than failing immediately.
404 | The error message should still help users install rust-analyzer.
405 | """
406 | from solidlsp.language_servers.rust_analyzer import RustAnalyzer
407 |
408 | with patch("shutil.which", return_value=None):
409 | with patch("os.path.isfile", return_value=False):
410 | with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None):
411 | with patch.object(RustAnalyzer, "_get_rustup_version", return_value="1.70.0"):
412 | with patch("subprocess.run") as mock_run:
413 | mock_run.return_value = MagicMock(
414 | returncode=1, stdout="", stderr="error: component 'rust-analyzer' is not available"
415 | )
416 | with pytest.raises(RuntimeError) as exc_info:
417 | RustAnalyzer._ensure_rust_analyzer_installed()
418 |
419 | error_message = str(exc_info.value)
420 | # Error should provide helpful installation instructions
421 | assert "rust-analyzer is not installed" in error_message.lower()
422 | assert "rustup" in error_message.lower() # Should suggest rustup installation
423 |
424 | @pytest.mark.rust
425 | def test_auto_install_success_but_binary_not_found_falls_through(self):
426 | """
427 | GIVEN rust-analyzer is NOT installed anywhere
428 | AND rustup IS installed
429 | WHEN _ensure_rust_analyzer_installed is called
430 | AND rustup component add SUCCEEDS
431 | BUT the binary is still not found after installation
432 | THEN it should fall through to common paths and eventually raise helpful error
433 |
434 | WHY: Even if rustup install reports success but binary isn't found,
435 | we try common paths as fallback. The final error provides installation
436 | guidance to help users resolve the issue.
437 | """
438 | from solidlsp.language_servers.rust_analyzer import RustAnalyzer
439 |
440 | with patch("shutil.which", return_value=None):
441 | with patch("os.path.isfile", return_value=False):
442 | with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None):
443 | with patch.object(RustAnalyzer, "_get_rustup_version", return_value="1.70.0"):
444 | with patch("subprocess.run") as mock_run:
445 | mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
446 | with pytest.raises(RuntimeError) as exc_info:
447 | RustAnalyzer._ensure_rust_analyzer_installed()
448 |
449 | error_message = str(exc_info.value)
450 | # Error should indicate rust-analyzer is not available and provide install instructions
451 | assert "rust-analyzer is not installed" in error_message.lower()
452 | assert "searched locations" in error_message.lower() # Should show what was checked
453 |
454 |
455 | class TestRustAnalyzerDetectionIntegration:
456 | """
457 | Integration tests that verify detection works on the current system.
458 | These tests are skipped if rust-analyzer is not installed.
459 | """
460 |
461 | @pytest.mark.rust
462 | def test_detection_finds_installed_rust_analyzer(self):
463 | """
464 | GIVEN rust-analyzer is installed on this system (via any method)
465 | WHEN _ensure_rust_analyzer_installed is called
466 | THEN it should return a valid path
467 |
468 | This test verifies the detection logic works end-to-end on the current system.
469 | """
470 | import shutil
471 |
472 | from solidlsp.language_servers.rust_analyzer import RustAnalyzer
473 |
474 | # Skip if rust-analyzer is not installed at all
475 | if not shutil.which("rust-analyzer"):
476 | # Check common locations
477 | common_paths = [
478 | "/opt/homebrew/bin/rust-analyzer",
479 | "/usr/local/bin/rust-analyzer",
480 | os.path.expanduser("~/.cargo/bin/rust-analyzer"),
481 | ]
482 | if not any(os.path.isfile(p) and os.access(p, os.X_OK) for p in common_paths):
483 | pytest.skip("rust-analyzer not installed on this system")
484 |
485 | result = RustAnalyzer._ensure_rust_analyzer_installed()
486 |
487 | assert result is not None
488 | assert os.path.isfile(result)
489 | assert os.access(result, os.X_OK)
490 |
```
--------------------------------------------------------------------------------
/src/serena/resources/dashboard/dashboard.css:
--------------------------------------------------------------------------------
```css
1 | html {
2 | scrollbar-gutter: stable; /* Prevent layout shift when scrollbar appears */
3 | }
4 |
5 | :root {
6 | /* Light theme variables */
7 | --bg-primary: #f5f5f5;
8 | --bg-secondary: #ffffff;
9 | --text-primary: #000000;
10 | --text-secondary: #333333;
11 | --text-muted: #666666;
12 | --border-color: #ddd;
13 | --btn-primary: #eaa45d;
14 | --btn-hover: #dca662;
15 | --btn-disabled: #6c757d;
16 | --shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
17 | --tool-highlight: #ffff00;
18 | --tool-highlight-text: #000000;
19 | --log-debug: #808080;
20 | --log-info: #000000;
21 | --log-warning: #FF8C00;
22 | --log-error: #FF0000;
23 | --stats-header: #f8f9fa;
24 | --header-height: 150px;
25 | --header-padding: 20px;
26 | --header-gap-main: 25px;
27 | --frame-padding: 25px;
28 | --border-radius: 5px;
29 | }
30 |
31 | [data-theme="dark"] {
32 | /* Dark theme variables */
33 | --bg-primary: #1a1a1a;
34 | --bg-secondary: #2d2d2d;
35 | --text-primary: #ffffff;
36 | --text-secondary: #e0e0e0;
37 | --text-muted: #b0b0b0;
38 | --border-color: #444;
39 | --btn-primary: #eaa45d;
40 | --btn-hover: #dca662;
41 | --btn-disabled: #6c757d;
42 | --shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
43 | --tool-highlight: #ffd700;
44 | --tool-highlight-text: #000000;
45 | --log-debug: #808080;
46 | --log-info: #ffffff;
47 | --log-warning: #FF8C00;
48 | --log-error: #FF0000;
49 | --stats-header: #3a3a3a;
50 | }
51 |
52 | body {
53 | font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
54 | margin: 0;
55 | background-color: var(--bg-primary);
56 | color: var(--text-primary);
57 | transition: background-color 0.3s ease, color 0.3s ease;
58 | }
59 |
60 | #frame {
61 | max-width: 1600px;
62 | margin: 0 auto;
63 | padding: var(--frame-padding);
64 | padding-top: 0;
65 | min-width: 1280px;
66 | }
67 |
68 | .main {
69 | padding-top: var(--header-gap-main);
70 | }
71 |
72 | .header {
73 | top: 0;
74 | left: 0;
75 | right: 0;
76 | height: var(--header-height);
77 | background-color: var(--bg-secondary);
78 | border-bottom: 1px solid var(--border-color);
79 | border-bottom-left-radius: var(--border-radius);
80 | border-bottom-right-radius: var(--border-radius);
81 | padding: var(--header-padding);
82 | display: flex;
83 | justify-content: space-between;
84 | align-items: center;
85 | gap: 20px;
86 | z-index: 1000;
87 | transition: background-color 0.3s ease, border-color 0.3s ease;
88 | min-height: 90px;
89 | box-shadow: var(--shadow);
90 | max-width: 1600px;
91 | margin: 0 auto;
92 | }
93 |
94 | .header-left {
95 | display: flex;
96 | gap: 30px;
97 | }
98 |
99 | .logo-container {
100 | height: var(--header-height);
101 | order: 1;
102 | flex-shrink: 0;
103 | }
104 |
105 | .logo-container img {
106 | height: calc(var(--header-height) - 20px);
107 | margin-top: 10px;
108 | display: block;
109 | }
110 |
111 | .header-banner {
112 | position: relative;
113 | top: 0;
114 | left: 0;
115 | order: 2;
116 | max-height: var(--header-height);
117 | }
118 |
119 | .header-nav {
120 | position: relative;
121 | display: flex;
122 | align-items: flex-end;
123 | width: 200px;
124 | height: 100%;
125 | gap: 10px;
126 | flex-shrink: 0;
127 | }
128 |
129 | .menu-button {
130 | background-color: var(--bg-secondary);
131 | color: var(--text-primary);
132 | border: 1px solid var(--border-color);
133 | padding: 8px 16px;
134 | border-radius: 4px;
135 | cursor: pointer;
136 | font-size: 16px;
137 | transition: background-color 0.3s ease, border-color 0.3s ease;
138 | display: flex;
139 | align-items: center;
140 | gap: 8px;
141 | }
142 |
143 | .menu-button:hover {
144 | background-color: var(--border-color);
145 | }
146 |
147 | .menu-dropdown {
148 | position: absolute;
149 | top: calc(var(--header-height) + 10px);
150 | right: 0;
151 | background-color: var(--bg-secondary);
152 | border: 1px solid var(--border-color);
153 | border-radius: 4px;
154 | box-shadow: var(--shadow);
155 | min-width: 200px;
156 | z-index: 999;
157 | transition: background-color 0.3s ease, border-color 0.3s ease;
158 | }
159 |
160 | .menu-dropdown a {
161 | display: block;
162 | padding: 12px 20px;
163 | color: var(--text-primary);
164 | text-decoration: none;
165 | transition: background-color 0.3s ease;
166 | }
167 |
168 | .menu-dropdown a:hover {
169 | background-color: var(--border-color);
170 | }
171 |
172 | .menu-dropdown a.active {
173 | background-color: var(--btn-primary);
174 | color: white;
175 | }
176 |
177 | .menu-dropdown hr {
178 | border: none;
179 | border-top: 1px solid var(--border-color);
180 | margin: 5px 0;
181 | }
182 |
183 | .platinum-banner-slide {
184 | position: absolute;
185 | top: 0;
186 | left: 0;
187 | pointer-events: none;
188 | height: 100%;
189 | opacity: 0;
190 | transition: opacity 0.5s ease-in-out;
191 | }
192 |
193 | .platinum-banner-slide.active {
194 | opacity: 1;
195 | pointer-events: auto;
196 | }
197 |
198 | .banner-image {
199 | object-fit: contain;
200 | border-radius: var(--border-radius);
201 | }
202 |
203 | .banner-border {
204 | border: 1px solid var(--border-color);
205 | }
206 |
207 | .platinum-banner-slide .banner-image {
208 | max-height: 100%;
209 | object-fit: contain;
210 | }
211 |
212 | .gold-banners-section {
213 | margin: 0 auto;
214 | width: 100%;
215 | position: relative;
216 | align-items: center;
217 | justify-content: center;
218 | padding: 0;
219 | }
220 |
221 | .gold-banner {
222 | position: relative;
223 | width: 100%;
224 | height: 60px;
225 | overflow: hidden;
226 | }
227 |
228 | .gold-banner-slide {
229 | position: absolute;
230 | pointer-events: none;
231 | opacity: 0;
232 | transition: opacity 0.5s ease-in-out;
233 | }
234 |
235 | .gold-banner-slide.active {
236 | opacity: 1;
237 | pointer-events: auto;
238 | }
239 |
240 | .gold-banner-slide .banner-image {
241 | max-width: 100%;
242 | object-fit: contain;
243 | }
244 |
245 | .page-view {
246 | /*max-width: 1600px;*/
247 | margin: 0 auto;
248 | }
249 |
250 | /* Overview Page Layout */
251 | .overview-container {
252 | display: grid;
253 | grid-template-columns: 1fr 400px;
254 | gap: 20px;
255 | }
256 |
257 | .overview-left {
258 | min-width: 0;
259 | }
260 |
261 | .overview-right {
262 | min-width: 0;
263 | }
264 |
265 | /* Overview Page Styles */
266 | .config-section,
267 | .basic-stats-section,
268 | .projects-section {
269 | background-color: var(--bg-secondary);
270 | border: 1px solid var(--border-color);
271 | border-radius: var(--border-radius);
272 | padding: 20px;
273 | margin-bottom: 20px;
274 | transition: background-color 0.3s ease, border-color 0.3s ease;
275 | }
276 |
277 | .config-section h2,
278 | .basic-stats-section h2 {
279 | margin-top: 0;
280 | color: var(--text-secondary);
281 | }
282 |
283 | /* Collapsible Headers */
284 | .collapsible-header {
285 | margin-top: 0;
286 | margin-bottom: 0;
287 | font-size: 18px;
288 | color: var(--text-secondary);
289 | cursor: pointer;
290 | user-select: none;
291 | display: flex;
292 | justify-content: space-between;
293 | align-items: center;
294 | }
295 |
296 | .collapsible-header:hover {
297 | color: var(--text-primary);
298 | }
299 |
300 | .toggle-icon {
301 | transition: transform 0.3s ease;
302 | font-size: 14px;
303 | }
304 |
305 | .toggle-icon.expanded {
306 | transform: rotate(-180deg);
307 | }
308 |
309 | .collapsible-content {
310 | margin-top: 15px;
311 | max-height: 400px;
312 | overflow-y: auto;
313 | }
314 |
315 | .config-grid {
316 | display: grid;
317 | grid-template-columns: 180px 1fr;
318 | gap: 12px;
319 | margin-bottom: 20px;
320 | }
321 |
322 | .config-label {
323 | font-weight: bold;
324 | color: var(--text-secondary);
325 | }
326 |
327 | .config-value {
328 | color: var(--text-primary);
329 | }
330 |
331 | .config-list {
332 | list-style: none;
333 | padding: 0;
334 | margin: 0;
335 | }
336 |
337 | .config-list li {
338 | padding: 4px 0;
339 | }
340 |
341 | .tools-grid {
342 | display: grid;
343 | grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
344 | gap: 8px;
345 | margin-top: 10px;
346 | }
347 |
348 | .tool-item {
349 | background-color: var(--bg-primary);
350 | padding: 6px 10px;
351 | border-radius: 3px;
352 | font-size: 13px;
353 | color: var(--text-primary);
354 | overflow: hidden;
355 | text-overflow: ellipsis;
356 | white-space: nowrap;
357 | cursor: default;
358 | }
359 |
360 | /* Projects List */
361 | .project-item {
362 | padding: 10px 12px;
363 | margin: 5px 0;
364 | border-radius: 4px;
365 | background-color: var(--bg-primary);
366 | border: 1px solid var(--border-color);
367 | transition: background-color 0.2s ease;
368 | }
369 |
370 | .project-item:hover {
371 | background-color: var(--border-color);
372 | }
373 |
374 | .project-item.active {
375 | background-color: var(--btn-primary);
376 | color: white;
377 | border-color: var(--btn-primary);
378 | }
379 |
380 | .project-name {
381 | font-weight: bold;
382 | margin-bottom: 4px;
383 | overflow: hidden;
384 | text-overflow: ellipsis;
385 | white-space: nowrap;
386 | }
387 |
388 | .project-path {
389 | font-size: 11px;
390 | color: var(--text-muted);
391 | overflow: hidden;
392 | text-overflow: ellipsis;
393 | white-space: nowrap;
394 | }
395 |
396 | .project-item.active .project-path {
397 | color: rgba(255, 255, 255, 0.8);
398 | }
399 |
400 | /* Generic Item Styles for Tools/Modes/Contexts */
401 | .info-item {
402 | padding: 8px 12px;
403 | margin: 5px 0;
404 | border-radius: 4px;
405 | background-color: var(--bg-primary);
406 | border: 1px solid var(--border-color);
407 | transition: background-color 0.2s ease;
408 | overflow: hidden;
409 | text-overflow: ellipsis;
410 | white-space: nowrap;
411 | cursor: default;
412 | }
413 |
414 | .info-item:hover {
415 | background-color: var(--border-color);
416 | }
417 |
418 | .info-item.active {
419 | background-color: var(--btn-primary);
420 | color: white;
421 | border-color: var(--btn-primary);
422 | font-weight: bold;
423 | }
424 |
425 | /* Basic Stats Styles */
426 | .basic-stats-section {
427 | background-color: var(--bg-secondary);
428 | border: 1px solid var(--border-color);
429 | border-radius: 5px;
430 | padding: 20px;
431 | margin-bottom: 20px;
432 | transition: background-color 0.3s ease, border-color 0.3s ease;
433 | }
434 |
435 | .basic-stats-section h2 {
436 | margin-top: 0;
437 | color: var(--text-secondary);
438 | }
439 |
440 | /* Executions Styles */
441 | .executions-section {
442 | background-color: var(--bg-secondary);
443 | border: 1px solid var(--border-color);
444 | border-radius: 5px;
445 | padding: 20px;
446 | margin-bottom: 20px;
447 | transition: background-color 0.3s ease, border-color 0.3s ease;
448 | }
449 |
450 | .executions-section h2 {
451 | margin-top: 0;
452 | color: var(--text-secondary);
453 | }
454 |
455 | .execution-list {
456 | display: flex;
457 | flex-direction: column;
458 | gap: 8px;
459 | }
460 |
461 | .execution-item {
462 | display: flex;
463 | align-items: center;
464 | gap: 10px;
465 | background-color: var(--bg-primary);
466 | border: 1px solid var(--border-color);
467 | border-radius: 20px;
468 | padding: 8px 12px;
469 | min-height: 40px;
470 | transition: background-color 0.2s ease, border-color 0.2s ease;
471 | }
472 |
473 | .execution-item.running {
474 | border-color: var(--btn-primary);
475 | background: linear-gradient(to right, rgba(234, 164, 93, 0.1), var(--bg-primary));
476 | }
477 |
478 | .execution-item.cancelled {
479 | border-color: var(--text-muted);
480 | background-color: var(--bg-primary);
481 | opacity: 0.7;
482 | }
483 |
484 | .execution-item.abandoned {
485 | border-color: var(--log-error);
486 | background: linear-gradient(to right, rgba(255, 0, 0, 0.1), var(--bg-primary));
487 | }
488 |
489 | .execution-spinner {
490 | width: 16px;
491 | height: 16px;
492 | border: 2px solid var(--border-color);
493 | border-top-color: var(--btn-primary);
494 | border-radius: 50%;
495 | animation: spin 0.7s linear infinite;
496 | flex-shrink: 0;
497 | }
498 |
499 | @keyframes spin {
500 | to { transform: rotate(360deg); }
501 | }
502 |
503 | .execution-name {
504 | flex: 1;
505 | font-size: 13px;
506 | white-space: nowrap;
507 | overflow: hidden;
508 | text-overflow: ellipsis;
509 | color: var(--text-primary);
510 | }
511 |
512 | .execution-meta {
513 | font-size: 11px;
514 | color: var(--text-muted);
515 | flex-shrink: 0;
516 | }
517 |
518 | .execution-cancel-btn {
519 | background: none;
520 | border: none;
521 | color: var(--text-muted);
522 | font-size: 16px;
523 | cursor: pointer;
524 | width: 24px;
525 | height: 24px;
526 | display: flex;
527 | align-items: center;
528 | justify-content: center;
529 | border-radius: 50%;
530 | transition: background-color 0.15s ease, color 0.15s ease;
531 | flex-shrink: 0;
532 | }
533 |
534 | .execution-cancel-btn:hover {
535 | background-color: rgba(255, 0, 0, 0.1);
536 | color: var(--log-error);
537 | }
538 |
539 | .execution-icon {
540 | width: 16px;
541 | height: 16px;
542 | border-radius: 50%;
543 | display: flex;
544 | align-items: center;
545 | justify-content: center;
546 | font-size: 10px;
547 | flex-shrink: 0;
548 | }
549 |
550 | .execution-icon.success {
551 | background-color: rgba(34, 197, 94, 0.2);
552 | border: 1px solid rgba(34, 197, 94, 0.6);
553 | color: #22c55e;
554 | }
555 |
556 | .execution-icon.cancelled {
557 | background-color: var(--border-color);
558 | border: 1px solid var(--text-muted);
559 | color: var(--text-muted);
560 | }
561 |
562 | .execution-icon.abandoned {
563 | background-color: rgba(255, 0, 0, 0.2);
564 | border: 1px solid var(--log-error);
565 | color: var(--log-error);
566 | }
567 |
568 | .execution-icon.error {
569 | background-color: rgba(255, 0, 0, 0.2);
570 | border: 1px solid var(--log-error);
571 | color: var(--log-error);
572 | }
573 |
574 | .last-execution-container {
575 | display: flex;
576 | align-items: center;
577 | gap: 12px;
578 | background: linear-gradient(to right, rgba(34, 197, 94, 0.08), transparent);
579 | border: 1px solid rgba(34, 197, 94, 0.2);
580 | border-radius: 8px;
581 | padding: 12px;
582 | }
583 |
584 | .last-execution-container.error {
585 | background: linear-gradient(to right, rgba(255, 0, 0, 0.08), transparent);
586 | border-color: rgba(255, 0, 0, 0.2);
587 | }
588 |
589 | .last-execution-icon-container {
590 | width: 28px;
591 | height: 28px;
592 | background-color: rgba(34, 197, 94, 0.2);
593 | border: 1px solid rgba(34, 197, 94, 0.6);
594 | border-radius: 50%;
595 | display: flex;
596 | align-items: center;
597 | justify-content: center;
598 | font-size: 14px;
599 | color: #22c55e;
600 | flex-shrink: 0;
601 | }
602 |
603 | .last-execution-container.error .last-execution-icon-container {
604 | background-color: rgba(255, 0, 0, 0.2);
605 | border-color: var(--log-error);
606 | color: var(--log-error);
607 | }
608 |
609 | .last-execution-body {
610 | flex: 1;
611 | }
612 |
613 | .last-execution-status {
614 | font-size: 11px;
615 | color: var(--text-muted);
616 | margin-bottom: 2px;
617 | }
618 |
619 | .last-execution-name {
620 | font-size: 13px;
621 | color: var(--text-primary);
622 | }
623 |
624 | .stat-bar-container {
625 | display: flex;
626 | align-items: center;
627 | margin: 8px 0;
628 | gap: 12px;
629 | }
630 |
631 | .stat-tool-name {
632 | min-width: 200px;
633 | max-width: 200px;
634 | font-weight: bold;
635 | color: var(--text-secondary);
636 | overflow: hidden;
637 | text-overflow: ellipsis;
638 | white-space: nowrap;
639 | cursor: default;
640 | }
641 |
642 | .bar-wrapper {
643 | flex: 1;
644 | height: 24px;
645 | background-color: var(--border-color);
646 | border-radius: 3px;
647 | overflow: hidden;
648 | position: relative;
649 | }
650 |
651 | .bar {
652 | height: 100%;
653 | background-color: var(--btn-primary);
654 | transition: width 0.5s ease;
655 | border-radius: 3px;
656 | }
657 |
658 | .stat-count {
659 | min-width: 60px;
660 | text-align: right;
661 | font-weight: bold;
662 | color: var(--text-primary);
663 | }
664 |
665 | .no-stats-message {
666 | text-align: center;
667 | color: var(--text-muted);
668 | font-style: italic;
669 | padding: 20px;
670 | }
671 |
672 | /* Log Container Styles */
673 | .log-container {
674 | background-color: var(--bg-secondary);
675 | border: 1px solid var(--border-color);
676 | border-radius: 5px;
677 | height: calc(100vh - var(--header-height) - 2 * var(--header-padding) - 3 * var(--header-gap-main));
678 | max-height: 700px;
679 | overflow-y: auto;
680 | overflow-x: auto;
681 | padding: 10px;
682 | white-space: pre-wrap;
683 | font-size: 12px;
684 | line-height: 1.4;
685 | color: var(--text-primary);
686 | transition: background-color 0.3s ease, border-color 0.3s ease, color 0.3s ease;
687 | }
688 |
689 | .controls {
690 | position: sticky;
691 | top: 90px;
692 | z-index: 100;
693 | background-color: var(--bg-primary);
694 | padding: 10px 0;
695 | margin-bottom: 10px;
696 | text-align: center;
697 | display: flex;
698 | justify-content: center;
699 | align-items: center;
700 | gap: 10px;
701 | flex-wrap: wrap;
702 | transition: background-color 0.3s ease;
703 | }
704 |
705 | .btn {
706 | background-color: var(--btn-primary);
707 | color: white;
708 | border: none;
709 | padding: 8px 16px;
710 | border-radius: 4px;
711 | cursor: pointer;
712 | font-size: 14px;
713 | transition: background-color 0.3s ease;
714 | }
715 |
716 | .btn:hover {
717 | background-color: var(--btn-hover);
718 | }
719 |
720 | .btn:disabled {
721 | background-color: var(--btn-disabled);
722 | cursor: not-allowed;
723 | }
724 |
725 | .theme-toggle {
726 | display: flex;
727 | align-items: center;
728 | gap: 5px;
729 | background-color: var(--bg-secondary);
730 | color: var(--text-primary);
731 | border: 1px solid var(--border-color);
732 | border-radius: 4px;
733 | padding: 8px 16px;
734 | cursor: pointer;
735 | font-size: 16px;
736 | transition: background-color 0.3s ease, border-color 0.3s ease;
737 | }
738 |
739 | .theme-toggle:hover {
740 | background-color: var(--border-color);
741 | }
742 |
743 | .theme-toggle span {
744 | line-height: 1;
745 | }
746 |
747 | .log-debug {
748 | color: var(--log-debug);
749 | }
750 |
751 | .log-info {
752 | color: var(--log-info);
753 | }
754 |
755 | .log-warning {
756 | color: var(--log-warning);
757 | }
758 |
759 | .log-error {
760 | color: var(--log-error);
761 | }
762 |
763 | .log-default {
764 | color: var(--log-info);
765 | }
766 |
767 | /* Tool name highlighting */
768 | .tool-name {
769 | background-color: var(--tool-highlight);
770 | color: var(--tool-highlight-text);
771 | font-weight: bold;
772 | }
773 |
774 | .loading {
775 | text-align: center;
776 | color: var(--text-muted);
777 | font-style: italic;
778 | }
779 |
780 | .error-message {
781 | color: var(--log-error);
782 | text-align: center;
783 | margin: 10px 0;
784 | }
785 |
786 | /* Advanced Stats Styles */
787 | .charts-container {
788 | display: flex;
789 | flex-wrap: wrap;
790 | gap: 15px;
791 | justify-content: space-between;
792 | max-width: 1400px;
793 | margin: 0 auto;
794 | }
795 |
796 | .chart-group {
797 | flex: 1;
798 | min-width: 280px;
799 | max-width: 320px;
800 | text-align: center;
801 | }
802 |
803 | .chart-wide {
804 | flex: 0 0 100%;
805 | min-width: 100%;
806 | margin-top: 10px;
807 | }
808 |
809 | .chart-group h3 {
810 | margin: 0 0 10px 0;
811 | color: var(--text-secondary);
812 | }
813 |
814 | .stats-summary {
815 | margin: 0 auto;
816 | border-collapse: collapse;
817 | background: var(--bg-secondary);
818 | border-radius: 5px;
819 | overflow: hidden;
820 | box-shadow: var(--shadow);
821 | transition: background-color 0.3s ease, box-shadow 0.3s ease;
822 | }
823 |
824 | .stats-summary th,
825 | .stats-summary td {
826 | padding: 10px 20px;
827 | text-align: left;
828 | border-bottom: 1px solid var(--border-color);
829 | color: var(--text-primary);
830 | transition: border-color 0.3s ease, color 0.3s ease;
831 | }
832 |
833 | .stats-summary th {
834 | background-color: var(--stats-header);
835 | font-weight: bold;
836 | transition: background-color 0.3s ease;
837 | }
838 |
839 | .stats-summary tr:last-child td {
840 | border-bottom: none;
841 | }
842 |
843 | @media (max-width: 1024px) {
844 | .overview-container {
845 | grid-template-columns: 1fr;
846 | }
847 |
848 | .overview-right {
849 | order: -1;
850 | }
851 | }
852 |
853 | @media (max-width: 768px) {
854 | body {
855 | padding-top: 140px;
856 | }
857 |
858 | .header {
859 | flex-direction: column;
860 | gap: 10px;
861 | padding: 10px 15px;
862 | }
863 |
864 | .logo-container {
865 | width: 100%;
866 | text-align: center;
867 | }
868 |
869 | .logo-container img {
870 | max-width: 200px;
871 | }
872 |
873 | .header-nav {
874 | width: 100%;
875 | justify-content: center;
876 | }
877 |
878 | .charts-container {
879 | flex-direction: column;
880 | }
881 |
882 | .chart-group,
883 | .chart-wide {
884 | min-width: auto;
885 | max-width: none;
886 | }
887 |
888 | .controls {
889 | flex-direction: column;
890 | gap: 5px;
891 | }
892 |
893 | .config-grid {
894 | grid-template-columns: 1fr;
895 | }
896 |
897 | .tools-grid {
898 | grid-template-columns: 1fr;
899 | }
900 |
901 | .stat-bar-container {
902 | flex-wrap: wrap;
903 | }
904 |
905 | .stat-tool-name {
906 | min-width: 100%;
907 | }
908 | }
909 |
910 | /* Modal Styles */
911 | .modal {
912 | position: fixed;
913 | z-index: 1000;
914 | left: 0;
915 | top: 0;
916 | width: 100%;
917 | height: 100%;
918 | overflow: auto;
919 | background-color: rgba(0, 0, 0, 0.5);
920 | }
921 |
922 | .modal-content {
923 | background-color: var(--bg-primary);
924 | margin: 10% auto;
925 | padding: 25px;
926 | border: 1px solid var(--border-color);
927 | border-radius: 8px;
928 | width: 90%;
929 | max-width: 500px;
930 | box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
931 | position: relative;
932 | }
933 |
934 | .modal-close {
935 | color: var(--text-muted);
936 | float: right;
937 | font-size: 28px;
938 | font-weight: bold;
939 | line-height: 20px;
940 | cursor: pointer;
941 | transition: color 0.2s;
942 | }
943 |
944 | .modal-close:hover,
945 | .modal-close:focus {
946 | color: var(--text-primary);
947 | }
948 |
949 | .modal h3 {
950 | margin-top: 0;
951 | margin-bottom: 15px;
952 | color: var(--text-primary);
953 | }
954 |
955 | /* Language Badge Styles */
956 | .languages-container {
957 | display: flex;
958 | flex-wrap: wrap;
959 | gap: 8px;
960 | }
961 |
962 | .language-badge {
963 | position: relative;
964 | display: inline-flex;
965 | align-items: center;
966 | padding: 6px 12px;
967 | background: var(--bg-secondary);
968 | border: 1px solid var(--border-color);
969 | border-radius: 6px;
970 | color: var(--text-primary);
971 | font-size: 13px;
972 | font-weight: 500;
973 | }
974 |
975 | .language-badge.removable {
976 | padding-right: 28px;
977 | }
978 |
979 | .language-remove {
980 | position: absolute;
981 | top: 2px;
982 | right: 2px;
983 | width: 18px;
984 | height: 18px;
985 | display: flex;
986 | align-items: center;
987 | justify-content: center;
988 | background: rgba(255, 68, 68, 0.1);
989 | border-radius: 3px;
990 | cursor: pointer;
991 | color: #ff4444;
992 | font-size: 14px;
993 | font-weight: bold;
994 | line-height: 1;
995 | transition: all 0.2s;
996 | }
997 |
998 | .language-remove:hover {
999 | background: rgba(255, 68, 68, 0.2);
1000 | transform: scale(1.1);
1001 | }
1002 |
1003 | .language-add-btn {
1004 | padding: 6px 12px;
1005 | font-size: 13px;
1006 | font-weight: 500;
1007 | border-radius: 6px;
1008 | border: 1px dashed var(--border-color);
1009 | background: var(--bg-secondary);
1010 | color: var(--text-primary);
1011 | cursor: pointer;
1012 | transition: all 0.2s;
1013 | }
1014 |
1015 | .language-add-btn:hover {
1016 | background: var(--border-color);
1017 | border-color: var(--btn-primary);
1018 | color: var(--btn-primary);
1019 | }
1020 |
1021 | .memory-add-btn {
1022 | display: inline-flex;
1023 | align-items: center;
1024 | padding: 8px 12px;
1025 | margin: 5px;
1026 | border-radius: 4px;
1027 | border: 1px dashed var(--border-color);
1028 | background: var(--bg-secondary);
1029 | color: var(--text-primary);
1030 | cursor: pointer;
1031 | transition: all 0.2s;
1032 | font-family: inherit;
1033 | font-size: inherit;
1034 | font-weight: inherit;
1035 | line-height: inherit;
1036 | }
1037 |
1038 | .memory-add-btn:hover {
1039 | background: var(--border-color);
1040 | border-color: var(--btn-primary);
1041 | color: var(--btn-primary);
1042 | }
1043 |
1044 | .language-spinner {
1045 | display: inline-flex;
1046 | align-items: center;
1047 | justify-content: center;
1048 | padding: 6px 12px;
1049 | }
1050 |
1051 | .spinner {
1052 | width: 16px;
1053 | height: 16px;
1054 | border: 2px solid var(--border-color);
1055 | border-top-color: var(--btn-primary);
1056 | border-radius: 50%;
1057 | animation: spin 0.8s linear infinite;
1058 | }
1059 |
1060 | @keyframes spin {
1061 | to {
1062 | transform: rotate(360deg);
1063 | }
1064 | }
1065 |
1066 | /* Memory Editor Styles */
1067 | .modal-content-large {
1068 | max-width: 800px;
1069 | width: 90%;
1070 | }
1071 |
1072 | .memory-editor {
1073 | font-family: 'Courier New', monospace;
1074 | font-size: 13px;
1075 | line-height: 1.5;
1076 | tab-size: 4;
1077 | -moz-tab-size: 4;
1078 | }
1079 |
1080 | .memory-editor:focus {
1081 | outline: 2px solid var(--btn-primary);
1082 | outline-offset: -1px;
1083 | }
1084 |
1085 | /* Memory Item Styles */
1086 | .memory-item {
1087 | position: relative;
1088 | display: inline-flex;
1089 | align-items: center;
1090 | padding: 8px 12px;
1091 | margin: 5px;
1092 | border-radius: 4px;
1093 | background-color: var(--bg-primary);
1094 | border: 1px solid var(--border-color);
1095 | transition: background-color 0.2s ease;
1096 | cursor: pointer;
1097 | overflow: hidden;
1098 | text-overflow: ellipsis;
1099 | white-space: nowrap;
1100 | }
1101 |
1102 | .memory-item:hover {
1103 | background-color: var(--border-color);
1104 | text-decoration: underline;
1105 | }
1106 |
1107 | .memory-item.removable {
1108 | padding-right: 28px;
1109 | }
1110 |
1111 | .memory-remove {
1112 | position: absolute;
1113 | top: 2px;
1114 | right: 2px;
1115 | width: 18px;
1116 | height: 18px;
1117 | display: flex;
1118 | align-items: center;
1119 | justify-content: center;
1120 | background: rgba(255, 68, 68, 0.1);
1121 | border-radius: 3px;
1122 | cursor: pointer;
1123 | color: #ff4444;
1124 | font-size: 14px;
1125 | font-weight: bold;
1126 | line-height: 1;
1127 | transition: all 0.2s;
1128 | }
1129 |
1130 | .memory-remove:hover {
1131 | background: rgba(255, 68, 68, 0.2);
1132 | transform: scale(1.1);
1133 | text-decoration: none;
1134 | }
1135 |
1136 | .memories-container {
1137 | display: flex;
1138 | flex-wrap: wrap;
1139 | gap: 8px;
1140 | }
1141 |
1142 | /* Copy Logs Button */
1143 | .copy-logs-btn {
1144 | position: absolute;
1145 | top: 15px;
1146 | right: 15px;
1147 | z-index: 10;
1148 | display: flex;
1149 | align-items: center;
1150 | gap: 6px;
1151 | padding: 8px 12px;
1152 | background-color: var(--bg-secondary);
1153 | border: 1px solid var(--border-color);
1154 | border-radius: 4px;
1155 | color: var(--text-primary);
1156 | cursor: pointer;
1157 | opacity: 0.8;
1158 | transition: opacity 0.2s ease, background-color 0.2s ease;
1159 | font-size: 13px;
1160 | font-weight: 500;
1161 | }
1162 |
1163 | .copy-logs-btn:hover {
1164 | opacity: 1;
1165 | background-color: var(--border-color);
1166 | }
1167 |
1168 | .copy-logs-btn svg {
1169 | flex-shrink: 0;
1170 | }
1171 |
1172 | .copy-logs-text {
1173 | display: none;
1174 | white-space: nowrap;
1175 | }
1176 |
1177 | .copy-logs-btn:hover .copy-logs-text {
1178 | display: inline;
1179 | }
1180 |
```