#
tokens: 47568/50000 6/410 files (page 13/21)
lines: on (toggle) GitHub
raw markdown copy reset
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 | 
```
Page 13/21FirstPrevNextLast