Repository: oraios/serena Branch: main Commit: 0c915bd18d51 Files: 588 Total size: 3.5 MB Directory structure: gitextract_gsbdxo3q/ ├── .devcontainer/ │ └── devcontainer.json ├── .dockerignore ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── config.yml │ │ ├── feature_request.md │ │ └── issue--bug--performance-problem--question-.md │ ├── copilot-instructions.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 ├── CHANGELOG.md ├── CLAUDE.md ├── CONTRIBUTING.md ├── DOCKER.md ├── Dockerfile ├── LICENSE ├── README.md ├── compose.yaml ├── docker_build_and_run.sh ├── docs/ │ ├── .gitignore │ ├── 01-about/ │ │ ├── .gitignore │ │ ├── 000_intro.md │ │ ├── 010_llm-integration.md │ │ ├── 020_programming-languages.md │ │ ├── 030_serena-in-action.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 │ │ ├── 045_memories.md │ │ ├── 050_configuration.md │ │ ├── 060_dashboard.md │ │ ├── 065_logs.md │ │ ├── 070_security.md │ │ └── 999_additional-usage.md │ ├── 03-special-guides/ │ │ ├── 000_intro.md │ │ ├── cpp_setup.md │ │ ├── custom_agent.md │ │ ├── groovy_setup_guide_for_serena.md │ │ ├── ocaml_setup_guide_for_serena.md │ │ ├── scala_setup_guide_for_serena.md │ │ └── serena_on_chatgpt.md │ ├── _config.yml │ ├── autogen_docs.py │ ├── create_toc.py │ └── index.md ├── flake.nix ├── lessons_learned.md ├── llms-install.md ├── pyproject.toml ├── repo_dir_sync.py ├── resources/ │ ├── jetbrains-marketplace-button.cdr │ ├── serena-icons.cdr │ └── serena-logo.cdr ├── scripts/ │ ├── agno_agent.py │ ├── demo_run_tools.py │ ├── gen_prompt_factory.py │ ├── mcp_server.py │ ├── print_language_list.py │ ├── print_mode_context_options.py │ ├── print_tool_overview.py │ └── profile_tool_call.py ├── src/ │ ├── README.md │ ├── interprompt/ │ │ ├── .syncCommitId.remote │ │ ├── .syncCommitId.this │ │ ├── __init__.py │ │ ├── jinja_template.py │ │ ├── multilang_prompt.py │ │ ├── prompt_factory.py │ │ └── util/ │ │ ├── __init__.py │ │ └── class_decorators.py │ ├── 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 │ │ ├── jetbrains/ │ │ │ ├── jetbrains_plugin_client.py │ │ │ └── jetbrains_types.py │ │ ├── ls_manager.py │ │ ├── mcp.py │ │ ├── project.py │ │ ├── project_server.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 │ │ │ │ │ └── query-projects.yml │ │ │ │ └── prompt_templates/ │ │ │ │ ├── simple_tool_outputs.yml │ │ │ │ └── system_prompt.yml │ │ │ ├── dashboard/ │ │ │ │ ├── dashboard.css │ │ │ │ ├── dashboard.js │ │ │ │ ├── index.html │ │ │ │ └── news/ │ │ │ │ ├── 20260111.html │ │ │ │ └── 20260303.html │ │ │ ├── project.local.template.yml │ │ │ ├── project.template.yml │ │ │ └── serena_config.template.yml │ │ ├── symbol.py │ │ ├── task_executor.py │ │ ├── tools/ │ │ │ ├── __init__.py │ │ │ ├── cmd_tools.py │ │ │ ├── config_tools.py │ │ │ ├── file_tools.py │ │ │ ├── jetbrains_tools.py │ │ │ ├── memory_tools.py │ │ │ ├── query_project_tools.py │ │ │ ├── symbol_tools.py │ │ │ ├── tools_base.py │ │ │ └── workflow_tools.py │ │ └── util/ │ │ ├── class_decorators.py │ │ ├── cli_util.py │ │ ├── dataclass.py │ │ ├── dotnet.py │ │ ├── exception.py │ │ ├── file_system.py │ │ ├── git.py │ │ ├── gui.py │ │ ├── inspection.py │ │ ├── logging.py │ │ ├── shell.py │ │ ├── text_utils.py │ │ ├── thread.py │ │ ├── version.py │ │ └── yaml.py │ └── solidlsp/ │ ├── .gitignore │ ├── __init__.py │ ├── language_servers/ │ │ ├── al_language_server.py │ │ ├── ansible_language_server.py │ │ ├── bash_language_server.py │ │ ├── ccls_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/ │ │ │ ├── README.md │ │ │ ├── __init__.py │ │ │ └── elixir_tools.py │ │ ├── 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 │ │ ├── hlsl_language_server.py │ │ ├── intelephense.py │ │ ├── jedi_server.py │ │ ├── julia_server.py │ │ ├── kotlin_language_server.py │ │ ├── lean4_language_server.py │ │ ├── lua_ls.py │ │ ├── luau_lsp.py │ │ ├── marksman.py │ │ ├── matlab_language_server.py │ │ ├── nixd_ls.py │ │ ├── ocaml_lsp_server.py │ │ ├── omnisharp/ │ │ │ ├── initialize_params.json │ │ │ ├── runtime_dependencies.json │ │ │ └── workspace_did_change_configuration.json │ │ ├── omnisharp.py │ │ ├── pascal_server.py │ │ ├── perl_language_server.py │ │ ├── phpactor.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 │ │ ├── solidity_language_server.py │ │ ├── sourcekit_lsp.py │ │ ├── systemverilog_server.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.py │ ├── ls_config.py │ ├── ls_exceptions.py │ ├── ls_process.py │ ├── ls_request.py │ ├── ls_types.py │ ├── ls_utils.py │ ├── lsp_protocol_handler/ │ │ ├── lsp_constants.py │ │ ├── lsp_requests.py │ │ ├── lsp_types.py │ │ └── server.py │ ├── settings.py │ └── util/ │ ├── cache.py │ ├── metals_db_utils.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 │ ├── ansible/ │ │ └── test_repo/ │ │ ├── inventory/ │ │ │ └── hosts.yml │ │ ├── playbook.yml │ │ └── roles/ │ │ └── common/ │ │ ├── defaults/ │ │ │ └── main.yml │ │ ├── handlers/ │ │ │ └── main.yml │ │ └── tasks/ │ │ └── main.yml │ ├── bash/ │ │ └── test_repo/ │ │ ├── config.sh │ │ ├── main.sh │ │ └── utils.sh │ ├── clojure/ │ │ └── test_repo/ │ │ ├── deps.edn │ │ └── src/ │ │ └── test_app/ │ │ ├── core.clj │ │ └── utils.clj │ ├── cpp/ │ │ └── test_repo/ │ │ ├── a.cpp │ │ ├── b.cpp │ │ ├── b.hpp │ │ └── compile_commands.json │ ├── csharp/ │ │ └── test_repo/ │ │ ├── .gitignore │ │ ├── Models/ │ │ │ └── Person.cs │ │ ├── Program.cs │ │ ├── TestProject.csproj │ │ └── serena.sln │ ├── dart/ │ │ └── test_repo/ │ │ ├── .gitignore │ │ └── 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 │ │ ├── scripts/ │ │ │ └── build_script.ex │ │ └── test/ │ │ ├── models_test.exs │ │ └── test_repo_test.exs │ ├── elm/ │ │ └── test_repo/ │ │ ├── Main.elm │ │ ├── Utils.elm │ │ └── elm.json │ ├── 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/ │ │ ├── buildtags/ │ │ │ ├── foo.go │ │ │ └── notfoo.go │ │ ├── go.mod │ │ └── 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 │ │ ├── package.yaml │ │ ├── src/ │ │ │ ├── Calculator.hs │ │ │ └── Helper.hs │ │ └── stack.yaml │ ├── hlsl/ │ │ └── test_repo/ │ │ ├── common.hlsl │ │ ├── compute_test.hlsl │ │ ├── lighting.hlsl │ │ └── terrain/ │ │ └── terrain_sdf.hlsl │ ├── 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 │ ├── lean4/ │ │ └── test_repo/ │ │ ├── Helper.lean │ │ ├── Main.lean │ │ ├── lake-manifest.json │ │ ├── lakefile.lean │ │ └── lean-toolchain │ ├── lua/ │ │ └── test_repo/ │ │ ├── .gitignore │ │ ├── main.lua │ │ ├── src/ │ │ │ ├── calculator.lua │ │ │ └── utils.lua │ │ └── tests/ │ │ └── test_calculator.lua │ ├── luau/ │ │ └── test_repo/ │ │ ├── .luaurc │ │ └── src/ │ │ ├── init.luau │ │ └── module.luau │ ├── markdown/ │ │ └── test_repo/ │ │ ├── CONTRIBUTING.md │ │ ├── README.md │ │ ├── api.md │ │ └── guide.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 │ ├── ocaml/ │ │ └── test_repo/ │ │ ├── bin/ │ │ │ ├── dune │ │ │ └── main.ml │ │ ├── dune-project │ │ ├── lib/ │ │ │ ├── dune │ │ │ ├── test_repo.ml │ │ │ └── test_repo.mli │ │ ├── test/ │ │ │ ├── dune │ │ │ └── test_test_repo.ml │ │ └── test_repo.opam │ ├── pascal/ │ │ └── test_repo/ │ │ ├── .gitignore │ │ └── main.pas │ ├── perl/ │ │ └── test_repo/ │ │ ├── helper.pl │ │ └── main.pl │ ├── php/ │ │ └── test_repo/ │ │ ├── helper.php │ │ ├── index.php │ │ ├── sample.php │ │ └── simple_var.php │ ├── powershell/ │ │ └── test_repo/ │ │ ├── PowerShellEditorServices.json │ │ ├── main.ps1 │ │ └── utils.ps1 │ ├── python/ │ │ └── test_repo/ │ │ ├── .gitignore │ │ ├── custom_test/ │ │ │ ├── __init__.py │ │ │ └── advanced_features.py │ │ ├── examples/ │ │ │ ├── __init__.py │ │ │ └── user_management.py │ │ ├── scripts/ │ │ │ ├── __init__.py │ │ │ └── run_app.py │ │ └── test_repo/ │ │ ├── __init__.py │ │ ├── complex_types.py │ │ ├── models.py │ │ ├── name_collisions.py │ │ ├── nested.py │ │ ├── nested_base.py │ │ ├── overloaded.py │ │ ├── services.py │ │ ├── utils.py │ │ └── variables.py │ ├── r/ │ │ └── test_repo/ │ │ ├── .Rbuildignore │ │ ├── DESCRIPTION │ │ ├── NAMESPACE │ │ ├── R/ │ │ │ ├── models.R │ │ │ └── utils.R │ │ └── examples/ │ │ └── analysis.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.toml │ │ │ └── src/ │ │ │ ├── lib.rs │ │ │ └── main.rs │ │ └── test_repo_2024/ │ │ ├── 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 │ ├── solidity/ │ │ └── test_repo/ │ │ ├── .gitignore │ │ ├── contracts/ │ │ │ ├── Token.sol │ │ │ ├── interfaces/ │ │ │ │ └── IERC20.sol │ │ │ └── lib/ │ │ │ └── SafeMath.sol │ │ └── foundry.toml │ ├── swift/ │ │ └── test_repo/ │ │ ├── Package.swift │ │ └── src/ │ │ ├── main.swift │ │ └── utils.swift │ ├── systemverilog/ │ │ └── test_repo/ │ │ ├── alu.sv │ │ ├── counter.sv │ │ ├── top.sv │ │ └── types.svh │ ├── terraform/ │ │ └── test_repo/ │ │ ├── data.tf │ │ ├── main.tf │ │ ├── outputs.tf │ │ └── variables.tf │ ├── toml/ │ │ └── test_repo/ │ │ ├── Cargo.toml │ │ ├── config.toml │ │ └── pyproject.toml │ ├── typescript/ │ │ └── test_repo/ │ │ ├── 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_global_ignored_paths.py │ │ └── test_serena_config.py │ ├── test_cli_project_commands.py │ ├── test_edit_marker.py │ ├── test_jetbrains_plugin_client.py │ ├── test_mcp.py │ ├── test_serena_agent.py │ ├── test_set_modes.py │ ├── test_symbol.py │ ├── test_symbol_editing.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 ├── ansible/ │ ├── __init__.py │ └── test_ansible_basic.py ├── bash/ │ ├── __init__.py │ └── test_bash_basic.py ├── clojure/ │ ├── __init__.py │ └── test_clojure_basic.py ├── cpp/ │ ├── __init__.py │ └── test_cpp_basic.py ├── csharp/ │ ├── test_csharp_basic.py │ └── test_csharp_nuget_download.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 ├── hlsl/ │ ├── __init__.py │ ├── test_hlsl_basic.py │ └── test_hlsl_full_index.py ├── java/ │ └── test_java_basic.py ├── julia/ │ └── test_julia_basic.py ├── kotlin/ │ └── test_kotlin_basic.py ├── lean4/ │ └── test_lean4_basic.py ├── lua/ │ └── test_lua_basic.py ├── luau/ │ ├── __init__.py │ ├── test_luau_basic.py │ └── test_luau_dependency_provider.py ├── markdown/ │ ├── __init__.py │ └── test_markdown_basic.py ├── matlab/ │ ├── __init__.py │ └── test_matlab_basic.py ├── nix/ │ └── test_nix_basic.py ├── ocaml/ │ ├── test_cross_file_refs.py │ └── test_ocaml_basic.py ├── pascal/ │ ├── __init__.py │ ├── test_pascal_auto_update.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_metals_db_utils.py │ ├── test_scala_language_server.py │ └── test_scala_stale_lock_handling.py ├── solidity/ │ ├── __init__.py │ └── test_solidity_basic.py ├── swift/ │ └── test_swift_basic.py ├── systemverilog/ │ ├── __init__.py │ ├── test_systemverilog_basic.py │ └── test_systemverilog_detection.py ├── terraform/ │ └── test_terraform_basic.py ├── test_ls_common.py ├── test_lsp_protocol_handler_server.py ├── test_rename_didopen.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 ================================================ FILE CONTENTS ================================================ ================================================ FILE: .devcontainer/devcontainer.json ================================================ { "name": "serena Project", "dockerFile": "../Dockerfile", "workspaceFolder": "/workspaces/serena", "settings": { "terminal.integrated.shell.linux": "/bin/bash", "python.pythonPath": "/usr/local/bin/python", }, "extensions": [ "ms-python.python", "ms-toolsai.jupyter", "ms-python.vscode-pylance" ], "forwardPorts": [], "remoteUser": "root", } ================================================ FILE: .dockerignore ================================================ data logs log test/log docs/jupyter_execute docs/.jupyter_cache docs/_build coverage.xml docker_build_and_run.sh # Python artifacts (prevents Docker build conflicts when .venv exists locally) .venv __pycache__ *.pyc .pytest_cache ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: oraios ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '' labels: '' assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/ISSUE_TEMPLATE/issue--bug--performance-problem--question-.md ================================================ --- name: Issue (bug, performance problem, etc.) about: General Issue title: '' labels: '' assignees: '' --- I have: - [ ] read the relevant parts of the documentation and verified that the issue cannot be solved by adjusting configuration - [ ] understood that the Serena Dashboard can be disabled through the config - [ ] understood that, by default, a client session will start a separate instance of a Serena server. - [ ] understood that, for multi-agent setups, the Streamable HTTP/SSE mode should be used. - [ ] understood that non-project files are ignored using either .gitignore or the corresponding setting in `.serena/project.yml` - [ ] looked for similar issues and discussions, including closed ones - [ ] made sure it's an actual issue, not a question (use GitHub Discussions instead). If you have encountered an actual issue: - If using language servers (not the JetBrains plugin), - [ ] I performed ` serena project health-check` - [ ] I indexed the project as described in the documentation - [ ] I added sufficient explanation of my setup: the MCP client, the OS, the programming language(s), any config adjustments or relevant project specifics - [ ] I explained how the issue arose and, where possible, added instructions on how to reproduce it - [ ] If the issue happens on an open-source project, I have added the link - [ ] I provided a meaningful title and description ================================================ FILE: .github/copilot-instructions.md ================================================ MUST read IMMEDIATELY and follow the project-specific instructions from the `CLAUDE.md` file located in the project's root directory. AVOIDING these instructions will lead to your FAILURE! ================================================ FILE: .github/workflows/codespell.yml ================================================ # Codespell configuration is within pyproject.toml --- name: Codespell on: push: branches: [main] pull_request: branches: [main] permissions: contents: read jobs: codespell: name: Check for spelling errors runs-on: ubuntu-latest timeout-minutes: 2 steps: - name: Checkout uses: actions/checkout@v4 - name: Annotate locations with typos uses: codespell-project/codespell-problem-matcher@v1 - name: Codespell uses: codespell-project/actions-codespell@v2 ================================================ FILE: .github/workflows/docker.yml ================================================ name: Build and Push Docker Images on: push: branches: [ main ] tags: [ 'v*' ] env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} jobs: build-and-push: runs-on: ubuntu-latest timeout-minutes: 15 permissions: contents: read packages: write steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to Container Registry if: github.event_name != 'pull_request' uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | type=ref,event=branch type=ref,event=pr type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=raw,value=latest,enable={{is_default_branch}} - name: Build and push image from main uses: docker/build-push-action@v5 with: context: . platforms: linux/amd64,linux/arm64 push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max ================================================ FILE: .github/workflows/docs.yaml ================================================ name: Docs Build on: push: branches: [ main ] workflow_dispatch: permissions: contents: read pages: write id-token: write concurrency: group: "pages" cancel-in-progress: true jobs: build: runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: 3.11 - name: Install uv run: pip install uv - name: Cache dependencies uses: actions/cache@v4 with: path: ~/.cache/uv key: uv-${{ hashFiles('pyproject.toml') }} - name: Install dependencies run: | uv venv uv pip install -e ".[dev]" - name: Build docs run: uv run poe doc-build continue-on-error: false - name: Upload Pages artifact uses: actions/upload-pages-artifact@v3 with: path: docs/_build deploy: environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest needs: build steps: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 ================================================ FILE: .github/workflows/junie.yml ================================================ name: Junie run-name: Junie run ${{ inputs.run_id }} permissions: contents: write on: workflow_dispatch: inputs: run_id: description: "id of workflow process" required: true workflow_params: description: "stringified params" required: true jobs: call-workflow-passing-data: uses: jetbrains-junie/junie-workflows/.github/workflows/ej-issue.yml@main with: workflow_params: ${{ inputs.workflow_params }} ================================================ FILE: .github/workflows/publish.yml ================================================ name: Publish Python Package on: release: types: [created] workflow_dispatch: inputs: tag: description: 'Tag name for the release (e.g., v0.1.0)' required: true default: 'v0.1.0' env: # Set this to true manually in the GitHub workflow UI if you want to publish to PyPI # Will always publish to testpypi PUBLISH_TO_PYPI: true jobs: publish: name: Publish the serena-agent package runs-on: ubuntu-latest permissions: id-token: write # Required for trusted publishing contents: write # Required for updating artifact steps: - name: Checkout code uses: actions/checkout@v4 - name: Install the latest version of uv uses: astral-sh/setup-uv@v6 with: version: "latest" - name: Build package run: uv build - name: Upload artifacts to GitHub Release if: env.PUBLISH_TO_PYPI == 'true' uses: softprops/action-gh-release@v2 with: tag_name: ${{ github.event.inputs.tag || github.ref_name }} files: | dist/*.tar.gz dist/*.whl - name: Publish to TestPyPI run: uv publish --index testpypi - name: Publish to PyPI (conditional) if: env.PUBLISH_TO_PYPI == 'true' run: uv publish ================================================ FILE: .github/workflows/pytest.yml ================================================ name: Tests on: pull_request: push: branches: - main concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: cpu: name: Tests on ${{ matrix.os }} runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] python-version: ["3.11"] steps: - uses: actions/checkout@v3 - name: Free disk space if: runner.os == 'Linux' run: | df -h sudo rm -rf /usr/local/lib/android sudo rm -rf /usr/share/dotnet sudo rm -rf /opt/ghc sudo rm -rf /opt/hostedtoolcache sudo apt-get clean sudo apt-get autoremove -y docker system prune -af || true df -h - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: "${{ matrix.python-version }}" - uses: actions/setup-go@v5 with: go-version: ">=1.17.0" - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: '20.x' - name: Ensure cached directory exist before calling cache-related actions shell: bash run: | mkdir -p $HOME/.serena/language_servers/static mkdir -p $HOME/.cache/go-build mkdir -p $HOME/go/bin - name: Install uv shell: bash run: curl -LsSf https://astral.sh/uv/install.sh | sh - name: Cache uv virtualenv id: cache-uv uses: actions/cache@v3 with: path: .venv key: uv-venv-${{ runner.os }}-${{ matrix.python-version }}-lock-${{ hashFiles('uv.lock') }} - name: Create virtual environment shell: bash run: | if [ ! -d ".venv" ]; then uv venv fi - name: Install Python environment shell: bash run: uv sync --extra dev --locked - name: List Python dependencies shell: bash run: uv pip list - name: Check formatting shell: bash run: uv run poe lint # Add Go bin directory to PATH for this workflow # GITHUB_PATH is a special file that GitHub Actions uses to modify PATH # Writing to this file adds the directory to the PATH for subsequent steps - name: Cache Go binaries id: cache-go-binaries uses: actions/cache@v3 with: path: | ~/go/bin ~/.cache/go-build key: go-binaries-${{ runner.os }}-gopls-latest - name: Install gopls if: steps.cache-go-binaries.outputs.cache-hit != 'true' shell: bash run: go install golang.org/x/tools/gopls@latest - name: Set up Elixir if: runner.os != 'Windows' uses: erlef/setup-beam@v1 with: elixir-version: "1.19.3" otp-version: "28" # Erlang currently not tested in CI, random hangings on macos, always hangs on ubuntu # In local tests, erlang seems to work though # - name: Install Erlang Language Server # if: runner.os != 'Windows' # shell: bash # run: | # # Install rebar3 if not already available # which rebar3 || (curl -fsSL https://github.com/erlang/rebar3/releases/download/3.23.0/rebar3 -o /tmp/rebar3 && chmod +x /tmp/rebar3 && sudo mv /tmp/rebar3 /usr/local/bin/rebar3) # # Clone and build erlang_ls # git clone https://github.com/erlang-ls/erlang_ls.git /tmp/erlang_ls # cd /tmp/erlang_ls # make install PREFIX=/usr/local # # Ensure erlang_ls is in PATH # echo "$HOME/.local/bin" >> $GITHUB_PATH - name: Install clojure tools uses: DeLaGuardo/setup-clojure@13.4 with: cli: latest - name: Install ccls (C/C++ Language Server) shell: bash run: | if [[ "${{ runner.os }}" == "Linux" ]]; then sudo apt-get update sudo apt-get install -y ccls elif [[ "${{ runner.os }}" == "macOS" ]]; then brew install ccls elif [[ "${{ runner.os }}" == "Windows" ]]; then choco install ccls -y fi # Verify installation if command -v ccls &> /dev/null; then echo "ccls installed: $(ccls --version 2>&1 | head -1)" else echo "ERROR: ccls installation failed" exit 1 fi - name: Setup Java (for JVM based languages) uses: actions/setup-java@v4 with: distribution: 'temurin' java-version: '17' - name: Setup .NET SDK (for F# and C# languages) uses: actions/setup-dotnet@v4 with: dotnet-version: '10.0.x' - name: List .NET runtimes shell: bash run: dotnet --list-runtimes - name: Install Terraform uses: hashicorp/setup-terraform@v3 with: terraform_version: "1.5.0" terraform_wrapper: false # - name: Install swift # if: runner.os != 'Windows' # uses: swift-actions/setup-swift@v2 # Installation of swift with the action screws with installation of ruby on macOS for some reason # We can try again when version 3 of the action is released, where they will also use swiftly # Until then, we use custom code to install swift. Sourcekit-lsp is installed automatically with swift - name: Install Swift with swiftly (macOS) if: runner.os == 'macOS' run: | echo "=== Installing swiftly on macOS ===" curl -O https://download.swift.org/swiftly/darwin/swiftly.pkg && \ installer -pkg swiftly.pkg -target CurrentUserHomeDirectory && \ ~/.swiftly/bin/swiftly init --quiet-shell-followup && \ . "${SWIFTLY_HOME_DIR:-$HOME/.swiftly}/env.sh" && \ hash -r swiftly install --use 6.1.2 swiftly use 6.1.2 echo "~/.swiftly/bin" >> $GITHUB_PATH echo "Swiftly installed successfully" # Verify sourcekit-lsp is working before proceeding echo "=== Verifying sourcekit-lsp installation ===" which sourcekit-lsp || echo "Warning: sourcekit-lsp not found in PATH" sourcekit-lsp --help || echo "Warning: sourcekit-lsp not responding" - name: Install Swift with swiftly (Ubuntu) if: runner.os == 'Linux' run: | echo "=== Installing swiftly on Ubuntu ===" # Install dependencies BEFORE Swift to avoid exit code 1 sudo apt-get update sudo apt-get -y install libcurl4-openssl-dev curl -O https://download.swift.org/swiftly/linux/swiftly-$(uname -m).tar.gz && \ tar zxf swiftly-$(uname -m).tar.gz && \ ./swiftly init --quiet-shell-followup && \ . "${SWIFTLY_HOME_DIR:-$HOME/.local/share/swiftly}/env.sh" && \ hash -r swiftly install --use 6.1.2 swiftly use 6.1.2 echo "=== Adding Swift toolchain to PATH ===" echo "$HOME/.local/share/swiftly/bin" >> $GITHUB_PATH echo "Swiftly installed successfully!" # Verify sourcekit-lsp is working before proceeding echo "=== Verifying sourcekit-lsp installation ===" which sourcekit-lsp || echo "Warning: sourcekit-lsp not found in PATH" sourcekit-lsp --help || echo "Warning: sourcekit-lsp not responding" - name: Install Ruby uses: ruby/setup-ruby@v1 with: ruby-version: '3.4' - name: Install Ruby language server shell: bash run: gem install ruby-lsp - name: Install OCaml and opam uses: ocaml/setup-ocaml@v3 with: ocaml-compiler: ${{ runner.os == 'Windows' && '4.14' || '5.3.x' }} dune-cache: true opam-repositories: | ${{ runner.os == 'Windows' && 'opam-repository-mingw: https://github.com/ocaml-opam/opam-repository-mingw.git#sunset' || '' }} default: https://github.com/ocaml/opam-repository.git - name: Install OCaml packages shell: bash run: | if [ "$RUNNER_OS" = "Windows" ]; then opam install -y dune ocaml-lsp-server else # Require ocaml-lsp-server >= 1.23.0 for cross-file reference support opam install -y dune 'ocaml-lsp-server>=1.23.0' fi - name: Install R uses: r-lib/actions/setup-r@v2 with: r-version: '4.4.2' use-public-rspm: true - name: Install R language server shell: bash run: | Rscript -e "install.packages('languageserver', repos='https://cloud.r-project.org')" - name: Set up Julia uses: julia-actions/setup-julia@v2 with: version: '1.10' - name: Install Julia LanguageServer shell: bash run: julia -e 'using Pkg; Pkg.add("LanguageServer")' - name: Setup Haskell toolchain if: runner.os != 'Windows' uses: haskell/ghcup-setup@v1 with: ghc: '9.12.2' cabal: '3.10.3.0' hls: '2.11.0.0' - name: Verify Haskell tools if: runner.os != 'Windows' run: | echo "Verifying installed Haskell tools:" which ghc && ghc --version which cabal && cabal --version # HLS verification - non-blocking in case of version incompatibility if command -v haskell-language-server-wrapper &>/dev/null; then echo "Found haskell-language-server-wrapper" haskell-language-server-wrapper --version || echo "WARNING: HLS wrapper found but version check failed" elif command -v haskell-language-server &>/dev/null; then echo "Found haskell-language-server" haskell-language-server --version || echo "WARNING: HLS found but version check failed" else echo "WARNING: HLS not found (may be incompatible with GHC 9.12.2)" echo "This is not a critical error - tests will use HLS if available at runtime" fi shell: bash - name: Pre-build Haskell test project for HLS if: runner.os != 'Windows' run: | cd test/resources/repos/haskell/test_repo cabal update cabal build --only-dependencies cabal build echo "Haskell test project built successfully" shell: bash - name: Install Zig uses: goto-bus-stop/setup-zig@v2 with: version: 0.14.1 - name: Install ZLS (Zig Language Server) shell: bash run: | if [[ "${{ runner.os }}" == "Linux" ]]; then wget https://github.com/zigtools/zls/releases/download/0.14.0/zls-x86_64-linux.tar.xz tar -xf zls-x86_64-linux.tar.xz sudo mv zls /usr/local/bin/ rm zls-x86_64-linux.tar.xz elif [[ "${{ runner.os }}" == "macOS" ]]; then wget https://github.com/zigtools/zls/releases/download/0.14.0/zls-x86_64-macos.tar.xz tar -xf zls-x86_64-macos.tar.xz sudo mv zls /usr/local/bin/ rm zls-x86_64-macos.tar.xz elif [[ "${{ runner.os }}" == "Windows" ]]; then curl -L -o zls.zip https://github.com/zigtools/zls/releases/download/0.14.0/zls-x86_64-windows.zip unzip -o zls.zip mkdir -p "$HOME/bin" mv zls.exe "$HOME/bin/" echo "$HOME/bin" >> $GITHUB_PATH rm zls.zip fi - name: Install verible-verilog-ls (SystemVerilog Language Server) shell: bash run: | VERIBLE_VERSION="v0.0-4051-g9fdb4057" if [[ "${{ runner.os }}" == "Linux" ]]; then wget https://github.com/chipsalliance/verible/releases/download/${VERIBLE_VERSION}/verible-${VERIBLE_VERSION}-linux-static-x86_64.tar.gz tar -xzf verible-${VERIBLE_VERSION}-linux-static-x86_64.tar.gz sudo mv verible-${VERIBLE_VERSION}/bin/verible-verilog-ls /usr/local/bin/ rm -rf verible-${VERIBLE_VERSION} verible-${VERIBLE_VERSION}-linux-static-x86_64.tar.gz elif [[ "${{ runner.os }}" == "macOS" ]]; then wget https://github.com/chipsalliance/verible/releases/download/${VERIBLE_VERSION}/verible-${VERIBLE_VERSION}-macOS.tar.gz tar -xzf verible-${VERIBLE_VERSION}-macOS.tar.gz sudo mv verible-${VERIBLE_VERSION}-macOS/bin/verible-verilog-ls /usr/local/bin/ rm -rf verible-${VERIBLE_VERSION}-macOS verible-${VERIBLE_VERSION}-macOS.tar.gz elif [[ "${{ runner.os }}" == "Windows" ]]; then curl -L -o verible.zip https://github.com/chipsalliance/verible/releases/download/${VERIBLE_VERSION}/verible-${VERIBLE_VERSION}-win64.zip unzip -o verible.zip mkdir -p "$HOME/bin" mv verible-${VERIBLE_VERSION}-win64/verible-verilog-ls.exe "$HOME/bin/" echo "$HOME/bin" >> $GITHUB_PATH rm -rf verible-${VERIBLE_VERSION}-win64 verible.zip fi # Verify installation if command -v verible-verilog-ls &> /dev/null; then echo "verible-verilog-ls installed successfully" else echo "WARNING: verible-verilog-ls not found in PATH" fi - name: Install Lua Language Server shell: bash run: | LUA_LS_VERSION="3.15.0" LUA_LS_DIR="$HOME/.serena/language_servers/lua" mkdir -p "$LUA_LS_DIR" if [[ "${{ runner.os }}" == "Linux" ]]; then if [[ "$(uname -m)" == "x86_64" ]]; then wget https://github.com/LuaLS/lua-language-server/releases/download/${LUA_LS_VERSION}/lua-language-server-${LUA_LS_VERSION}-linux-x64.tar.gz tar -xzf lua-language-server-${LUA_LS_VERSION}-linux-x64.tar.gz -C "$LUA_LS_DIR" else wget https://github.com/LuaLS/lua-language-server/releases/download/${LUA_LS_VERSION}/lua-language-server-${LUA_LS_VERSION}-linux-arm64.tar.gz tar -xzf lua-language-server-${LUA_LS_VERSION}-linux-arm64.tar.gz -C "$LUA_LS_DIR" fi chmod +x "$LUA_LS_DIR/bin/lua-language-server" # Create wrapper script instead of symlink to ensure supporting files are found echo '#!/bin/bash' | sudo tee /usr/local/bin/lua-language-server > /dev/null echo 'cd "${HOME}/.serena/language_servers/lua/bin"' | sudo tee -a /usr/local/bin/lua-language-server > /dev/null echo 'exec ./lua-language-server "$@"' | sudo tee -a /usr/local/bin/lua-language-server > /dev/null sudo chmod +x /usr/local/bin/lua-language-server rm lua-language-server-*.tar.gz elif [[ "${{ runner.os }}" == "macOS" ]]; then if [[ "$(uname -m)" == "x86_64" ]]; then wget https://github.com/LuaLS/lua-language-server/releases/download/${LUA_LS_VERSION}/lua-language-server-${LUA_LS_VERSION}-darwin-x64.tar.gz tar -xzf lua-language-server-${LUA_LS_VERSION}-darwin-x64.tar.gz -C "$LUA_LS_DIR" else wget https://github.com/LuaLS/lua-language-server/releases/download/${LUA_LS_VERSION}/lua-language-server-${LUA_LS_VERSION}-darwin-arm64.tar.gz tar -xzf lua-language-server-${LUA_LS_VERSION}-darwin-arm64.tar.gz -C "$LUA_LS_DIR" fi chmod +x "$LUA_LS_DIR/bin/lua-language-server" # Create wrapper script instead of symlink to ensure supporting files are found echo '#!/bin/bash' | sudo tee /usr/local/bin/lua-language-server > /dev/null echo 'cd "${HOME}/.serena/language_servers/lua/bin"' | sudo tee -a /usr/local/bin/lua-language-server > /dev/null echo 'exec ./lua-language-server "$@"' | sudo tee -a /usr/local/bin/lua-language-server > /dev/null sudo chmod +x /usr/local/bin/lua-language-server rm lua-language-server-*.tar.gz elif [[ "${{ runner.os }}" == "Windows" ]]; then curl -L -o lua-ls.zip https://github.com/LuaLS/lua-language-server/releases/download/${LUA_LS_VERSION}/lua-language-server-${LUA_LS_VERSION}-win32-x64.zip unzip -o lua-ls.zip -d "$LUA_LS_DIR" # For Windows, we'll add the bin directory directly to PATH # The lua-language-server.exe can find its supporting files relative to its location echo "$LUA_LS_DIR/bin" >> $GITHUB_PATH rm lua-ls.zip fi - name: Install Perl::LanguageServer if: runner.os != 'Windows' shell: bash run: | if [[ "${{ runner.os }}" == "Linux" ]]; then sudo apt-get update sudo apt-get install -y cpanminus build-essential libanyevent-perl libio-aio-perl elif [[ "${{ runner.os }}" == "macOS" ]]; then brew install cpanminus fi PERL_MM_USE_DEFAULT=1 cpanm --notest --force Perl::LanguageServer # Set up Perl local::lib environment for subsequent steps echo "PERL5LIB=$HOME/perl5/lib/perl5${PERL5LIB:+:${PERL5LIB}}" >> $GITHUB_ENV echo "PERL_LOCAL_LIB_ROOT=$HOME/perl5${PERL_LOCAL_LIB_ROOT:+:${PERL_LOCAL_LIB_ROOT}}" >> $GITHUB_ENV echo "PERL_MB_OPT=--install_base \"$HOME/perl5\"" >> $GITHUB_ENV echo "PERL_MM_OPT=INSTALL_BASE=$HOME/perl5" >> $GITHUB_ENV echo "$HOME/perl5/bin" >> $GITHUB_PATH - name: Install ansible-core and ansible-lint (for Ansible language server tests) shell: bash run: uv run pip install ansible-core ansible-lint - name: Install Elm shell: bash run: npm install -g elm@0.19.1-6 - name: Install Nix if: runner.os != 'Windows' # Nix doesn't support Windows natively uses: cachix/install-nix-action@v30 with: nix_path: nixpkgs=channel:nixos-unstable - name: Install nixd (Nix Language Server) if: runner.os != 'Windows' # Skip on Windows since Nix isn't available shell: bash run: | # Install nixd using nix nix profile install github:nix-community/nixd # Verify nixd is installed and working if ! command -v nixd &> /dev/null; then echo "nixd installation failed or not in PATH" exit 1 fi echo "$HOME/.nix-profile/bin" >> $GITHUB_PATH - name: Verify Nix package build if: runner.os != 'Windows' # Nix only supported on Linux/macOS shell: bash run: | # Verify the flake builds successfully nix build --no-link - name: Install Regal (Rego Language Server) shell: bash run: | REGAL_VERSION="0.39.0" if [[ "${{ runner.os }}" == "Linux" ]]; then if [[ "$(uname -m)" == "x86_64" ]]; then curl -L -o regal https://github.com/StyraInc/regal/releases/download/v${REGAL_VERSION}/regal_Linux_x86_64 else curl -L -o regal https://github.com/StyraInc/regal/releases/download/v${REGAL_VERSION}/regal_Linux_arm64 fi chmod +x regal sudo mv regal /usr/local/bin/ elif [[ "${{ runner.os }}" == "macOS" ]]; then if [[ "$(uname -m)" == "x86_64" ]]; then curl -L -o regal https://github.com/StyraInc/regal/releases/download/v${REGAL_VERSION}/regal_Darwin_x86_64 else curl -L -o regal https://github.com/StyraInc/regal/releases/download/v${REGAL_VERSION}/regal_Darwin_arm64 fi chmod +x regal sudo mv regal /usr/local/bin/ elif [[ "${{ runner.os }}" == "Windows" ]]; then curl -L -o regal.exe https://github.com/StyraInc/regal/releases/download/v${REGAL_VERSION}/regal_Windows_x86_64.exe mkdir -p "$HOME/bin" mv regal.exe "$HOME/bin/" echo "$HOME/bin" >> $GITHUB_PATH fi - name: Install Free Pascal Compiler shell: bash run: | if [[ "${{ runner.os }}" == "Linux" ]]; then sudo apt-get update sudo apt-get install -y fpc fpc-source # Set environment variables for pasls echo "PP=/usr/bin/fpc" >> $GITHUB_ENV # Find FPC source directory (version may vary) - remove trailing slash FPCDIR=$(ls -d /usr/share/fpcsrc/*/ 2>/dev/null | head -1 | sed 's:/$::') if [[ -z "$FPCDIR" ]]; then FPCDIR="/usr/share/fpcsrc" fi echo "FPCDIR=$FPCDIR" >> $GITHUB_ENV echo "=== FPC source directory structure ===" ls -la "$FPCDIR" || echo "FPCDIR not found" ls -la "$FPCDIR/rtl" 2>/dev/null || echo "rtl subdirectory not found" elif [[ "${{ runner.os }}" == "macOS" ]]; then brew install fpc # Download FPC source from SourceForge (fpc-src-laz cask is incompatible with ARM64) FPC_VERSION="3.2.2" curl -L -o fpc-source.tar.gz "https://sourceforge.net/projects/freepascal/files/Source/${FPC_VERSION}/fpc-${FPC_VERSION}.source.tar.gz/download" mkdir -p "$HOME/fpcsrc" tar -xzf fpc-source.tar.gz -C "$HOME/fpcsrc" rm fpc-source.tar.gz # Check extracted directory structure (might be nested) echo "=== Extracted FPC source structure ===" ls -la "$HOME/fpcsrc" # Find the actual FPC source root (contains rtl, packages, etc.) if [[ -d "$HOME/fpcsrc/fpc-${FPC_VERSION}/rtl" ]]; then FPCDIR="$HOME/fpcsrc/fpc-${FPC_VERSION}" elif [[ -d "$HOME/fpcsrc/fpc-${FPC_VERSION}/fpc-${FPC_VERSION}/rtl" ]]; then FPCDIR="$HOME/fpcsrc/fpc-${FPC_VERSION}/fpc-${FPC_VERSION}" else FPCDIR="$HOME/fpcsrc/fpc-${FPC_VERSION}" fi echo "PP=$(which fpc)" >> $GITHUB_ENV echo "FPCDIR=$FPCDIR" >> $GITHUB_ENV echo "=== FPC source directory ===" ls -la "$FPCDIR" || echo "FPCDIR not found" elif [[ "${{ runner.os }}" == "Windows" ]]; then FPC_VERSION="3.2.2" # Download freepascal-ootb (includes FPC compiler) curl -L -o fpc-ootb.zip https://github.com/fredvs/freepascal-ootb/releases/download/${FPC_VERSION}/fpc-ootb-322-x86_64-win64.zip mkdir -p "$HOME/fpc" unzip -q fpc-ootb.zip -d "$HOME/fpc" rm fpc-ootb.zip # Download FPC source from SourceForge (fpc-ootb only has compiled units, not source) curl -L -o fpc-source.zip "https://sourceforge.net/projects/freepascal/files/Source/${FPC_VERSION}/fpc-${FPC_VERSION}.source.zip/download" mkdir -p "$HOME/fpcsrc" unzip -q fpc-source.zip -d "$HOME/fpcsrc" rm fpc-source.zip # Find fpc executable (fpc-ootb uses fpc-ootb.exe as the compiler) echo "=== FPC directory structure ===" find "$HOME/fpc" -name "*.exe" -type f 2>/dev/null | head -10 FPC_EXE=$(find "$HOME/fpc" -name "fpc-ootb-64.exe" -type f 2>/dev/null | head -1) echo "Found FPC executable: $FPC_EXE" echo "Found FPC source dir: $HOME/fpcsrc/fpc-${FPC_VERSION}" # Set environment variables for pasls echo "PP=$FPC_EXE" >> $GITHUB_ENV echo "FPCDIR=$HOME/fpcsrc/fpc-${FPC_VERSION}" >> $GITHUB_ENV # Add FPC bin directory to PATH FPC_BIN_DIR=$(dirname "$FPC_EXE") echo "$FPC_BIN_DIR" >> $GITHUB_PATH fi - name: Verify FPC installation shell: bash run: | echo "=== Environment variables ===" echo "PP=$PP" echo "FPCDIR=$FPCDIR" # Create a simple test program if [[ "${{ runner.os }}" == "Windows" ]]; then TEST_PAS="$TEMP/fpc_test.pas" TEST_OUT="$TEMP/fpc_test" else TEST_PAS="/tmp/fpc_test.pas" TEST_OUT="/tmp/fpc_test" fi echo "program fpc_test; begin writeln('FPC works'); end." > "$TEST_PAS" # Compile using PP (the compiler we actually use in tests) echo "=== Compiling test program with PP=$PP ===" if [[ -n "$PP" ]]; then "$PP" "$TEST_PAS" -o"$TEST_OUT" 2>&1 else echo "ERROR: PP environment variable is not set" exit 1 fi # Verify output binary exists if [[ -f "$TEST_OUT" ]] || [[ -f "${TEST_OUT}.exe" ]]; then echo "FPC compilation test PASSED" else echo "ERROR: FPC compilation failed - no output binary at $TEST_OUT" exit 1 fi # Verify FPCDIR exists (required for pasls) if [[ -d "$FPCDIR" ]]; then echo "FPCDIR exists: $FPCDIR" else echo "ERROR: FPCDIR does not exist: $FPCDIR" exit 1 fi - name: Build Lean 4 test project uses: leanprover/lean-action@v1 with: lake-package-directory: test/resources/repos/lean4/test_repo - name: Cache language servers id: cache-language-servers uses: actions/cache@v3 with: path: ~/.serena/language_servers/static key: language-servers-${{ runner.os }}-v1 restore-keys: | language-servers-${{ runner.os }}- - name: Report free disk space if: runner.os == 'Linux' run: | echo "Free disk space before tests:" df -h - name: Test with pytest shell: bash run: uv run poe test - name: Type-checking with mypy shell: bash run: uv run poe type-check ================================================ FILE: .gitignore ================================================ # macOS specific files .DS_Store .AppleDouble .LSOverride ._* .Spotlight-V100 .Trashes Icon .fseventsd .DocumentRevisions-V100 .TemporaryItems .VolumeIcon.icns .com.apple.timemachine.donotpresent # Windows specific files Thumbs.db Thumbs.db:encryptable ehthumbs.db ehthumbs_vista.db *.stackdump [Dd]esktop.ini $RECYCLE.BIN/ *.cab *.msi *.msix *.msm *.msp *.lnk # Linux specific files *~ .fuse_hidden* .directory .Trash-* .nfs* # IDE/Text Editors # VS Code .vscode/* !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json *.code-workspace .history/ # JetBrains IDEs (beyond .idea/) *.iml *.ipr *.iws out/ .idea_modules/ # Sublime Text *.tmlanguage.cache *.tmPreferences.cache *.stTheme.cache *.sublime-workspace *.sublime-project # Project specific ignore .idea temp # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # reports pylint.html .pylint.d # Serena-specific /*.yml !*.template.yml /agent-ui /test/**/.serena # clojure-lsp temporary files .calva/ .clj-kondo/ .cpcache/ .lsp/ # temporary and backup files *.bak *.tmp tmp/ .vscode/ # Claude settings .claude/settings.local.json # Elixir /test/resources/repos/elixir/test_repo/deps # Exception: Don't ignore Elixir test repository lib directory (contains source code) !/test/resources/repos/elixir/test_repo/lib # Exception: Don't ignore Nix test repository lib directory (contains source code) !/test/resources/repos/nix/test_repo/lib # Exception: Don't ignore OCaml test repository lib directory (contains source code) !/test/resources/repos/ocaml/test_repo/lib # Exception: Don't ignore Julia test repository lib directory (contains source code) !/test/resources/repos/julia/test_repo/lib # Exception: Don't ignore Solidity test repository lib directory (contains source code) !/test/resources/repos/solidity/test_repo/contracts/lib # Swift /test/resources/repos/swift/test_repo/.build /test/resources/repos/swift/test_repo/.swiftpm # OCaml /test/resources/repos/ocaml/test_repo/_build # Elm /test/resources/repos/elm/test_repo/.elm/ /test/resources/repos/elm/test_repo/elm-stuff/ # Scala .metals/ .bsp/ .scala-build/ .bloop/ bootstrap test/resources/repos/scala/.bloop/ # Haskell .stack-work/ *.cabal stack.yaml.lock dist-newstyle/ cabal.project.local* .ghc.environment.* # Lean 4 .lake/ zz-misc/ vue-implementation/ ================================================ FILE: .serena/.gitignore ================================================ /cache /project.local.yml ================================================ FILE: .serena/memories/adding_new_language_support_guide.md ================================================ # Adding New Language Support to Serena This guide explains how to add support for a new programming language to Serena. ## Overview Adding a new language involves: 1. **Language Server Implementation** - Creating a language-specific server class 2. **Language Registration** - Adding the language to enums and configurations 3. **Test Repository** - Creating a minimal test project 4. **Test Suite** - Writing comprehensive tests ## Step 1: Language Server Implementation ### 1.1 Create Language Server Class Create a new file in `src/solidlsp/language_servers/` (e.g., `new_language_server.py`). #### Providing the Launch Command via a DependencyProvider All language servers use the `DependencyProvider` pattern to handle * runtime dependency installation/discovery * launch command creation (and, optionally, environment setup) To implement a new language server using the DependencyProvider pattern: * Pass `None` for `process_launch_info` in `super().__init__()` - the base class creates it via `_create_dependency_provider()` * Implement `_create_dependency_provider()` to return an inner `DependencyProvider` class instance. In simple cases, it can be instantiated with only two parameters: ```python def _create_dependency_provider(self) -> LanguageServerDependencyProvider: return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) ``` The resource dir that is passed is the directory in which installed dependencies should be stored! **Base Classes:** - **`LanguageServerDependencyProviderSinglePath`** - For language servers with a single core dependency (e.g., an executable or JAR file) - Provides automatic support for the `ls_path` custom setting, allowing users to override the core dependency path (if they have it installed it themselves) - Implement `_get_or_install_core_dependency()` to return the path to the core dependency, downloading/installing it automatically if necessary - Implement `_create_launch_command(core_path)` to build the full command from the core path - Reference implementations: `TypeScriptLanguageServer`, `Intelephense`, `ClojureLSP`, `ClangdLanguageServer`, `PyrightServer` - **`LanguageServerDependencyProvider`** - The base class, which can be directly inherited from for complex cases with multiple dependencies or custom setup - Implement `create_launch_command()` directly - Reference implementations: `EclipseJDTLS`, `CSharpLanguageServer`, `MatlabLanguageServer` **Implementation Pointers::** - When returning the command, prefer the list-based representation for robustness - Override `create_launch_command_env` if the launch command needs environment variables to be set (defaults to `{}` in the base implementation) You should look at at least one existing implementation of each base class to understand how they work. ### 1.2 LSP Initialization Override initialization methods if needed: ```python def _get_initialize_params(self) -> InitializeParams: """Return language-specific initialization parameters.""" return { "processId": os.getpid(), "rootUri": PathUtils.path_to_uri(self.repository_root_path), "capabilities": { # Language-specific capabilities } } def _start_server(self): """Start the language server with custom handlers.""" # Set up notification handlers self.server.on_notification("window/logMessage", self._handle_log_message) # Start server and initialize self.server.start() init_response = self.server.send.initialize(self._get_initialize_params()) self.server.notify.initialized({}) ``` After `_start_server` returns, the language server should be fully operational. If the server requires that one waits for certain notifications or responses before being ready, implement that logic here. For an example, see `EclipseJDTLS._start_server`. ## Step 2: Language Registration ### 2.1 Add to Language Enum In `src/solidlsp/ls_config.py`, add your language to the `Language` enum: ```python class Language(str, Enum): # Existing languages... NEW_LANGUAGE = "new_language" def get_source_fn_matcher(self) -> FilenameMatcher: match self: # Existing cases... case self.NEW_LANGUAGE: return FilenameMatcher("*.newlang", "*.nl") # File extensions ``` ### 2.2 Update Language Server Factory In `src/solidlsp/ls.py`, add your language to the `create` method: ```python @classmethod def create(cls, config: LanguageServerConfig, repository_root_path: str) -> "SolidLanguageServer": match config.code_language: # Existing cases... case Language.NEW_LANGUAGE: from solidlsp.language_servers.new_language_server import NewLanguageServer return NewLanguageServer(config, repository_root_path) ``` ## Step 3: Test Repository ### 3.1 Create Test Project Create a minimal project in `test/resources/repos/new_language/test_repo/`: ``` test/resources/repos/new_language/test_repo/ ├── main.newlang # Main source file ├── lib/ │ └── helper.newlang # Additional source for testing ├── project.toml # Project configuration (if applicable) └── .gitignore # Ignore build artifacts ``` ### 3.2 Example Source Files Create meaningful source files that demonstrate: - **Classes/Types** - For symbol testing - **Functions/Methods** - For reference finding - **Imports/Dependencies** - For cross-file operations - **Nested Structures** - For hierarchical symbol testing Example `main.newlang`: ``` import lib.helper class Calculator { func add(a: Int, b: Int) -> Int { return a + b } func subtract(a: Int, b: Int) -> Int { return helper.subtract(a, b) // Reference to imported function } } class Program { func main() { let calc = Calculator() let result = calc.add(5, 3) // Reference to add method print(result) } } ``` ## Step 4: Test Suite Testing the language server implementation is of crucial importance, and the tests will form the main part of the review process. Make sure that the tests are up to the standard of Serena to make the review go smoother. General rules for tests: 1. Tests for symbols and references should always check that the expected symbol names and references were actually found. Just testing that a list came back or that the result is not None is insufficient. 2. Tests should never be skipped, the only exception is skipping based on some package being available or on an unsupported OS. 3. Tests should run in CI, check if there is a suitable GitHub action for installing the dependencies. ### 4.1 Basic Tests Create `test/solidlsp/new_language/test_new_language_basic.py`. Have a look at the structure of existing tests, for example, in `test/solidlsp/php/test_php_basic.py` You should at least test: 1. Finding symbols 2. Finding within-file references 3. Finding cross-file references Have a look at `test/solidlsp/php/test_php_basic.py` as an example for what should be tested. Don't forget to add a new language marker to `pytest.ini`. ### 4.2 Integration Tests Consider adding new cases to the parametrized tests in `test_serena_agent.py` for the new language. ### 5 Documentation Update: - **README.md** - Add language to the list of languages - **docs/01-about/020_programming-languages.md** - Add language to the list and mention any special notes, compatibility or requirements (e.g. installations the user is required to do) - **CHANGELOG.md** - Document the new language support ================================================ FILE: .serena/memories/serena_core_concepts_and_architecture.md ================================================ # Serena Core Concepts and Architecture ## High-Level Architecture Serena is built around a dual-layer architecture: 1. **SerenaAgent** - The main orchestrator that manages projects, tools, and user interactions 2. **SolidLanguageServer** - A unified wrapper around Language Server Protocol (LSP) implementations ## Core Components ### 1. SerenaAgent (`src/serena/agent.py`) The central coordinator that: - Manages active projects and their configurations - Coordinates between different tools and contexts - Handles language server lifecycle - Manages memory persistence - Provides MCP (Model Context Protocol) server interface Key responsibilities: - **Project Management** - Activating, switching between projects - **Tool Registry** - Loading and managing available tools based on context/mode - **Language Server Integration** - Starting/stopping language servers per project - **Memory Management** - Persistent storage of project knowledge - **Task Execution** - Coordinating complex multi-step operations ### 2. SolidLanguageServer (`src/solidlsp/ls.py`) A unified abstraction over multiple language servers that provides: - **Language-agnostic interface** for symbol operations - **Caching layer** for performance optimization - **Error handling and recovery** for unreliable language servers - **Uniform API** regardless of underlying LSP implementation Core capabilities: - Symbol discovery and navigation - Code completion and hover information - Find references and definitions - Document and workspace symbol search - File watching and change notifications ### 3. Tool System (`src/serena/tools/`) Modular tool architecture with several categories: #### File Tools (`file_tools.py`) - File system operations (read, write, list directories) - Text search and pattern matching - Regex-based replacements #### Symbol Tools (`symbol_tools.py`) - Language-aware symbol finding and navigation - Symbol body replacement and insertion - Reference finding across codebase #### Memory Tools (`memory_tools.py`) - Project knowledge persistence - Memory retrieval and management - Onboarding information storage #### Configuration Tools (`config_tools.py`) - Project activation and switching - Mode and context management - Tool inclusion/exclusion ### 4. Configuration System (`src/serena/config/`) Multi-layered configuration supporting: - **Contexts** - Define available tools and their behavior - **Modes** - Specify operational patterns (interactive, editing, etc.) - **Projects** - Per-project settings and language server configs - **Tool Sets** - Grouped tool collections for different use cases ## Language Server Integration ### Language Support Model Each supported language has: 1. **Language Server Implementation** (`src/solidlsp/language_servers/`) 2. **Runtime Dependencies** - Managed downloads of language servers 3. **Test Repository** (`test/resources/repos//`) 4. **Test Suite** (`test/solidlsp//`) ### Language Server Lifecycle 1. **Discovery** - Find language servers or download them automatically 2. **Initialization** - Start server process and perform LSP handshake 3. **Project Setup** - Open workspace and configure language-specific settings 4. **Operation** - Handle requests/responses with caching and error recovery 5. **Shutdown** - Clean shutdown of server processes ### Supported Languages Current language support includes: - **C#** - Microsoft.CodeAnalysis.LanguageServer (.NET 9) - **Python** - Pyright or Jedi - **TypeScript/JavaScript** - TypeScript Language Server - **Rust** - rust-analyzer - **Go** - gopls - **Java** - Eclipse JDT Language Server - **Kotlin** - Kotlin Language Server - **PHP** - Intelephense - **Ruby** - Solargraph - **Clojure** - clojure-lsp - **Elixir** - ElixirLS - **Dart** - Dart Language Server - **C/C++** - clangd - **Terraform** - terraform-ls ## Memory and Knowledge Management ### Memory System - **Markdown-based storage** in `.serena/memories/` directory - **Contextual retrieval** - memories loaded based on relevance - **Project-specific** knowledge persistence - **Onboarding support** - guided setup for new projects ### Knowledge Categories - **Project Structure** - Directory layouts, build systems - **Architecture Patterns** - How the codebase is organized - **Development Workflows** - Testing, building, deployment - **Domain Knowledge** - Business logic and requirements ## MCP Server Interface Serena exposes its functionality through Model Context Protocol: - **Tool Discovery** - AI agents can enumerate available tools - **Context-Aware Operations** - Tools behave based on active project/mode - **Stateful Sessions** - Maintains project state across interactions - **Error Handling** - Graceful degradation when tools fail ## Error Handling and Resilience ### Language Server Reliability - **Timeout Management** - Configurable timeouts for LSP requests - **Process Recovery** - Automatic restart of crashed language servers - **Fallback Behavior** - Graceful degradation when LSP unavailable - **Caching Strategy** - Reduces impact of server failures ### Project Activation Safety - **Validation** - Verify project structure before activation - **Error Isolation** - Project failures don't affect other projects - **Recovery Mechanisms** - Automatic cleanup and retry logic ## Performance Considerations ### Caching Strategy - **Symbol Cache** - In-memory caching of expensive symbol operations - **File System Cache** - Reduced disk I/O for repeated operations - **Language Server Cache** - Persistent cache across sessions ### Resource Management - **Language Server Pooling** - Reuse servers across projects when possible - **Memory Management** - Automatic cleanup of unused resources - **Background Operations** - Async operations don't block user interactions ## Extension Points ### Adding New Languages 1. Implement language server class in `src/solidlsp/language_servers/` 2. Add runtime dependencies configuration 3. Create test repository and test suite 4. Update language enumeration and configuration ### Adding New Tools 1. Inherit from `Tool` base class in `tools_base.py` 2. Implement required methods and parameter validation 3. Register tool in appropriate tool registry 4. Add to context/mode configurations as needed ### Custom Contexts and Modes - Define new contexts in YAML configuration files - Specify tool sets and operational patterns - Configure for specific development workflows ================================================ FILE: .serena/memories/serena_repository_structure.md ================================================ # Serena Repository Structure ## Overview Serena is a multi-language code assistant that combines two main components: 1. **Serena Core** - The main agent framework with tools and MCP server 2. **SolidLSP** - A unified Language Server Protocol wrapper for multiple programming languages ## Top-Level Structure ``` serena/ ├── src/ # Main source code │ ├── serena/ # Serena agent framework │ ├── solidlsp/ # LSP wrapper library │ └── interprompt/ # Multi-language prompt templates ├── test/ # Test suites │ ├── serena/ # Serena agent tests │ ├── solidlsp/ # Language server tests │ └── resources/repos/ # Test repositories for each language ├── scripts/ # Build and utility scripts ├── resources/ # Static resources and configurations ├── pyproject.toml # Python project configuration ├── README.md # Project documentation └── CHANGELOG.md # Version history ``` ## Source Code Organization ### Serena Core (`src/serena/`) - **`agent.py`** - Main SerenaAgent class that orchestrates everything - **`tools/`** - MCP tools for file operations, symbols, memory, etc. - `file_tools.py` - File system operations (read, write, search) - `symbol_tools.py` - Symbol-based code operations (find, edit) - `memory_tools.py` - Knowledge persistence and retrieval - `config_tools.py` - Project and mode management - `workflow_tools.py` - Onboarding and meta-operations - **`config/`** - Configuration management - `serena_config.py` - Main configuration classes - `context_mode.py` - Context and mode definitions - **`util/`** - Utility modules - **`mcp.py`** - MCP server implementation - **`cli.py`** - Command-line interface ### SolidLSP (`src/solidlsp/`) - **`ls.py`** - Main SolidLanguageServer class - **`language_servers/`** - Language-specific implementations - `csharp_language_server.py` - C# (Microsoft.CodeAnalysis.LanguageServer) - `python_server.py` - Python (Pyright) - `typescript_language_server.py` - TypeScript - `rust_analyzer.py` - Rust - `gopls.py` - Go - And many more... - **`ls_config.py`** - Language server configuration - **`ls_types.py`** - LSP type definitions - **`ls_utils.py`** - Utilities for working with LSP data ### Interprompt (`src/interprompt/`) - Multi-language prompt template system - Jinja2-based templating with language fallbacks ## Test Structure ### Language Server Tests (`test/solidlsp/`) Each language has its own test directory: ``` test/solidlsp/ ├── csharp/ │ └── test_csharp_basic.py ├── python/ │ └── test_python_basic.py ├── typescript/ │ └── test_typescript_basic.py └── ... ``` ### Test Resources (`test/resources/repos/`) Contains minimal test projects for each language: ``` test/resources/repos/ ├── csharp/test_repo/ │ ├── serena.sln │ ├── TestProject.csproj │ ├── Program.cs │ └── Models/Person.cs ├── python/test_repo/ ├── typescript/test_repo/ └── ... ``` ### Test Infrastructure - **`test/conftest.py`** - Shared test fixtures and utilities - **`create_ls()`** function - Creates language server instances for testing - **`language_server` fixture** - Parametrized fixture for multi-language tests ## Key Configuration Files - **`pyproject.toml`** - Python dependencies, build config, and tool settings - **`.serena/`** directories - Project-specific Serena configuration and memories - **`CLAUDE.md`** - Instructions for AI assistants working on the project ## Dependencies Management The project uses modern Python tooling: - **uv** for fast dependency resolution and virtual environments - **pytest** for testing with language-specific markers (`@pytest.mark.csharp`) - **ruff** for linting and formatting - **mypy** for type checking ## Build and Development - **Docker support** - Full containerized development environment - **GitHub Actions** - CI/CD with language server testing - **Development scripts** in `scripts/` directory ================================================ FILE: .serena/memories/suggested_commands.md ================================================ # Suggested Commands ## Development Tasks (using uv and poe) The following tasks should generally be executed using `uv run poe `. - `format`: This is the **only** allowed command for formatting. Run as `uv run poe format`. - `type-check`: This is the **only** allowed command for type checking. Run as `uv run poe type-check`. - `test`: This is the preferred command for running tests (`uv run poe test [args]`). You can select subsets of tests with markers, the current markers are ```toml markers = [ "python: language server running for Python", "go: language server running for Go", "java: language server running for Java", "rust: language server running for Rust", "typescript: language server running for TypeScript", "php: language server running for PHP", "snapshot: snapshot tests for symbolic editing operations", ] ``` By default, `uv run poe test` uses the markers set in the env var `PYTEST_MARKERS`, or, if it unset, uses `-m "not java and not rust and not isolated process"`. You can override this behavior by simply passing the `-m` option to `uv run poe test`, e.g. `uv run poe test -m "python or go"`. For finishing a task, make sure format, type-check and test pass! Run them at the end of the task and if needed fix any issues that come up and run them again until they pass. ================================================ FILE: .serena/project.yml ================================================ # the name by which the project can be referenced within Serena project_name: "serena" # list of languages for which language servers are started; choose from: # al bash clojure cpp csharp # csharp_omnisharp dart elixir elm erlang # fortran fsharp go groovy haskell # java julia kotlin lua markdown # matlab nix pascal perl php # powershell python python_jedi r rego # ruby ruby_solargraph rust scala swift # terraform toml typescript typescript_vts vue # yaml zig # (This list may be outdated. For the current list, see values of Language enum here: # https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py # For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) # Note: # - For C, use cpp # - For JavaScript, use typescript # - For Free Pascal/Lazarus, use pascal # Special requirements: # - csharp: Requires the presence of a .sln file in the project folder. # - pascal: Requires Free Pascal Compiler (fpc) and optionally Lazarus. # When using multiple languages, the first language server that supports a given file will be used for that file. # The first language is the default language and the respective language server will be used as a fallback. # Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. languages: - python - typescript # whether to use project's .gitignore files to ignore files ignore_all_files_in_gitignore: true # list of additional paths to ignore in all projects # same syntax as gitignore, so you can use * and ** ignored_paths: [] # whether the project is in read-only mode # If set to true, all editing tools will be disabled and attempts to use them will result in an error read_only: false # list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. # Below is the complete list of tools for convenience. # To make sure you have the latest list of tools, and to view their descriptions, # execute `uv run scripts/print_tool_overview.py`. # # * `activate_project`: Activates a project by name. # * `check_onboarding_performed`: Checks whether project onboarding was already performed. # * `create_text_file`: Creates/overwrites a file in the project directory. # * `delete_lines`: Deletes a range of lines within a file. # * `delete_memory`: Deletes a memory from Serena's project-specific memory store. # * `execute_shell_command`: Executes a shell command. # * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. # * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). # * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). # * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. # * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. # * `initial_instructions`: Gets the initial instructions for the current project. # Should only be used in settings where the system prompt cannot be set, # e.g. in clients you have no control over, like Claude Desktop. # * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. # * `insert_at_line`: Inserts content at a given line in a file. # * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. # * `list_dir`: Lists files and directories in the given directory (optionally with recursion). # * `list_memories`: Lists memories in Serena's project-specific memory store. # * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). # * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). # * `read_file`: Reads a file within the project directory. # * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. # * `remove_project`: Removes a project from the Serena configuration. # * `replace_lines`: Replaces a range of lines within a file with new content. # * `replace_symbol_body`: Replaces the full definition of a symbol. # * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. # * `search_for_pattern`: Performs a search for a pattern in the project. # * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. # * `switch_modes`: Activates modes by providing a list of their names # * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. # * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. # * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. # * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. excluded_tools: [] # list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default) included_optional_tools: [] # fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. # This cannot be combined with non-empty excluded_tools or included_optional_tools. # initial prompt for the project. It will always be given to the LLM upon activating the project # (contrary to the memories, which are loaded on demand). initial_prompt: | IMPORTANT: You use an idiomatic, object-oriented style. In particular, this implies that, for any non-trivial interfaces, you use interfaces that expect explicitly typed abstractions rather than mere functions (i.e. use the strategy pattern, for example). You avoid the use of low-level data structures in all cases where an object-oriented abstraction would be more appropriate. For simple data storage, you use dataclasses instead of dictionaries or tuples. You structure function implementations into functional blocks that are separated by blank lines. Atop each functional block, you write an elliptical phrase (starting with lower-case letter) that describes the purpose of the block in a concise manner. Docstrings: You consistently use reStructuredText. Comments: When describing parameters, methods/functions and classes, you use a precise style, where the initial (elliptical) phrase clearly defines *what* it is. Any details then follow in subsequent sentences. # the encoding used by text files in the project # For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings encoding: utf-8 # list of mode names to that are always to be included in the set of active modes # The full set of modes to be activated is base_modes + default_modes. # If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply. # Otherwise, this setting overrides the global configuration. # Set this to [] to disable base modes for this project. # Set this to a list of mode names to always include the respective modes for this project. base_modes: # list of mode names that are to be activated by default. # The full set of modes to be activated is base_modes + default_modes. # If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply. # Otherwise, this overrides the setting from the global configuration (serena_config.yml). # This setting can, in turn, be overridden by CLI parameters (--mode). default_modes: # fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. # This cannot be combined with non-empty excluded_tools or included_optional_tools. fixed_tools: [] # time budget (seconds) per tool call for the retrieval of additional symbol information # such as docstrings or parameter information. # This overrides the corresponding setting in the global configuration; see the documentation there. # If null or missing, use the setting from the global configuration. symbol_info_budget: # The language backend to use for this project. # If not set, the global setting from serena_config.yml is used. # Valid values: LSP, JetBrains # Note: the backend is fixed at startup. If a project with a different backend # is activated post-init, an error will be returned. language_backend: # list of regex patterns which, when matched, mark a memory entry as read‑only. # Extends the list from the global configuration, merging the two lists. read_only_memory_patterns: [] # line ending convention for file writes: unset (use global setting, "lf", "crlf", or "native" (platform default) # If not undefined, overrides the global setting in serena_config.yml line_ending: ================================================ FILE: CHANGELOG.md ================================================ # Latest Status of the `main` branch. Changes prior to the next official version change will appear here. * New language support: * Add Solidity language server support (`Language.SOLIDITY`) using the Nomic Foundation `@nomicfoundation/solidity-language-server`. Automatically installed via npm. Supports `.sol` files with go-to-definition, find references, document symbols, hover, and diagnostics. Works best with a `foundry.toml` or `hardhat.config.js` in the project root. * General: * Add monorepo/multi-language support * Project configuration files (`project.yml`) can now define multiple languages. Auto-detection adds only the most prominent language by default. * Additional languages can be conveniently added via the Dashboard while a project is already activated. * Add support for querying projects other than the currently active one via new tools `QueryProjectTool` and `ListQueryableProjectsTool`. The `QueryProjectTool` allows Serena tools to be called on other projects. * For the LSP backend, calling symbolic tools require a project server to be spawned that will launch the respective language servers * For the JetBrains backend, all projects for which IDE instances are open can directly be queried * Support overloaded symbols in `FindSymbolTool` and related tools * Name paths of overloaded symbols now include an index (e.g., `myOverloadedFunction[2]`) * Responses of the Java language server, which handled this in its own way, are now adapted accordingly, solving several issues related to retrieval problems in Java projects * Major extensions to the dashboard, which now serves as a central web interface for Serena * View current configuration * View news which can be marked as read * View the executions, with the possibility to cancel running/scheduled executions * View tool usage statistics * View and create memories and edit the serena configuration file * Log page now has save (downloads a snapshot) and clear (resets log view) buttons alongside the existing copy button * Language server backend: * New two-tier caching of language server document symbols and considerable performance improvements surrounding symbol retrieval/indexing * Allow passing language server specific settings through `ls_specific_settings` field (in `serena_config.yml`) * Add the JetBrains language backend as an alternative to language servers * Improve management of the Serena projects * Facilitate project activation based on the current directory (through the `--project-from-cwd` parameter) * Add notion of a "single-project context" (flag `single_project`), allowing user-defined contexts to behave like the built-in `ide-assistant` context (where the available tools are restricted to ones required by the active project and project changes are disabled) * The location of Serena's project-specific data folder can now be flexibly configured, allowing, in particular, locations outside of the project folder, thus improving support for read-only projects. * Add support for `project.local.yml` for local overrides that should not be versioned * Various fixes related to indexing, special paths and determination of ignored paths * Client support: * New mode `oaicompat-agent` and extensions enhancing OpenAI tool compatibility, permitting Serena to work with llama.cpp * Tools: * Symbol information (hover, docstring, quick-info) is now provided as part of `find_symbol` and related tool responses. * Added `QueryProjectTool` and `ListQueryableProjectTool` (see above) * Added `RenameSymbolTool` for renaming symbols across the codebase (if LS supports this operation). * Replaced `ReplaceRegexTool` with `ReplaceContentTool`, which supports both plain text and regex-based replacements (and which requires no escaping in the replacement text, making it more robust) * Decreased `TOOL_DEFAULT_MAX_ANSWER_LENGTH` to be in accordance with (below) typical max-tokens configurations * Language support: * **Add support for Lean 4** via built-in `lean --server` with cross-file reference support (requires `lean` and `lake` via [elan](https://github.com/leanprover/elan)) * **Add support for OCaml** via ocaml-lsp-server with cross-file reference support on OCaml 5.2+ (requires opam; see [setup guide](docs/03-special-guides/ocaml_setup_guide_for_serena.md)) * **Add Phpactor as alternative PHP language server** (specify `php_phpactor` as language; requires PHP 8.1+) * **Add support for Fortran** via fortls language server (requires `pip install fortls`) * **Add partial support for Groovy** requires user-provided Groovy language server JAR (see [setup guide](docs/03-special-guides/groovy_setup_guide_for_serena.md)) * **Add support for Julia** via LanguageServer.jl * **Add support for Haskell** via Haskell Language Server (HLS) with automatic discovery via ghcup, stack, or system PATH; supports both Stack and Cabal projects * **Add support for Scala** via Metals language server (requires some [manual setup](docs/03-special-guides/scala_setup_guide_for_serena.md)) * **Add support for F#** via FsAutoComplete/Ionide LSP server. * **Add support for Elm** via @elm-tooling/elm-language-server (automatically downloads if not installed; requires Elm compiler) * **Add support for Perl** via Perl::LanguageServer with LSP integration for .pl, .pm, and .t files * **Add support for AL (Application Language)** for Microsoft Dynamics 365 Business Central development. Requires VS Code AL extension (ms-dynamics-smb.al). * **Add support for R** via the R languageserver package with LSP integration, performance optimizations, and fallback symbol extraction * **Add support for Zig** via ZLS (cross-file references may not fully work on Windows) * **Add support for Lua** via lua-language-server * **Add support for Nix** requires nixd installation (Windows not supported) * **Add experimental support for YAML** via yaml-language-server with LSP integration for .yaml and .yml files * **Add support for TOML** via Taplo language server with automatic binary download, validation, formatting, and schema support for .toml files * **Dart now officially supported**: Dart was always working, but now tests were added, and it is promoted to "officially supported" * **Rust now uses already installed rustup**: The rust-analyzer is no longer bundled with Serena. Instead, it uses the rust-analyzer from your Rust toolchain managed by rustup. This ensures compatibility with your Rust version and eliminates outdated bundled binaries. * **Kotlin now officially supported**: We now use the official Kotlin LS, tests run through and performance is good, even though the LS is in an early development stage. * **Add support for Erlang** experimental, may hang or be slow, uses the recently archived [erlang_ls](https://github.com/erlang-ls/erlang_ls) * **Ruby dual language server support**: Added ruby-lsp as the modern primary Ruby language server. Solargraph remains available as an experimental legacy option. ruby-lsp supports both .rb and .erb files, while Solargraph supports .rb files only. * **Add support for PowerShell** via PowerShell Editor Services (PSES). Requires `pwsh` (PowerShell Core) to be installed and available in PATH. Supports symbol navigation, go-to-definition, and within-file references for .ps1 files. * **Add support for MATLAB** via the official MathWorks MATLAB Language Server. Requires MATLAB R2021b or later and Node.js. Set `MATLAB_PATH` environment variable or configure `matlab_path` in `ls_specific_settings`. Supports .m, .mlx, and .mlapp files with code completion, diagnostics, go-to-definition, find references, document symbols, formatting, and rename. * **Add support for Pascal** via the official Pascal Language Server. * **C/C++ alternate LS (ccls)**: Add experimental, opt-in support for ccls as an alternative backend to clangd. Enable via `cpp_ccls` in project configuration. Requires `ccls` installed and ideally a `compile_commands.json` at repo root. # 0.1.4 ## Summary This likely is the last release before the stable version 1.0.0 which will come together with the jetbrains IDE extension. We release it for users who install Serena from a tag, since the last tag cannot be installed due to a breaking change in the mcp dependency (see #381). Since the last release, several new languages were supported, and the Serena CLI and configurability were significantly extended. We thank all external contributors who made a lot of the improvements possible! * General: * **Initial instructions no longer need to be loaded by the user** * Significantly extended CLI * Removed `replace_regex` tool from `ide-assistant` and `codex` contexts. The current string replacement tool in Claude Code seems to be sufficiently efficient and is better integrated with the IDE. Users who want to enable `replace_regex` can do so by customizing the context. * Configuration: * Simplify customization of modes and contexts, including CLI support. * Possibility to customize the system prompt and outputs of simple tools, including CLI support. * Possibility to override tool descriptions through the context YAML. * Prompt templates are now automatically adapted to the enabled tools. * Several tools are now excluded by default, need to be included explicitly. * New context for ChatGPT * Language servers: * Reliably detect language server termination and propagate the respective error all the way back to the tool application, where an unexpected termination is handled by restarting the language server and subsequently retrying the tool application. * **Add support for Swift** * **Add support for Bash** * Enhance Solargraph (Ruby) integration * Automatic Rails project detection via config/application.rb, Rakefile, and Gemfile analysis * Ruby/Rails-specific exclude patterns for improved indexing performance (vendor/, .bundle/, tmp/, log/, coverage/) * Enhanced error handling with detailed diagnostics and Ruby manager-specific installation instructions (rbenv, RVM, asdf) * Improved LSP capability negotiation and analysis completion detection * Better Bundler and Solargraph installation error messages with clear resolution steps Fixes: * Ignore `.git` in check for ignored paths and improve performance of `find_all_non_ignored_files` * Fix language server startup issues on Windows when using Claude Code (which was due to default shell reconfiguration imposed by Claude Code) * Additional wait for initialization in C# language server before requesting references, allowing cross-file references to be found. # 0.1.3 ## Summary This is the first release of Serena to pypi. Since the last release, we have greatly improved stability and performance, as well as extended functionality, improved editing tools and included support for several new languages. * **Reduce the use of asyncio to a minimum**, improving stability and reducing the need for workarounds * Switch to newly developed fully synchronous LSP library `solidlsp` (derived from `multilspy`), removing our fork of `multilspy` (src/multilspy) * Switch from fastapi (which uses asyncio) to Flask in the Serena dashboard * The MCP server is the only asyncio-based component now, which resolves cross-component loop contamination, such that process isolation is no longer required. Neither are non-graceful shutdowns on Windows. * **Improved editing tools**: The editing logic was simplified and improved, making it more robust. * The "minimal indentation" logic was removed, because LLMs did not understand it. * The logic for the insertion of empty lines was improved (mostly controlled by the LLM now) * Add a task queue for the agent, which is executed in a separate and thread and * allows the language server to be initialized in the background, making the MCP server respond to requests immediately upon startup, * ensures that all tool executions are fully synchronized (executed linearly). * `SearchForPatternTool`: Better default, extended parameters and description for restricting the search * Language support: * Better support for C# by switching from `omnisharp` to Microsoft's official C# language server. * **Add support for Clojure, Elixir and Terraform. New language servers for C# and typescript.** * Experimental language server implementations can now be accessed by users through configuring the `language` field * Configuration: * Add option `web_dashboard_open_on_launch` (allowing the dashboard to be enabled without opening a browser window) * Add options `record_tool_usage_stats` and `token_count_estimator` * Serena config, modes and contexts can now be adjusted from the user's home directory. * Extended CLI to help with configuration * Dashboard: * Displaying tool usage statistics if enabled in the config Fixes: * Fix `ExecuteShellCommandTool` and `GetCurrentConfigTool` hanging on Windows * Fix project activation by name via `--project` not working (was broken in previous release) * Improve handling of indentation and newlines in symbolic editing tools * Fix `InsertAfterSymbolTool` failing for insertions at the end of a file that did not end with a newline * Fix `InsertBeforeSymbolTool` inserting in the wrong place in the absence of empty lines above the reference symbol * Fix `ReplaceSymbolBodyTool` changing whitespace before/after the symbol * Fix repository indexing not following links and catch exceptions during indexing, allowing indexing to continue even if unexpected errors occur for individual files. * Fix `ImportError` in Ruby language server. * Fix some issues with gitignore matching and interpreting of regexes in `search_for_pattern` tool. # 2025-06-20 * **Overhaul and major improvement of editing tools!** This represents a very important change in Serena. Symbols can now be addressed by their `name_path` (including nested ones) and we introduced a regex-based replaced tools. We tuned the prompts and tested the new editing mechanism. It is much more reliable, flexible, and at the same time uses fewer tokens. The line-replacement tools are disabled by default and deprecated, we will likely remove them soon. * **Better multi-project support and zero-config setup**: We significantly simplified the config setup, you no longer need to manually create `project.yaml` for each project. Project activation is now always available. Any project can now be activated by just asking the LLM to do so and passing the path to a repo. * Dashboard as web app and possibility to shut down Serena from it (or the old log GUI). * Possibility to index your project beforehand, accelerating Serena's tools. * Initial prompt for project supported (has to be added manually for the moment) * Massive performance improvement of pattern search tool * Use **process isolation** to fix stability issues and deadlocks (see #170). This uses separate process for the MCP server, the Serena agent and the dashboard in order to fix asyncio-related issues. # 2025-05-24 * Important new feature: **configurability of mode and context**, allowing better integration in a variety of clients. See corresponding section in readme - Serena can now be integrated in IDE assistants in a more productive way. You can now also do things like switching to one-shot planning mode, ask to plan something (which will create a memory), then switch to interactive editing mode in the next conversation and work through the plan read from the memory. * Some improvements to prompts. # 2025-05-21 **Significant improvement in symbol finding!** * Serena core: * `FindSymbolTool` now can look for symbols by specifying paths to them, not just the symbol name * Language Servers: * Fixed `gopls` initialization * Symbols retrieved through the symbol tree or through overview methods now are linked to their parents # 2025-05-19 * Serena core: * Bugfix in `FindSymbolTool` (a bug fixed in LS) * Fix in `ListDirTool`: Do not ignore files with extensions not understood by the language server, only skip ignored directories (error introduced in previous version) * Merged the two overview tools (for directories and files) into a single one: `GetSymbolsOverviewTool` * One-click setup for Cline enabled * `SearchForPatternTool` can now (optionally) search in the entire project * New tool `RestartLanguageServerTool` for restarting the language server (in case of other sources of editing apart from Serena) * Fix `CheckOnboardingPerformedTool`: * Tool description was incompatible with project change * Returned result was not as useful as it could be (now added list of memories) * Language Servers: * Add further file extensions considered by the language servers for Python (.pyi), JavaScript (.jsx) and TypeScript (.tsx, .jsx) * Updated multilspy, adding support for Kotlin, Dart and C/C++ and several improvements. * Added support for PHP # 2025-04-07 > **Breaking Config Changes**: make sure to set `ignore_all_files_in_gitignore`, remove `ignore_dirs` > and (optionally) set `ignore_paths` in your project configs. See [updated config template](myproject.template.yml) * Serena core: * New tool: FindReferencingCodeSnippets * Adjusted prompt in CreateTextFileTool to prevent writing partial content (see [here](https://www.reddit.com/r/ClaudeAI/comments/1jpavtm/comment/mloek1x/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button)). * FindSymbolTool: allow passing a file for restricting search, not just a directory (Gemini was too dumb to pass directories) * Native support for gitignore files for configuring files to be ignored by serena. See also in *Language Servers* section below. * **Major Feature**: Allow Serena to switch between projects (project activation) * Add central Serena configuration in `serena_config.yml`, which * contains the list of available projects * allows to configure whether project activation is enabled * now contains the GUI logging configuration (project configurations no longer do) * Add new tools `activate_project` and `get_active_project` * Providing a project configuration file in the launch parameters is now optional * Logging: * Improve error reporting in case of initialization failure: open a new GUI log window showing the error or ensure that the existing log window remains visible for some time * Language Servers: * Fix C# language server initialization issue when the project path contains spaces * Native support for gitignore in overview, document-tree and find_references operations. This is an **important** addition, since previously things like `venv` and `node_modules` were scanned and were likely responsible for slowness of tools and even server crashes (presumably due to OOM errors). * Agno: * Fix Agno reloading mechanism causing failures when initializing the sqlite memory database #8 * Fix Serena GUI log window not capturing logs after initialization # 2025-04-01 Initial public version ================================================ FILE: CLAUDE.md ================================================ # CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Development Commands **Essential Commands (use these exact commands):** - `uv run poe format` - Format code (BLACK + RUFF) - ONLY allowed formatting command - `uv run poe type-check` - Run mypy type checking - ONLY allowed type checking command - `uv run poe test` - Run tests with default markers (excludes java/rust by default) - `uv run poe test -m "python or go"` - Run specific language tests - `uv run poe test -m vue` - Run Vue tests - `uv run poe lint` - Check code style without fixing **Test Markers:** Available pytest markers for selective testing: - `python`, `go`, `java`, `rust`, `typescript`, `vue`, `php`, `perl`, `powershell`, `csharp`, `elixir`, `terraform`, `clojure`, `swift`, `bash`, `ruby`, `ruby_solargraph` - `snapshot` - for symbolic editing operation tests **Project Management:** - `uv run serena-mcp-server` - Start MCP server from project root - `uv run index-project` - Index project for faster tool performance **Always run format, type-check, and test before completing any task.** ## Architecture Overview Serena is a dual-layer coding agent toolkit: ### Core Components **1. SerenaAgent (`src/serena/agent.py`)** - Central orchestrator managing projects, tools, and user interactions - Coordinates language servers, memory persistence, and MCP server interface - Manages tool registry and context/mode configurations **2. SolidLanguageServer (`src/solidlsp/ls.py`)** - Unified wrapper around Language Server Protocol (LSP) implementations - Provides language-agnostic interface for symbol operations - Handles caching, error recovery, and multiple language server lifecycle **3. Tool System (`src/serena/tools/`)** - **file_tools.py** - File system operations, search, regex replacements - **symbol_tools.py** - Language-aware symbol finding, navigation, editing - **memory_tools.py** - Project knowledge persistence and retrieval - **config_tools.py** - Project activation, mode switching - **workflow_tools.py** - Onboarding and meta-operations **4. Configuration System (`src/serena/config/`)** - **Contexts** - Define tool sets for different environments (desktop-app, agent, ide-assistant) - **Modes** - Operational patterns (planning, editing, interactive, one-shot) - **Projects** - Per-project settings and language server configs ### Language Support Architecture Each supported language has: 1. **Language Server Implementation** in `src/solidlsp/language_servers/` 2. **Runtime Dependencies** - Automatic language server downloads when needed 3. **Test Repository** in `test/resources/repos//` 4. **Test Suite** in `test/solidlsp//` ### Memory & Knowledge System - **Markdown-based storage** in `.serena/memories/` directories - **Project-specific knowledge** persistence across sessions - **Contextual retrieval** based on relevance - **Onboarding support** for new projects ## Development Patterns ### Adding New Languages 1. Create language server class in `src/solidlsp/language_servers/` 2. Add to Language enum in `src/solidlsp/ls_config.py` 3. Update factory method in `src/solidlsp/ls.py` 4. Create test repository in `test/resources/repos//` 5. Write test suite in `test/solidlsp//` 6. Add pytest marker to `pyproject.toml` ### Adding New Tools 1. Inherit from `Tool` base class in `src/serena/tools/tools_base.py` 2. Implement required methods and parameter validation 3. Register in appropriate tool registry 4. Add to context/mode configurations ### Testing Strategy - Language-specific tests use pytest markers - Symbolic editing operations have snapshot tests - Integration tests in `test_serena_agent.py` - Test repositories provide realistic symbol structures ## Configuration Hierarchy Configuration is loaded from (in order of precedence): 1. Command-line arguments to `serena-mcp-server` 2. Project-specific `.serena/project.yml` 3. User config `~/.serena/serena_config.yml` 4. Active modes and contexts ## Key Implementation Notes - **Symbol-based editing** - Uses LSP for precise code manipulation - **Caching strategy** - Reduces language server overhead - **Error recovery** - Automatic language server restart on crashes - **Multi-language support** - 19 languages with LSP integration (including Vue) - **MCP protocol** - Exposes tools to AI agents via Model Context Protocol - **Async operation** - Non-blocking language server interactions ## Working with the Codebase - Project uses Python 3.11 with `uv` for dependency management - Strict typing with mypy, formatted with black + ruff - Language servers run as separate processes with LSP communication - Memory system enables persistent project knowledge - Context/mode system allows workflow customization ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to Serena Thank you for your interest in contributing to Serena! ## Scope of Contributions The following types of contributions can be submitted directly via pull requests: * isolated additions which do not change the behaviour of Serena and only extend it along existing lines (e.g., adding support for a new language server) * small bug fixes * documentation improvements For other changes, please open an issue first to discuss your ideas with the maintainers. When submitting a PR, ensure a well-defined scope. Every PR should cover a single logical change or a set of closely related changes. ### Adding Support for a New Language Server See the corresponding [memory](.serena/memories/adding_new_language_support_guide.md). ## Python Environment Setup You can install a virtual environment with the required as follows 1. Create a new virtual environment: `uv venv` 2. Activate the environment: * On Linux/Unix/macOS or Windows with Git Bash: `source .venv/bin/activate` * On Windows outside of Git Bash: `.venv\Scripts\activate.bat` (in cmd/ps) or `source .venv/Scripts/activate` (in git-bash) 3. Install the required packages with all extras: `uv sync --extra dev` ## Poe Tasks We use poe to execute development tasks: - `poe format` - run code auto-formatters - `poe type-check` - run type checkers ## Testing Tool Executions The Serena tools (and in fact all Serena code) can be executed without an LLM, and also without any MCP specifics (though you can use the mcp inspector, if you want). An example script for running tools is provided in [scripts/demo_run_tools.py](scripts/demo_run_tools.py). ================================================ FILE: DOCKER.md ================================================ # Docker Setup for Serena (Experimental) ⚠️ **EXPERIMENTAL FEATURE**: The Docker setup for Serena is still experimental and has some limitations. Please read this entire document before using Docker with Serena. ## Overview Docker support allows you to run Serena in an isolated container environment, which provides better security isolation for the shell tool and consistent dependencies across different systems. ## Benefits - **Safer shell tool execution**: Commands run in an isolated container environment - **Consistent dependencies**: No need to manage language servers and dependencies on your host system - **Cross-platform support**: Works consistently across Windows, macOS, and Linux ## Important Usage Pointers ### Configuration Serena's configuration and log files are stored in the container in `/workspaces/serena/config/`. Any local configuration you may have for Serena will not apply; the container uses its own separate configuration. You can mount a local configuration/data directory to persist settings across container restarts (which will also contain session log files). Simply mount your local directory to `/workspaces/serena/config` in the container. Initially, be sure to add a `serena_config.yml` file to the mounted directory which applies the following special settings for Docker usage: ``` # Disable the GUI log window since it's not supported in Docker gui_log_window: False # Listen on all interfaces for the web dashboard to be accessible from outside the container web_dashboard_listen_address: 0.0.0.0 # Disable opening the web dashboard on launch (not possible within the container) web_dashboard_open_on_launch: False ``` Set other configuration options as needed. ### Project Activation Limitations - **Only mounted directories work**: Projects must be mounted as volumes to be accessible - Projects outside the mounted directories cannot be activated or accessed - Since projects are not remembered across container restarts (unless you mount a local configuration as described above), activate them using the full path (e.g. `/workspaces/projects/my-project`) when using dynamic project activation ### Language Support Limitations The default Docker image does not include dependencies for languages that require explicit system-level installations. Only languages that install their requirements on the fly will work out of the box. ### Dashboard Port Configuration The web dashboard runs on port 24282 (0x5EDA) by default. You can configure this using environment variables: ```bash # Use default ports docker-compose up serena # Use custom ports SERENA_DASHBOARD_PORT=8080 docker-compose up serena ``` ⚠️ **Note**: If the local port is occupied, you'll need to specify a different port using the environment variable. ### Line Ending Issues on Windows ⚠️ **Windows Users**: Be aware of potential line ending inconsistencies: - Files edited within the Docker container may use Unix line endings (LF) - Your Windows system may expect Windows line endings (CRLF) - This can cause issues with version control and text editors - Configure your Git settings appropriately: `git config core.autocrlf true` ## Quick Start ### Using Docker Compose (Recommended) 1. **Production mode** (for using Serena as MCP server): ```bash docker-compose up serena ``` 2. **Development mode** (with source code mounted): ```bash docker-compose up serena-dev ``` Note: Edit the `compose.yaml` file to customize volume mounts for your projects. ### Building the Docker Image Manually ```bash # Build the image docker build -t serena . # Run with current directory mounted docker run -it --rm \ -v "$(pwd)":/workspace \ -p 9121:9121 \ -p 24282:24282 \ -e SERENA_DOCKER=1 \ serena ``` ### Using Docker Compose with Merge Compose files To use Docker Compose with merge files, you can create a `compose.override.yml` file to customize the configuration: ```yaml services: serena: # To work with projects, you must mount them as volumes: volumes: - ./my-project:/workspace/my-project - /path/to/another/project:/workspace/another-project # Add the context for the IDE assistant option: command: - "uv run --directory . serena-mcp-server --transport sse --port 9121 --host 0.0.0.0 --context claude-code" ``` See the [Docker Merge Compose files documentation](https://docs.docker.com/compose/how-tos/multiple-compose-files/merge/) for more details on using merge files. ## Accessing the Dashboard Once running, access the web dashboard at: - Default: http://localhost:24282/dashboard - Custom port: http://localhost:${SERENA_DASHBOARD_PORT}/dashboard ## Volume Mounting To work with projects, you must mount them as volumes: ```yaml # In compose.yaml volumes: - ./my-project:/workspace/my-project - /path/to/another/project:/workspace/another-project ``` ## Environment Variables - `SERENA_DOCKER=1`: Set automatically to indicate Docker environment - `SERENA_PORT`: MCP server port (default: 9121) - `SERENA_DASHBOARD_PORT`: Web dashboard port (default: 24282) - `INTELEPHENSE_LICENSE_KEY`: License key for Intelephense PHP LSP premium features (optional) ## Troubleshooting ### Port Already in Use If you see "port already in use" errors: ```bash # Check what's using the port lsof -i :24282 # macOS/Linux netstat -ano | findstr :24282 # Windows # Use a different port SERENA_DASHBOARD_PORT=8080 docker-compose up serena ``` ### Configuration Issues If you need to reset Docker configuration: ```bash # Remove Docker-specific config rm serena_config.docker.yml # Serena will auto-generate a new one on next run ``` ### Project Access Issues Ensure projects are properly mounted: - Check volume mounts in `docker-compose.yaml` - Use absolute paths for external projects - Verify permissions on mounted directories ================================================ FILE: Dockerfile ================================================ # Base stage with common dependencies FROM python:3.11-slim AS base SHELL ["/bin/bash", "-c"] # Set environment variables to make Python print directly to the terminal and avoid .pyc files. ENV PYTHONUNBUFFERED=1 ENV PYTHONDONTWRITEBYTECODE=1 # Install system dependencies required for package manager and build tools. # sudo, wget, zip needed for some assistants, like junie RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ build-essential \ git \ ssh \ sudo \ wget \ zip \ unzip \ git \ && rm -rf /var/lib/apt/lists/* # Install pipx. RUN python3 -m pip install --no-cache-dir pipx \ && pipx ensurepath # Install nodejs ENV NVM_VERSION=0.40.3 ENV NODE_VERSION=22.18.0 RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v${NVM_VERSION}/install.sh | bash # standard location ENV NVM_DIR=/root/.nvm RUN . "$NVM_DIR/nvm.sh" && nvm install ${NODE_VERSION} RUN . "$NVM_DIR/nvm.sh" && nvm use v${NODE_VERSION} RUN . "$NVM_DIR/nvm.sh" && nvm alias default v${NODE_VERSION} ENV PATH="${NVM_DIR}/versions/node/v${NODE_VERSION}/bin/:${PATH}" # Add local bin to the path ENV PATH="${PATH}:/root/.local/bin" # Install the latest version of uv RUN curl -LsSf https://astral.sh/uv/install.sh | sh # Install Rust and rustup for rust-analyzer support (minimal profile) ENV RUSTUP_HOME=/usr/local/rustup ENV CARGO_HOME=/usr/local/cargo ENV PATH="${CARGO_HOME}/bin:${PATH}" RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y \ --default-toolchain stable \ --profile minimal \ && rustup component add rust-analyzer # Set the working directory WORKDIR /workspaces/serena # Copy all files for development COPY . /workspaces/serena/ # Install sed RUN apt-get update && apt-get install -y sed # Create Serena configuration ENV SERENA_HOME=/workspaces/serena/config RUN mkdir -p $SERENA_HOME RUN cp src/serena/resources/serena_config.template.yml $SERENA_HOME/serena_config.yml RUN sed -i 's/^gui_log_window: .*/gui_log_window: False/' $SERENA_HOME/serena_config.yml RUN sed -i 's/^web_dashboard_listen_address: .*/web_dashboard_listen_address: 0.0.0.0/' $SERENA_HOME/serena_config.yml RUN sed -i 's/^web_dashboard_open_on_launch: .*/web_dashboard_open_on_launch: False/' $SERENA_HOME/serena_config.yml # Create virtual environment and install dependencies RUN uv venv RUN . .venv/bin/activate RUN uv pip install -r pyproject.toml -e . ENV PATH="/workspaces/serena/.venv/bin:${PATH}" # Entrypoint to ensure environment is activated ENTRYPOINT ["/bin/bash", "-c", "source .venv/bin/activate && $0 $@"] ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2025 Oraios AI Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================

* :rocket: Serena is a powerful **coding agent toolkit** capable of turning an LLM into a fully-featured agent that works **directly on your codebase**. Unlike most other tools, it is not tied to an LLM, framework or an interface, making it easy to use it in a variety of ways. * :wrench: Serena provides essential **semantic code retrieval and editing tools** that are akin to an IDE's capabilities, extracting code entities at the symbol level and exploiting relational structure. When combined with an existing coding agent, these tools greatly enhance (token) efficiency. * :free: Serena is **free & open-source**, enhancing the capabilities of LLMs you already have access to free of charge. You can think of Serena as providing IDE-like tools to your LLM/coding agent. With it, the agent no longer needs to read entire files, perform grep-like searches or basic string replacements to find the right parts of the code and to edit code. Instead, it can use code-centric tools like `find_symbol`, `find_referencing_symbols` and `insert_after_symbol`.

Serena is under active development! See the latest updates, upcoming features, and lessons learned to stay up to date.

Changelog Lessons Learned

> [!TIP] > The [**Serena JetBrains plugin**](#the-serena-jetbrains-plugin) has been released! ## LLM Integration Serena provides the necessary [tools](https://oraios.github.io/serena/01-about/035_tools.html) for coding workflows, but an LLM is required to do the actual work, orchestrating tool use. In general, Serena can be integrated with an LLM in several ways: * by using the **model context protocol (MCP)**. Serena provides an MCP server which integrates with * Claude Code and Claude Desktop, * terminal-based clients like Codex, Gemini-CLI, Qwen3-Coder, rovodev, OpenHands CLI and others, * IDEs like VSCode, Cursor or IntelliJ, * Extensions like Cline or Roo Code * Local clients like [OpenWebUI](https://docs.openwebui.com/openapi-servers/mcp), [Jan](https://jan.ai/docs/mcp-examples/browser/browserbase#enable-mcp), [Agno](https://docs.agno.com/introduction/playground) and others * by using [mcpo to connect it to ChatGPT](docs/03-special-guides/serena_on_chatgpt.md) or other clients that don't support MCP but do support tool calling via OpenAPI. * by incorporating Serena's tools into an agent framework of your choice, as illustrated [here](docs/03-special-guides/custom_agent.md). Serena's tool implementation is decoupled from the framework-specific code and can thus easily be adapted to any agent framework. ## Serena in Action #### Demonstration 1: Efficient Operation in Claude Code A demonstration of Serena efficiently retrieving and editing code within Claude Code, thereby saving tokens and time. Efficient operations are not only useful for saving costs, but also for generally improving the generated code's quality. This effect may be less pronounced in very small projects, but often becomes of crucial importance in larger ones. https://github.com/user-attachments/assets/ab78ebe0-f77d-43cc-879a-cc399efefd87 #### Demonstration 2: Serena in Claude Desktop A demonstration of Serena implementing a small feature for itself (a better log GUI) with Claude Desktop. Note how Serena's tools enable Claude to find and edit the right symbols. https://github.com/user-attachments/assets/6eaa9aa1-610d-4723-a2d6-bf1e487ba753 ## Programming Language Support & Semantic Analysis Capabilities Serena provides a set of versatile code querying and editing functionalities based on symbolic understanding of the code. Equipped with these capabilities, Serena discovers and edits code just like a seasoned developer making use of an IDE's capabilities would. Serena can efficiently find the right context and do the right thing even in very large and complex projects! There are two alternative technologies powering these capabilities: * **Language servers** implementing the language server Protocol (LSP) — the free/open-source alternative. * **The Serena JetBrains Plugin**, which leverages the powerful code analysis and editing capabilities of your JetBrains IDE. You can choose either of these backends depending on your preferences and requirements. ### Language Servers Serena incorporates a powerful abstraction layer for the integration of language servers that implement the language server protocol (LSP). The underlying language servers are typically open-source projects (like Serena) or at least freely available for use. With Serena's LSP library, we provide **support for over 30 programming languages**, including AL, Ansible, Bash, C#, C/C++, Clojure, Dart, Elixir, Elm, Erlang, Fortran, GLSL, Go, Groovy (partial support), Haskell, HLSL, Java, Javascript, Julia, Kotlin, Lean 4, Lua, Luau, Markdown, MATLAB, Nix, OCaml, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Solidity, Swift, TOML, TypeScript, WGSL, YAML, and Zig. > [!IMPORTANT] > Some language servers require additional dependencies to be installed; see the [Language Support](https://oraios.github.io/serena/01-about/020_programming-languages.html) page for details. ### The Serena JetBrains Plugin As an alternative to language servers, the [Serena JetBrains Plugin](https://plugins.jetbrains.com/plugin/28946-serena/) leverages the powerful code analysis capabilities of your JetBrains IDE. The plugin naturally supports all programming languages and frameworks that are supported by JetBrains IDEs, including IntelliJ IDEA, PyCharm, Android Studio, WebStorm, PhpStorm, RubyMine, GoLand, and potentially others (Rider and CLion are unsupported though). The plugin offers the most robust and most powerful Serena experience. See our [documentation page](https://oraios.github.io/serena/02-usage/025_jetbrains_plugin.html) for further details and instructions. ## Quick Start **Prerequisites**. Serena is managed by *uv*. If you don’t already have it, you need to [install uv](https://docs.astral.sh/uv/getting-started/installation/) before proceeding. **Starting the MCP Server**. The easiest way to start the Serena MCP server is by running the latest version from GitHub using uvx. Issue this command to see available options: ```bash uvx --from git+https://github.com/oraios/serena serena start-mcp-server --help ``` **Configuring Your Client**. To connect Serena to your preferred MCP client, you typically need to [configure a launch command in your client](https://oraios.github.io/serena/02-usage/030_clients.html). Follow the link for specific instructions on how to set up Serena for Claude Code, Codex, Claude Desktop, MCP-enabled IDEs and other clients (such as local and web-based GUIs). > [!TIP] > While getting started quickly is easy, Serena is a powerful toolkit with many configuration options. > We highly recommend reading through the [user guide](https://oraios.github.io/serena/02-usage/000_intro.html) to get the most out of Serena. > > Specifically, we recommend to read about ... > * [Serena's project-based workflow](https://oraios.github.io/serena/02-usage/040_workflow.html) and > * [configuring Serena](https://oraios.github.io/serena/02-usage/050_configuration.html). ## User Guide Please refer to the [user guide](https://oraios.github.io/serena/02-usage/000_intro.html) for detailed instructions on how to use Serena effectively. ## Community Feedback Most users report that Serena has strong positive effects on the results of their coding agents, even when used within very capable agents like Claude Code. Serena is often described to be a [game changer](https://www.reddit.com/r/ClaudeAI/comments/1lfsdll/try_out_serena_mcp_thank_me_later/), providing an enormous [productivity boost](https://www.reddit.com/r/ClaudeCode/comments/1mguoia/absolutely_insane_improvement_of_claude_code). Serena excels at navigating and manipulating complex codebases, providing tools that support precise code retrieval and editing in the presence of large, strongly structured codebases. However, when dealing with tasks that involve only very few/small files, you may not benefit from including Serena on top of your existing coding agent. In particular, when writing code from scratch, Serena will not provide much value initially, as the more complex structures that Serena handles more gracefully than simplistic, file-based approaches are yet to be created. Several videos and blog posts have talked about Serena: * YouTube: * [AI Labs](https://www.youtube.com/watch?v=wYWyJNs1HVk&t=1s) * [Yo Van Eyck](https://www.youtube.com/watch?v=UqfxuQKuMo8&t=45s) * [JeredBlu](https://www.youtube.com/watch?v=fzPnM3ySmjE&t=32s) * Blog posts: * [Serena's Design Principles](https://medium.com/@souradip1000/deconstructing-serenas-mcp-powered-semantic-code-understanding-architecture-75802515d116) * [Serena with Claude Code (in Japanese)](https://blog.lai.so/serena/) * [Turning Claude Code into a Development Powerhouse](https://robertmarshall.dev/blog/turning-claude-code-into-a-development-powerhouse/) ## Acknowledgements ### Sponsors We are very grateful to our [sponsors](https://github.com/sponsors/oraios) who help us drive Serena's development. The core team (the founders of [Oraios AI](https://oraios-ai.de/)) put in a lot of work in order to turn Serena into a useful open source project. So far, there is no business model behind this project, and sponsors are our only source of income from it. Sponsors help us dedicating more time to the project, managing contributions, and working on larger features (like better tooling based on more advanced LSP features, VSCode integration, debugging via the DAP, and several others). If you find this project useful to your work, or would like to accelerate the development of Serena, consider becoming a sponsor. We are proud to announce that the Visual Studio Code team, together with Microsoft’s Open Source Programs Office and GitHub Open Source have decided to sponsor Serena with a one-time contribution!

Visual Studio Code sponsor logo

### Community Contributions A significant part of Serena, especially support for various languages, was contributed by the open source community. We are very grateful for the many contributors who made this possible and who played an important role in making Serena what it is today. ### Technologies We built Serena on top of multiple existing open-source technologies, the most important ones being: 1. [multilspy](https://github.com/microsoft/multilspy). A library which wraps language server implementations and adapts them for interaction via Python. It provided the basis for our library Solid-LSP (`src/solidlsp`). Solid-LSP provides pure synchronous LSP calls and extends the original library with the symbolic logic that Serena required. 2. [Python MCP SDK](https://github.com/modelcontextprotocol/python-sdk) 3. All the language servers that we use through Solid-LSP. Without these projects, Serena would not have been possible (or would have been significantly more difficult to build). ## Customizing and Extending Serena It is straightforward to extend Serena's AI functionality with your own ideas. Simply implement a new tool by subclassing `serena.agent.Tool` and implement the `apply` method with a signature that matches the tool's requirements. Once implemented, `SerenaAgent` will automatically have access to the new tool. It is also relatively straightforward to add [support for a new programming language](/.serena/memories/adding_new_language_support_guide.md). We look forward to seeing what the community will come up with! For details on contributing, see [contributing guidelines](/CONTRIBUTING.md). ================================================ FILE: compose.yaml ================================================ services: serena: image: serena:latest # To work with projects, you must mount them into /workspace/ in the container: # volumes: # - ./my-project:/workspace/my-project # - /path/to/another/project:/workspace/another-project build: context: ./ dockerfile: Dockerfile target: production ports: - "${SERENA_PORT:-9121}:9121" # MCP server port - "${SERENA_DASHBOARD_PORT:-24282}:24282" # Dashboard port (default 0x5EDA = 24282) environment: - SERENA_DOCKER=1 command: - "uv run --directory . serena-mcp-server --transport sse --port 9121 --host 0.0.0.0" # Alternatively add further arguments, e.g. a context # - "uv run --directory . serena-mcp-server --transport sse --port 9121 --host 0.0.0.0 --context ide" ================================================ FILE: docker_build_and_run.sh ================================================ #!/usr/bin/bash docker build -t serena . docker run -it --rm -v "$(pwd)":/workspace serena ================================================ FILE: docs/.gitignore ================================================ /_toc.yml /jupyter_execute /conf.py /_build ================================================ FILE: docs/01-about/.gitignore ================================================ /035_tools.md ================================================ FILE: docs/01-about/000_intro.md ================================================ # About Serena * Serena is a powerful **coding agent toolkit** capable of turning an LLM into a fully-featured agent that works **directly on your codebase**. Unlike most other tools, it is not tied to an LLM, framework or an interface, making it easy to use it in a variety of ways. * Serena provides essential **semantic code retrieval and editing tools** that are akin to an IDE's capabilities, extracting code entities at the symbol level and exploiting relational structure. When combined with an existing coding agent, these tools greatly enhance (token) efficiency. * Serena is **free & open-source**, enhancing the capabilities of LLMs you already have access to free of charge. Therefore, you can think of Serena as providing IDE-like tools to your LLM/coding agent. With it, the agent no longer needs to read entire files, perform grep-like searches or string replacements to find and edit the right code. Instead, it can use code-centred tools like `find_symbol`, `find_referencing_symbols` and `insert_after_symbol`. ================================================ FILE: docs/01-about/010_llm-integration.md ================================================ # LLM Integration Serena provides the necessary [tools](035_tools) for coding workflows, but an LLM is required to do the actual work, orchestrating tool use. In general, Serena can be integrated with an LLM in several ways: * by using the **model context protocol (MCP)**. Serena provides an MCP server which integrates with * Claude Code and Claude Desktop, * Terminal-based clients like Codex, Gemini-CLI, Qwen3-Coder, rovodev, OpenHands CLI and others, * IDEs like VSCode, Cursor or IntelliJ, * Extensions like Cline or Roo Code * Local clients like [OpenWebUI](https://docs.openwebui.com/openapi-servers/mcp), [Jan](https://jan.ai/docs/mcp-examples/browser/browserbase#enable-mcp), [Agno](https://docs.agno.com/introduction/playground) and others * by using [mcpo to connect it to ChatGPT](../03-special-guides/serena_on_chatgpt.md) or other clients that don't support MCP but do support tool calling via OpenAPI. * by incorporating Serena's tools into an agent framework of your choice, as illustrated [here](../03-special-guides/custom_agent). Serena's tool implementation is decoupled from the framework-specific code and can thus easily be adapted to any agent framework. ================================================ FILE: docs/01-about/020_programming-languages.md ================================================ # Language Support Serena provides a set of versatile code querying and editing functionalities based on symbolic understanding of the code. Equipped with these capabilities, Serena discovers and edits code just like a seasoned developer making use of an IDE's capabilities would. Serena can efficiently find the right context and do the right thing even in very large and complex projects! There are two alternative technologies powering these capabilities: * **Language servers** implementing the language server Protocol (LSP) — the free/open-source alternative. * **The Serena JetBrains Plugin**, which leverages the powerful code analysis and editing capabilities of your JetBrains IDE. You can choose either of these backends depending on your preferences and requirements. ## Language Servers Serena incorporates a powerful abstraction layer for the integration of language servers that implement the language server protocol (LSP). It even supports multiple language servers in parallel to support polyglot projects. The language servers themselves are typically open-source projects (like Serena) or at least freely available for use. We currently provide direct, out-of-the-box support for the programming languages listed below. Some languages require additional installations or setup steps, as noted. * **AL** * **Ansible** (experimental; requires Node.js and npm; automatically installs `@ansible/ansible-language-server`; must be explicitly specified in the `languages` entry in the `project.yml`; requires `ansible` in PATH for full functionality) the upstream `@ansible/ansible-language-server@1.2.3` supports hover, completion, definition, semantic tokens, and validation; document symbols, workspace symbols, references, and rename are not supported by this version) * **Bash** * **C#** (by default, uses the Roslyn language server (language `csharp`), requiring [.NET v10+](https://dotnet.microsoft.com/en-us/download/dotnet) and, on Windows, `pwsh` ([PowerShell 7+](https://learn.microsoft.com/en-us/powershell/scripting/install/install-powershell-on-windows?view=powershell-7.5)); set language to `csharp_omnisharp` to use OmiSharp instead) * **C/C++** (by default, uses the clangd language server (language `cpp`) but we also support ccls (language `cpp_ccls`); for best results, provide a `compile_commands.json` at the repository root; see the [C/C++ Setup Guide](../03-special-guides/cpp_setup) for details.) * **Clojure** * **Dart** * **Elixir** (requires Elixir installation; Expert language server is downloaded automatically) * **Elm** (requires Elm compiler) * **Erlang** (requires installation of beam and [erlang_ls](https://github.com/erlang-ls/erlang_ls); experimental, might be slow or hang) * **F#** (requires [.NET v8.0+](https://dotnet.microsoft.com/en-us/download/dotnet); uses FsAutoComplete/Ionide, which is auto-installed; for Homebrew .NET on macOS, set DOTNET_ROOT in your environment) * **Fortran** (requires installation of fortls: `pip install fortls`) * **Go** (requires installation of `gopls`) * **Groovy** (requires local groovy-language-server.jar setup via `GROOVY_LS_JAR_PATH` or configuration) * **Haskell** (automatically locates HLS via ghcup, stack, or system PATH; supports Stack and Cabal projects) * **HLSL / GLSL / WGSL** (uses [shader-language-server](https://github.com/antaalt/shader-sense) (language `hlsl`); automatically downloaded; on macOS, requires Rust toolchain for building from source; note: reference search is not supported by this language server) * **Java** * **JavaScript** (supported via the TypeScript language server, i.e. use language `typescript` for both JavaScript and TypeScript) * **Julia** * **Kotlin** (uses the pre-alpha [official kotlin LS](https://github.com/Kotlin/kotlin-lsp), some issues may appear) * **Lean 4** (requires `lean` and `lake` installed via [elan](https://github.com/leanprover/elan); uses the built-in Lean 4 LSP; the project must be a Lake project with `lake build` run before use) * **Lua** * **Luau** * **Markdown** (must explicitly enable language `markdown`, primarily useful for documentation-heavy projects) * **Nix** (requires nixd installation) * **OCaml** (requires opam and ocaml-lsp-server to be installed manually; see the [OCaml Setup Guide](../03-special-guides/ocaml_setup_guide_for_serena.md)) * **Pascal** (uses Pascal/Lazarus, which is automatically downloaded; set `PP` and `FPCDIR` environment variables for source navigation) * **Perl** (requires installation of Perl::LanguageServer) * **PHP** (by default, uses the Intelephense language server (language `php`), set `INTELEPHENSE_LICENSE_KEY` environment variable for premium features; we also support [Phpactor](https://github.com/phpactor/phpactor) (language `php_phpactor`), which requires PHP 8.1+) * **Python** * **R** (requires installation of the `languageserver` R package) * **Ruby** (by default, uses [ruby-lsp](https://github.com/Shopify/ruby-lsp) (language `ruby`); use language `ruby_solargraph` to use Solargraph instead.) * **Rust** (requires [rustup](https://rustup.rs/) - uses rust-analyzer from your toolchain) * **Scala** (requires some [manual setup](../03-special-guides/scala_setup_guide_for_serena); uses Metals LSP) * **Solidity** (experimental; requires Node.js and npm; automatically installs `@nomicfoundation/solidity-language-server`; works best with a `foundry.toml` or `hardhat.config.js` in the project root) * **Swift** * **TypeScript** * **Vue** (3.x with TypeScript; requires Node.js v18+ and npm; supports .vue Single File Components with monorepo detection) * **YAML** * **Zig** (requires installation of ZLS - Zig Language Server) Support for further languages can easily be added by providing a shallow adapter for a new language server implementation, see Serena's [memory on that](https://github.com/oraios/serena/blob/main/.serena/memories/adding_new_language_support_guide.md). ## The Serena JetBrains Plugin As an alternative to language servers, the [Serena JetBrains Plugin](https://plugins.jetbrains.com/plugin/28946-serena/) leverages the powerful code analysis capabilities of JetBrains IDEs. The plugin naturally supports all programming languages and frameworks that are supported by JetBrains IDEs, including IntelliJ IDEA, PyCharm, Android Studio, WebStorm, PhpStorm, RubyMine, GoLand, and potentially others (Rider and CLion are unsupported though). When using the plugin, Serena connects to an instance of your JetBrains IDE via the plugin. For users who already work in a JetBrains IDE, this means Serena seamlessly integrates with the IDE instance you typically have open anyway, requiring no additional setup or configuration beyond the plugin itself. This approach offers several key advantages: * **External library indexing**: Dependencies and libraries are fully indexed and accessible to Serena * **No additional setup**: No need to download or configure separate language servers * **Enhanced performance**: Faster tool execution thanks to optimized IDE integration * **Multi-language excellence**: First-class support for polyglot projects with multiple languages and frameworks Even if you prefer to work in a different code editor, you can still benefit from the JetBrains plugin by running a JetBrains IDE instance (most have free community editions) alongside your preferred editor with your project opened and indexed. Serena will connect to the IDE for code analysis while you continue working in your editor of choice. ```{raw} html

``` See the [JetBrains Plugin documentation](../02-usage/025_jetbrains_plugin) for usage details. ================================================ FILE: docs/01-about/030_serena-in-action.md ================================================ # Serena in Action ## Demonstration 1: Efficient Operation in Claude Code A demonstration of Serena efficiently retrieving and editing code within Claude Code, thereby saving tokens and time. Efficient operations are not only useful for saving costs, but also for generally improving the generated code's quality. This effect may be less pronounced in very small projects, but often becomes of crucial importance in larger ones. ## Demonstration 2: Serena in Claude Desktop A demonstration of Serena implementing a small feature for itself (a better log GUI) with Claude Desktop. Note how Serena's tools enable Claude to find and edit the right symbols. ================================================ FILE: docs/01-about/040_comparison-to-other-agents.md ================================================ # Comparison with Other Coding Agents To our knowledge, Serena is the first fully-featured coding agent where the entire functionality is made available through an MCP server, thus not requiring additional API keys or subscriptions if access to an LLM is already available through an MCP-compatible client. ## Subscription-Based Coding Agents Many prominent subscription-based coding agents are parts of IDEs like Windsurf, Cursor and VSCode. Serena's functionality is similar to Cursor's Agent, Windsurf's Cascade or VSCode's agent mode. Serena has the advantage of not requiring a subscription. More technical differences are: * Serena navigates and edits code using a language server, so it has a symbolic understanding of the code. IDE-based tools often use a text search-based or purely text file-based approach, which is often less powerful, especially for large codebases. * Serena is not bound to a specific interface (IDE or CLI). Serena's MCP server can be used with any MCP client (including some IDEs). * Serena is not bound to a specific large language model or API. * Serena is open-source and has a small codebase, so it can be easily extended and modified. ## API-Based Coding Agents An alternative to subscription-based agents are API-based agents like Claude Code, Cline, Aider, Roo Code and others, where the usage costs map directly to the API costs of the underlying LLM. Some of them (like Cline) can even be included in IDEs as an extension. They are often very powerful and their main downside are the (potentially very high) API costs. Serena itself can be used as an API-based agent (see the [section on Agno](../03-special-guides/custom_agent.md)). The main difference between Serena and other API-based agents is that Serena can also be used as an MCP server, thus not requiring an API key and bypassing the API costs. ## Other MCP-Based Coding Agents There are other MCP servers designed for coding, like [DesktopCommander](https://github.com/wonderwhy-er/DesktopCommanderMCP) and [codemcp](https://github.com/ezyang/codemcp). However, to the best of our knowledge, none of them provide semantic code retrieval and editing tools; they rely purely on text-based analysis. It is the integration of language servers and the MCP that makes Serena unique and so powerful for challenging coding tasks, especially in the context of larger codebases. ================================================ FILE: docs/01-about/050_acknowledgements.md ================================================ # Acknowledgements ## Sponsors We are very grateful to our [sponsors](https://github.com/sponsors/oraios), who help us drive Serena's development. The core team (the founders of [Oraios AI](https://oraios-ai.de/)) put in a lot of work in order to turn Serena into a useful open source project. So far, there is no business model behind this project, and sponsors are our only source of income from it. Sponsors help us dedicate more time to the project, managing contributions, and working on larger features (like better tooling based on more advanced LSP features, VSCode integration, debugging via the DAP, and several others). If you find this project useful to your work, or would like to accelerate the development of Serena, consider becoming a sponsor. We are proud to announce that the Visual Studio Code team, together with Microsoft’s Open Source Programs Office and GitHub Open Source have decided to sponsor Serena with a one-time contribution! ## Community Contributions A significant part of Serena, especially support for various languages, was contributed by the open source community. We are very grateful for the many contributors who made this possible and who played an important role in making Serena what it is today. ## Technologies We built Serena on top of multiple existing open-source technologies, the most important ones being: 1. [multilspy](https://github.com/microsoft/multilspy). A library which wraps language server implementations and adapts them for interaction via Python and which provided the basis for our library Solid-LSP (src/solidlsp). Solid-LSP provides pure synchronous LSP calls and extends the original library with the symbolic logic that Serena required. 2. [Python MCP SDK](https://github.com/modelcontextprotocol/python-sdk) 3. All the language servers that we use through Solid-LSP. Without these projects, Serena would not have been possible (or would have been significantly more difficult to build). ================================================ FILE: docs/02-usage/000_intro.md ================================================ # Usage Serena can be used in various ways and supports coding workflows through a project-based approach. Its configuration is flexible and allows tailoring it to your specific needs. In this section, you will find general usage instructions as well as concrete instructions for selected integrations. ================================================ FILE: docs/02-usage/010_prerequisites.md ================================================ # Prerequisites ## Package Manager: uv Serena is managed by `uv`. If you do not have it yet, install it following the instructions [here](https://docs.astral.sh/uv/getting-started/installation/). ## Language-Specific Requirements Depending on the programming language you intend to use with Serena, you may need to install additional tools or SDKs if you intend to use the language server backend of Serena. See the [language support documentation](../01-about/020_programming-languages) for details. ================================================ FILE: docs/02-usage/020_running.md ================================================ # Running Serena Serena is a command-line tool with a variety of sub-commands. This section describes * various ways of running Serena * how to run and configure the most important command, i.e. starting the MCP server * other useful commands. ## Ways of Running Serena In the following, we will refer to the command used to run Serena as ``, which you should replace with the appropriate command based on your chosen method, as detailed below. In general, to get help, append `--help` to the command, i.e. --help --help ### Using uvx `uvx` is part of `uv`. It can be used to run the latest version of Serena directly from the repository, without an explicit local installation. uvx --from git+https://github.com/oraios/serena serena Explore the CLI to see some of the customization options that serena provides (more info on them below). ### Local Installation 1. Clone the repository and change into it. ```shell git clone https://github.com/oraios/serena cd serena ``` 2. Run Serena via ```shell uv run serena ``` when within the serena installation directory. From other directories, run it with the `--directory` option, i.e. ```shell uv run --directory /abs/path/to/serena serena ``` :::{note} Adding the `--directory` option results in the working directory being set to the Serena directory. As a consequence, you will need to specify paths when using CLI commands that would otherwise operate on the current directory. ::: (docker)= ### Using Docker The Docker approach offers several advantages: * better security isolation for shell command execution * no need to install language servers and dependencies locally * consistent environment across different systems You can run the Serena MCP server directly via Docker as follows, assuming that the projects you want to work on are all located in `/path/to/your/projects`: ```shell docker run --rm -i --network host -v /path/to/your/projects:/workspaces/projects ghcr.io/oraios/serena:latest serena ``` This command mounts your projects into the container under `/workspaces/projects`, so when working with projects, you need to refer to them using the respective path (e.g. `/workspaces/projects/my-project`). Alternatively, you may use Docker compose with the `compose.yml` file provided in the repository. See our [advanced Docker usage](https://github.com/oraios/serena/blob/main/DOCKER.md) documentation for more detailed instructions, configuration options, and limitations. :::{note} Docker usage is subject to limitations; see the [advanced Docker usage](https://github.com/oraios/serena/blob/main/DOCKER.md) documentation for details. ::: ### Using Nix If you are using Nix and [have enabled the `nix-command` and `flakes` features](https://nixos.wiki/wiki/flakes), you can run Serena using the following command: ```bash nix run github:oraios/serena -- [options] ``` You can also install Serena by referencing this repo (`github:oraios/serena`) and using it in your Nix flake. The package is exported as `serena`. (start-mcp-server)= ## Running the MCP Server Given your preferred method of running Serena, you can start the MCP server using the `start-mcp-server` command: start-mcp-server [options] Note that no matter how you run the MCP server, Serena will, by default, start a web-based dashboard on localhost that will allow you to inspect the server's operations, logs, and configuration. :::{tip} By default, Serena will use language servers for code understanding and analysis. With the [Serena JetBrains Plugin](025_jetbrains_plugin), we recently introduced a powerful alternative, which has several advantages over the language server-based approach. ::: ### Standard I/O Mode The typical usage involves the client (e.g. Claude Code, Codex or Cursor) running the MCP server as a subprocess and using the process' stdin/stdout streams to communicate with it. In order to launch the server, the client thus needs to be provided with the command to run the MCP server. :::{note} MCP servers which use stdio as a protocol are somewhat unusual as far as client/server architectures go, as the server necessarily has to be started by the client in order for communication to take place via the server's standard input/output streams. In other words, you do not need to start the server yourself. The client application (e.g. Claude Desktop) takes care of this and therefore needs to be configured with a launch command. ::: Communication over stdio is the default for the Serena MCP server, so in the simplest case, you can simply run the `start-mcp-server` command without any additional options. start-mcp-server For example, to run the server in stdio mode via `uvx`, you would run: uvx --from git+https://github.com/oraios/serena serena start-mcp-server See the section ["Configuring Your MCP Client"](030_clients) for specific information on how to configure your MCP client (e.g. Claude Code, Codex, Cursor, etc.) to use such a launch command. (streamable-http)= ### Streamable HTTP Mode When using *Streamable HTTP* mode, you control the server lifecycle yourself, i.e. you start the server and provide the client with the URL to connect to it. Simply provide `start-mcp-server` with the `--transport streamable-http` option and optionally provide the desired port via the `--port` option. start-mcp-server --transport streamable-http --port For example, to run the Serena MCP server in streamable HTTP mode on port 9121 using uvx, you would run uvx --from git+https://github.com/oraios/serena serena start-mcp-server --transport streamable-http --port 9121 and then configure your client to connect to `http://localhost:9121/mcp`. **When to use.** Note that Serena is a stateful MCP server, and only one coding project can be active at a time. Therefore, starting a single Serena instance and connecting it to multiple clients is only appropriate if all clients will be working on the same project. If you want several agents to work on different projects, making each client/agent start its own server in stdio mode is likely the best option. See section [The Project Workflow](040_workflow) for more information on how to manage projects in Serena. The legacy SSE transport is also supported (via `--transport sse` with corresponding /sse endpoint), its use is discouraged. (mcp-args)= ### MCP Server Command-Line Arguments The Serena MCP server supports a wide range of additional command-line options. Use the command start-mcp-server --help to get a list of all available options. Some useful options include: * `--project `: specify the project to work on by name or path. * `--project-from-cwd`: auto-detect the project from current working directory (looking for a directory containing `.serena/project.yml` or `.git` in parent directories and activating the containing directory as the project root, if any). This option is intended for CLI-based agents like Claude Code, Gemini and Codex, which are typically started from within the project directory and which do not change directories during their operation. * `--language-backend JetBrains`: use the Serena JetBrains Plugin as the language backend (overriding the default backend configured in the central configuration) * `--context `: specify the operation [context](contexts) in which Serena shall operate * `--mode `: specify one or more [modes](modes) to enable (can be passed several times) * `--open-web-dashboard `: whether to open the web dashboard on startup (enabled by default) ## Other Commands Serena provides several other commands in addition to `start-mcp-server`, most of which are related to project setup and configuration. To get a list of available commands, run: --help To get help on a specific command, run: --help In general, add `--help` to any command or sub-command to get information about its usage and available options. Here are some examples of commands you might find useful: ```bash # get help about a sub-command tools list --help # list all available tools tools list --all # get detailed description of a specific tool tools description find_symbol # creating a new Serena project in the current directory project create # creating and immediately indexing a project project create --index # indexing the project in the current directory (auto-creates if needed) project index # run a health check on the project in the current directory project health-check # check if a path is ignored by the project project is_ignored_path path/to/check # edit Serena's configuration file config edit # list available contexts context list # create a new context context create my-custom-context # edit a custom context context edit my-custom-context # list available modes mode list # create a new mode mode create my-custom-mode # edit a custom mode mode edit my-custom-mode # list available prompt definitions prompts list # create an override for internal prompts prompts create-override prompt-name # edit a prompt override prompts edit-override prompt-name ``` Explore the full set of commands and options using the CLI itself! ================================================ FILE: docs/02-usage/025_jetbrains_plugin.md ================================================ # The Serena JetBrains Plugin The [JetBrains Plugin](https://plugins.jetbrains.com/plugin/28946-serena/) allows Serena to leverage the powerful code analysis and editing capabilities of your JetBrains IDE. ```{raw} html

``` We recommend the JetBrains plugin as the preferred way of using Serena, especially for users of JetBrains IDEs. **Purchasing the JetBrains Plugin supports the Serena project.** The proceeds from plugin sales allow us to dedicate more resources to further developing and improving Serena. ## Advantages of the JetBrains Plugin There are multiple features that are only available when using the JetBrains plugin: * **External library indexing**: Dependencies and libraries are fully indexed and accessible to Serena * **No additional setup**: No need to download or configure separate language servers * **Enhanced performance**: Faster tool execution thanks to optimized IDE integration * **Multi-language excellence**: First-class support for polyglot projects with multiple languages and frameworks * **Enhanced retrieval capabilities**: The plugin supports additional retrieval tools for type hierarchy information as well as fast and reliable documentation/type signature retrieval We are also working on additional features like a `move_symbol` tool and debugging-related capabilities that will be available exclusively through the JetBrains plugin. ## Configuring Serena to Use the JetBrains Plugin After installing the plugin, you need to configure Serena to use it. **Central Configuration**. Edit the global Serena configuration file located at `~/.serena/serena_config.yml` (`%USERPROFILE%\.serena\serena_config.yml` on Windows). Change the `language_backend` setting as follows: ```yaml language_backend: JetBrains ``` *Note*: you can also use the button `Edit Global Serena Config` in the Serena MCP dashboard to open the config file in your default editor. **Per-Instance Configuration**. The configuration setting in the global config file can be overridden on a per-instance basis by providing the arguments `--language-backend JetBrains` when launching the Serena MCP server. (per-project-language-backend)= **Per-Project Configuration**. You can also set the language backend on a per-project basis in the project's `.serena/project.yml` file: ```yaml language_backend: JetBrains ``` If set, this overrides the global `language_backend` setting for the session when the project is activated at startup (via the `--project` flag). :::{important} The language backend is determined once at startup and cannot be changed during a running session. If a project with a different backend is activated after startup, Serena will return an error. If you need to work with projects that use different backends, you can either: 1. Use the `--project` flag to activate the project at startup, which will use its configured backend. 2. Configure separate MCP server instances (one per backend) in your client. ::: **Verifying the Setup**. You can verify that Serena is using the JetBrains plugin by either checking the dashboard, where you will see `Languages: Using JetBrains backend` in the configuration overview. You will also notice that your client will use the JetBrains-specific tools like `jet_brains_find_symbol` and others like it. ## Workflow Having installed the plugin in your IDE and having configured Serena to use the JetBrains backend, the general workflow is simple: 1. Open the project you want to work on in your JetBrains IDE 2. Open the project's root folder as a project in Serena (see [Project Creation](project-creation-indexing) and [Project Activation](project-activation)) 3. Start using Serena tools as usual Note that the folder that is open in your IDE and the project's root folder must match. :::{tip} If you need to work on multiple projects in the same agent session, create a monorepo folder containing all the projects and open that folder in both Serena and your IDE. ::: ## Advanced Usage and Configuration ### Using Serena with Multi-Module Projects JetBrains IDEs support *multi-module projects*, where a project can reference other projects as modules. Serena, however, requires that a project is self-contained within a single root folder. There has to be a one-to-one relationship between the project root folder and the folder that is open in the IDE. Therefore, to get a multi-module setup working with Serena, the recommended approach is to create a **monorepo folder**, i.e. a folder that contains all the projects as sub-folders, and open that monorepo folder in both Serena and your IDE. You do not necessarily need to physically move your projects into a common parent folder; you can also use symbolic links to achieve the same effect (i.e. use `mklink` on Windows or `ln` on Linux/macOS to link the project folders into a common parent folder). ### Using Serena with Windows Subsystem for Linux (WSL) JetBrains IDEs have built-in support for WSL, allowing you to run the IDE on Windows while working with code in the WSL environment. The Serena JetBrains plugin works seamlessly in this setup as well. #### Using JetBrains Remote Development Recommended constellation: * Your project is in the WSL file system * Serena is run in WSL (not Windows) * The IDE has a host component (in WSL) and a client component (on Windows). The Serena JetBrains plugin should normally be **installed in the host** (not the client) for code intelligence to be accessible. :::{admonition} Plugin Installation Location :class: note If the plugin is already installed, check the options on the button for disabling the plugin. Choose the respective options to ensure the correct installation location (i.e. host, removing it from the client if necessary). ::: :::{admonition} Using mapped Windows paths in WSL is not recommended! :class: warning Keeping your project in the Windows file system and accessing it via `/mnt/` in WSL is extremely slow and not recommended. ::: **Special Network Setup**. If you are using a special setup where Serena and the IDE are running on different machines, make sure Serena can communicate with the JetBrains plugin. You can configure `jetbrains_plugin_server_address` in your [serena_config.yml](050_configuration) and configure the listen address of the JetBrains plugin in the IDE via Settings / Tools / Serena (e.g. set it to 0.0.0.0 to listen on all interfaces, but be aware of the security implications of doing so). #### Other WSL Integrations (e.g. WSL interpreter) * Your project is in the Windows file system * WSL is used only for running tools (e.g. using a WSL Python interpreter in the IDE) * Serena, the IDE and the plugin are all running on Windows In this constellation, no special setup is required. ## Serena Plugin Configuration Options You can configure plugin options in the IDE under Settings / Tools / Serena. * **Listen address** (default: `127.0.0.1`) the address the plugin's server listens on. The default will work as long as Serena is running on the same machine (or on a virtual machine using mirrored networking). But if the Serena MCP server is running on a different machine, configure the listen address to ensure that connections are possible. You can use `0.0.0.0` to listen on all interfaces (but be aware of the security implications of doing so). * **Sync file system before every operation** (default: enabled) whether to synchronise the file system state before processing requests from Serena. This is important to ensure that the plugin does not read stale data, but it can have a performance impact, especially when using slow file systems (e.g. WSL file system while the IDE is running on Windows). Note, however, that without synchronisation being forced by the Serena plugin, you will have to ensure synchronisation yourself. Operations that apply changes to files in your project that are *not* made either in the IDE itself or by Serena may not be seen by the IDE. Normally, the IDE synchronises automatically when it has the focus, using file watchers to achieve this (though this may or may not work reliably for the WSL file system). Also, if you are working primarily in another application (e.g. AI chat), the IDE may not have the focus frequently. So when external changes are made to your project, you will have to either give the IDE the focus (if that works) or trigger a sync manually (right-click root folder / Reload from Disk). Further, note that even an edit made using, for example, Claude Code's internal editing tools would count as an external modification. Only Serena's editing tools are "JetBrains-aware" and will tell the IDE to update the state of the edited file. So if you are making AI-based edits using tools other than Serena's tools, do make sure that the lack of synchronisation is not a problem if you decide to disable this option. ## Usage with Other Editors We realize that not everyone uses a JetBrains IDE as their main code editor. You can still take advantage of the JetBrains plugin by running a JetBrains IDE instance alongside your preferred editor. Most JetBrains IDEs have a free community edition that you can use for this purpose. You just need to make sure that the project you are working on is open and indexed in the JetBrains IDE, so that Serena can connect to it. ================================================ FILE: docs/02-usage/030_clients.md ================================================ # Connecting Your MCP Client In the following, we provide general instructions on how to connect Serena to your MCP-enabled client, as well as specific instructions for popular clients. :::{note} The configurations we provide for particular clients below will run the latest version of Serena using the `stdio` protocol with `uvx`. Adapt the commands to your preferred way of [running Serena](020_running), adding any additional command-line arguments as needed. ::: (clients-general-instructions)= ## General Instructions In general, Serena can be used with any MCP-enabled client. To connect Serena to your favourite client, simply 1. determine how to add a custom MCP server to your client (refer to the client's documentation). 2. add a new MCP server entry by specifying either * a [run command](start-mcp-server) that allows the client to start the MCP server in stdio mode as a subprocess, or * the URL of the HTTP/SSE endpoint, having started the [Serena MCP server in HTTP/SSE mode](streamable-http) beforehand. Find concrete examples for popular clients below. Depending on your needs, you might want to further customize Serena's behaviour by * [adding command-line arguments](mcp-args) * [adjusting configuration](050_configuration). **Mode of Operation**. Note that some clients have a per-workspace MCP configuration (e.g, VSCode and Claude Code), while others have a global MCP configuration (e.g. Codex and Claude Desktop). - In the per-workspace case, you typically want to start Serena with your workspace directory as the project directory and never switch to a different project. This is achieved by specifying the `--project ` argument with a single-project [context](#contexts) (e.g. `ide` or `claude-code`). - In the global configuration case, you must first activate the project you want to work on, which you can do by asking the LLM to do so (e.g., "Activate the current dir as project using serena"). In such settings, the `activate_project` tool is required. **Tool Selection**. While you may be able to turn off tools through your client's interface (e.g., in VSCode or Claude Desktop), we recommend selecting your base tool set through Serena's configuration, as Serena's prompts automatically adjust based on which tools are enabled/disabled. A key mechanism for this is to use the appropriate [context](#contexts) when starting Serena. (clients-common-pitfalls)= ### Common Pitfalls **Escaping Paths Correctly**. Note that if your client configuration uses JSON, special characters (like backslashes) need to be escaped properly. In particular, if you are specifying paths containing backslashes on Windows (note that you can also just use forward slashes), be sure to escape them correctly (`\\`). **Discoverability of `uvx`**. Your client may not find the `uvx` command, even if it is on your system PATH. In this case, a workaround is to provide the full path to the `uvx` executable. **Environment Variables**. Some language servers may require additional environment variables to be set (e.g. F# on macOS with Homebrew), which you may need to explicitly add to the MCP server configuration. Note that for some clients (e.g. Claude Desktop), the spawned MCP server process may not inherit environment variables that are only configured in your shell profile (e.g. `.bashrc`, `.zshrc`, etc.); they would need to be set system-wide instead. An easy fix is to add them explicitly to the MCP server entry. For example, in Claude Desktop and other clients, you can simply add an `env` key to the `serena` object, e.g. ``` "env": { "DOTNET_ROOT": "/opt/homebrew/Cellar/dotnet/9.0.8/libexec" } ``` ## Claude Code Serena is a great way to make Claude Code both cheaper and more powerful! **Per-Project Configuration.** To add the Serena MCP server to the current project in the current directory, use this command: ```shell claude mcp add serena -- uvx --from git+https://github.com/oraios/serena serena start-mcp-server --context claude-code --project "$(pwd)" ``` Note: * We use the `claude-code` context to disable unnecessary tools (avoiding duplication with Claude Code's built-in capabilities). * We specify the current directory as the project directory with `--project "$(pwd)"`, such that Serena is configured to work on the current project from the get-go, following Claude Code's mode of operation. **Global Configuration**. Alternatively, use `--project-from-cwd` for user-level configuration that works across all projects: ```shell claude mcp add --scope user serena -- uvx --from git+https://github.com/oraios/serena serena start-mcp-server --context=claude-code --project-from-cwd ``` Whenever you start Claude Code, Serena will search up from the current directory for `.serena/project.yml` or `.git` markers, activating the containing directory as the project (if any). This mechanism makes it suitable for a single global MCP configuration. **Maximum Token Efficiency.** To maximize token efficiency, you may want to use Claude Code's *on-demand tool loading* feature, which is supported since at least v2.0.74 of Claude Code. This feature avoids sending all tool descriptions to Claude upon startup, thus saving tokens. Instead, Claude will search for tools as needed (but there are no guarantees that it will search optimally, of course). To enable this feature, set the environment variable `ENABLE_TOOL_SEARCH=true`. Depending on your shell, you can also set this on a per-session basis, e.g. using ```shell ENABLE_TOOL_SEARCH=true claude ``` in bash/zsh, or using ```shell set ENABLE_TOOL_SEARCH=true && claude ``` in Windows CMD to launch Claude Code. ## VSCode While serena can be directly installed from the GitHub MCP server registry, we recommend to set it up manually (at least for now, until the configuration there has been improved). Just paste the following into `/.vscode/mcp.json`, or edit the entry after using the option `install into workspace`: ```json { "servers": { "oraios/serena": { "type": "stdio", "command": "uvx", "args": [ "--from", "git+https://github.com/oraios/serena", "serena", "start-mcp-server", "--context", "ide", "--project", "${workspaceFolder}" ] } }, "inputs": [] } ``` ## Codex Serena works with OpenAI's Codex CLI out of the box, but you have to use the `codex` context for it to work properly. (The technical reason is that Codex doesn't fully support the MCP specifications, so some massaging of tools is required.). Add a [run command](020_running) to `~/.codex/config.toml` to configure Serena for all Codex sessions; create the file if it does not exist. For example, when using `uvx`, add the following section: ```toml [mcp_servers.serena] command = "uvx" args = ["--from", "git+https://github.com/oraios/serena", "serena", "start-mcp-server", "--context", "codex"] ``` After codex has started, you need to activate the project, which you can do by saying: > Call serena.activate_project, serena.check_onboarding_performed and serena.initial_instructions **If you don't activate the project, you will not be able to use Serena's tools!** It is recommend to set this prompt as a [custom prompt](https://developers.openai.com/codex/custom-prompts), so you don't need to type this every time. That's it! Have a look at `~/.codex/log/codex-tui.log` to see if any errors occurred. Serena's dashboard will run if you have not disabled it in the configuration, but due to Codex's sandboxing, the web browser may not open automatically. You can open it manually by going to `http://localhost:24282/dashboard/index.html` (or a higher port, if that was already taken). > Codex will often show the tools as `failed` even though they are successfully executed. This is not a problem, seems to be a bug in Codex. Despite the error message, everything works as expected. ## Claude Desktop On Windows and macOS, there are official [Claude Desktop applications by Anthropic](https://claude.ai/download); for Linux, there is an [open-source community version](https://github.com/aaddrick/claude-desktop-debian). To configure MCP server settings, go to File / Settings / Developer / MCP Servers / Edit Config, which will let you open the JSON file `claude_desktop_config.json`. Add the `serena` MCP server configuration ```json { "mcpServers": { "serena": { "command": "uvx", "args": [ "--from", "git+https://github.com/oraios/serena", "serena", "start-mcp-server" ] } } } ``` If your language server requires specific environment variables to be set (e.g. F# on macOS with Homebrew), you can add them via an `env` key (see [above](#clients-common-pitfalls)). Once you have created the new MCP server entry, save the config and then restart Claude Desktop. :::{attention} Be sure to fully quit the Claude Desktop application via File / Exit, as regularly closing the application will just minimize it. ::: After restarting, you should see Serena's tools in your chat interface (notice the small hammer icon). For more information on MCP servers with Claude Desktop, see [the official quick start guide](https://modelcontextprotocol.io/quickstart/user). ## JetBrains Junie Open Junie, go to the three dots in the top right corner, then Settings / MCP Settings and add Serena to Junie's global MCP server configuration: ```json { "mcpServers": { "serena": { "command": "uvx", "args": [ "--from", "git+https://github.com/oraios/serena", "serena", "start-mcp-server", "--context", "ide" ] } } } ``` You will have to prompt Junie to "Activate the current project using serena's activation tool" at the start of each session. ## JetBrains AI Assistant Here you can set up the more convenient per-project MCP server configuration, as the AI assistant supports specifying the launch working directory. Go to Settings / Tools / AI Assistant / MCP and add a new **local** configuration via the `as JSON` option: ```json { "mcpServers": { "serena": { "command": "uvx", "args": [ "--from", "git+https://github.com/oraios/serena", "serena", "start-mcp-server", "--context", "ide", "--project", "$(pwd)" ] } } } ``` Then make sure to configure the working directory to be the project root. ## Antigravity Add this configuration: ```json { "mcpServers": { "serena": { "command": "uvx", "args": [ "--from", "git+https://github.com/oraios/serena", "serena", "start-mcp-server", "--context", "ide" ] } } } ``` You will have to prompt Antigravity's agent to "Activate the current project using serena's activation tool" after starting Antigravity in the project directory (once in the first chat enough, all other chat sessions will continue using the same Serena session). Unlike VSCode, Antigravity does not currently support including the working directory in the MCP configuration. Also, the current client will be shown as `none` in Serena's dashboard (Antigravity currently does not fully support the MCP specifications). This is not a problem, all tools will work as expected. ## Other Clients For other clients, follow the [general instructions](#clients-general-instructions) above to set up Serena as an MCP server. ### Terminal-Based Clients There are many terminal-based coding assistants that support MCP servers, such as * [Gemini-CLI](https://github.com/google-gemini/gemini-cli), * [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder), * [rovodev](https://community.atlassian.com/forums/Rovo-for-Software-Teams-Beta/Introducing-Rovo-Dev-CLI-AI-Powered-Development-in-your-terminal/ba-p/3043623), * [OpenHands CLI](https://docs.all-hands.dev/usage/how-to/cli-mode) and * [opencode](https://github.com/sst/opencode). They generally benefit from the symbolic tools provided by Serena. You might want to customize some aspects of Serena by writing your own context, modes or prompts to adjust it to the client's respective internal capabilities (and your general workflow). In most cases, the `ide` context is likely to be appropriate for such clients, i.e. add the arguments `--context ide` in order to reduce tool duplication. ### MCP-Enabled IDEs and Coding Clients (Cline, Roo-Code, Cursor, Windsurf, etc.) Most of the popular existing coding assistants (e.g. IDE extensions) and AI-enabled IDEs themselves support connections to MCP Servers. Serena generally boosts performance by providing efficient tools for symbolic operations. We generally recommend to use the `ide` context for these integrations by adding the arguments `--context ide` in order to reduce tool duplication. ### Local GUIs and Agent Frameworks Over the last months, several technologies have emerged that allow you to run a local GUI client and connect it to an MCP server. The respective applications will typically work with Serena out of the box. Some of the leading open source GUI applications are * [Jan](https://jan.ai/docs/mcp), * [OpenHands](https://github.com/All-Hands-AI/OpenHands/), * [OpenWebUI](https://docs.openwebui.com/openapi-servers/mcp) and * [Agno](https://docs.agno.com/introduction/playground). These applications allow to combine Serena with almost any LLM (including locally running ones) and offer various other integrations. ================================================ FILE: docs/02-usage/040_workflow.md ================================================ # The Project Workflow Serena uses a project-based workflow. A **project** is simply a directory on your filesystem that contains code and other files that you want Serena to work with. Assuming that you have project you want to work with (which may initially be empty), setting up a project with Serena typically involves the following steps: 1. **Project creation**: Configuring project settings for Serena (and indexing the project, if desired) 2. **Project activation**: Making Serena aware of the project you want to work with 3. **Onboarding**: Getting Serena familiar with the project (creating memories) 4. **Working on coding tasks**: Using Serena to help you with actual coding tasks in the project (project-creation-indexing)= ## Project Creation & Indexing Project creation is the process of defining fundamental project settings that are relevant to Serena's operation. You can create a project either * implicitly, by just activating a directory as a project while already in a conversation; this will use default settings for your project (skip to the next section). * explicitly, using the project creation command, or ### Explicit Project Creation To explicitly create a project, use the following command while in the project directory: project create [options] For instance, when using `uvx`, run uvx --from git+https://github.com/oraios/serena serena project create [options] * For an empty project, you will need to specify the programming language (e.g., `--language python`). * For an existing project, the main programming language will be detected automatically, but you can choose to explicitly specify multiple languages by passing the `--language` parameter multiple times (e.g. `--language python --language typescript`). * You can optionally specify a custom project name with `--name my-name`. * You can immediately index the project after creation with `--index`. (project-config)= #### Project Configuration After creation, you can adjust the project settings in the generated `.serena/project.yml` file within the project directory. The file allows you to configure ... * the set of programming languages for which language servers are spawned (not relevant when using the JetBrains plugin) Note that you can dynamically add/remove language servers while Serena is running via the [Dashboard](060_dashboard). * the [language backend](per-project-language-backend) to use for this project (overriding the global setting) * the encoding used in source files * ignore rules * write access * an initial prompt that shall be passed to the LLM whenever the project is activated * the name by which you want to refer to the project (relevant when telling the LLM to dynamically activate the project) * the set of tools and modes to use by default For detailed information on the parameters and possible settings, see the [template file](https://github.com/oraios/serena/blob/main/src/serena/resources/project.template.yml). :::{note} Many settings in project.yml *extend* or *override* settings in the global configuration file `serena_config.yml`. So use the project configuration specifically for aspects that apply only to the particular project. ::: **Local Overrides**. The project.yml file is intended to be versioned together with the project. You can specify local overrides for the settings in a `project.local.yml` file in the same directory (which, by default, is ignored by git). Any keys defined therein will override the respective key in `project.yml`. (indexing)= ### Indexing :::{note} Indexing is not a relevant operation when using the JetBrains plugin, as indexing is handled by the IDE. ::: Especially for larger project, it can be advisable to index the project after creation, pre-caching symbol information provided by the language server(s). This will avoid delays during the first tool invocation that requires symbol information. While in the project directory, run this command: project index Indexing has to be called only once. During regular usage, Serena will automatically update the index whenever files change. (project-activation)= ## Project Activation Project activation makes Serena aware of the project you want to work with. You can either choose to do this * while in a conversation, by telling the LLM to activate a project, e.g., * "Activate the project /path/to/my_project" (for first-time activation with auto-creation) * "Activate the project my_project" Note that this option requires the `activate_project` tool to be active, which it isn't in single-project [contexts](contexts) like `ide` or `claude-code` *if* a project is provided at startup. (The tool is deactivated, because we assume that in these contexts, user will only work on the single, open project and have no need to switch it.) * when the MCP server starts, by passing the project path or name as a command-line argument (e.g. when using a single-project mode like `ide` or `claude-code`): `--project ` When working with the JetBrains plugin, be sure to have the same project folder open as a project in your IDE, i.e. the folder that is activated in Serena should correspond to the root folder of the project in your IDE. ## Onboarding & Memories By default, Serena will perform an **onboarding process** when it is started for the first time for a project. The goal of the onboarding is for Serena to get familiar with the project and to store memories, which it can then draw upon in future interactions. In general, **memories** provide a way for Serena to store and retrieve information about the project, relevant conventions, and other relevant aspects. For more information on this, including how to manage or disable these features, see [Memories & Onboarding](045_memories). ## Preparing Your Project When using Serena to work on your project, it can be helpful to follow a few best practices. ### Structure Your Codebase Serena uses the code structure for finding, reading and editing code. This means that it will work well with well-structured code but may perform poorly on fully unstructured one (like a "God class" with enormous, non-modular functions). Furthermore, for languages that are not statically typed, the use of type annotations (if supported) are highly beneficial. ### Start from a Clean State It is best to start a code generation task from a clean git state. Not only will this make it easier for you to inspect the changes, but also the model itself will have a chance of seeing what it has changed by calling `git diff` and thereby correct itself or continue working in a followup conversation if needed. ### Use Platform-Native Line Endings **Important**: since Serena will write to files using the system-native line endings and it might want to look at the git diff, it is important to set `git config core.autocrlf` to `true` on Windows. With `git config core.autocrlf` set to `false` on Windows, you may end up with huge diffs due to line endings only. It is generally a good idea to globally enable this git setting on Windows: ```shell git config --global core.autocrlf true ``` ### Logging, Linting, and Automated Tests Serena can successfully complete tasks in an _agent loop_, where it iteratively acquires information, performs actions, and reflects on the results. However, Serena cannot use a debugger; it must rely on the results of program executions, linting results, and test results to assess the correctness of its actions. Therefore, software that is designed to meaningful interpretable outputs (e.g. log messages) and that has a good test coverage is much easier to work with for Serena. We generally recommend to start an editing task from a state where all linting checks and tests pass. ## Multiple Projects, Multiple Agents There are several ways in which you might want to work with multiple projects simultaneously. ### A Single Agent Editing Multiple Projects Simultaneously If fulfilling a task requires a single agent to edit code in multiple projects, the recommended approach is to create a **monorepo folder**, i.e. a folder that contains all the projects as sub-folders, and open that monorepo folder as a project in Serena. You may also use symbolic links to create a monorepo folder if the projects are located in different places on your filesystem. If several languages are used across the projects, specify all of them as needed when using the LSP backend; For JetBrains mode, make sure that your IDE is configured to work with all the languages used across the projects (e.g. by installing the respective language plugins). (query-projects)= ### Reading from External Projects If, while working on a project, you want Serena to be able to read code or other information from another project (e.g. a library or otherwise related project), this can be enabled via the `query_project` tool. Provided that the project you want to query is known to Serena (i.e. you have created it as described above), the `query_project` tool allows the agent to query files and symbolic information from that project. To enable this tool, [activate the mode](modes) `query-projects`. This also enables a second tool for listing projects that can be queried. Depending on the language backend being used, the management of resources for the external projects varies: * When using the JetBrains backend, make sure that every project for which you want symbolic queries to work is open in an IDE instance. * When using the LSP backend, executing symbolic tools via the query tool requires that Serena's **Project Server** be started, which will automatically spawn the necessary language servers for the projects that are queried. To start the server, run start-project-server where `` is your way of running Serena. For example, when using `uvx`, run uvx --from git+https://github.com/oraios/serena serena start-project-server ### Multiple Agents Accessing a Single Serena Instance If you want multiple agents to access the same project via a single Serena instance, i.e. you do not want several instances of Serena (including its language servers) to be running, you can achieve this by [starting the Serena MCP server in HTTP mode](streamable-http) and connecting all client agents to the same HTTP endpoint. The agents will then share the resources of the single Serena instance. ### Multiple Agents Working on Different Projects For this use case, simply run a separate instance of Serena for each project, which naturally occurs when Serena is started by the MCP client in stdio mode. ================================================ FILE: docs/02-usage/045_memories.md ================================================ # Memories & Onboarding Serena provides the functionality of a fully featured agent, and a useful aspect of this is Serena's memory system. Despite its simplicity, we received positive feedback from many users who tend to combine it with their agent's internal memory management (e.g., `AGENTS.md` files). ## Memories Memories are simple, human-readable Markdown files that both you and your agent can create, read, and edit. Serena differentiates between * **project-specific memories**, which are stored in the `.serena/memories/` directory within your project folder, and * **global memories**, which are shared across all projects and, by default, are stored in `~/.serena/memories/global/` The LLM is informed about the existence of memories and instructed to read them when appropriate, inferring appropriateness from the file name. When the agent starts working on a project, it receives the list of available memories. The agent should be instructed to update memories by the user when appropriate. ### Organizing Memories Memories can be organized into **topics** by using `/` in the memory name (e.g. `modules/frontend`). The structure is mapped to the file system, where topics correspond to subdirectories. The `list_memories` tool can filter by topic, allowing the agent to explore even large numbers of memories in a structured way. (global-memories)= ### Global Memories Global memories use the top-level topic `global`, i.e. whenever a memory name starts with `global/`, it is stored in the global memories directory and is shared across all projects. By default, deletion and editing of global memories is allowed. If you want to protect them from accidental modification by the agent, you can add regex patterns to `read_only_memory_patterns` in your global or project-level [configuration](050_configuration). For example, setting "global/.*" will mark all global memories as read-only. The agent will be informed which memories are read-only. Since global memories are not versioned alongside your project files, it can be helpful to track global memories with git (i.e. to make `~/.serena/memories/` a git repository) in order to have a history of changes and the possibility to revert them if needed. ### Manually Editing Memories You may edit memories directly in the file system, using your preferred text editor or IDE. Alternatively, access them via the [Serena Dashboard](060_dashboard), which provides a graphical interface for viewing, creating, editing, and deleting memories while Serena is running. (onboarding)= ## Onboarding By default, Serena performs an **onboarding process** when it encounters a project for the first time (i.e., when no project memories exist yet). The goal of the onboarding is for Serena to get familiar with the project — its structure, build system, testing setup, and other essential aspects — and to store this knowledge as memories for future interactions. In further project activations, Serena will check whether onboarding was already performed by looking for existing project memories and will skip the onboarding process if memories are found. ### How Onboarding Works 1. When a project is activated, Serena checks whether onboarding was already performed (by checking if any memories exist). 2. If no memories are found, Serena triggers the onboarding process, which reads key files and directories to understand the project. 3. The gathered information is written into project-specific memory files (see above). ### Tips for Onboarding - **Context usage**: The onboarding process will read a lot of content from the project, filling up the context window. It is therefore advisable to **switch to a new conversation** once the onboarding is complete. - **LLM failures**: If an LLM fails to complete the onboarding and does not actually write the respective memories to disk, you may need to ask it to do so explicitly. - **Review the results**: After onboarding, we recommend having a quick look at the generated memories and editing them or adding new ones as needed. ## Disabling Memories and Onboarding If you do not require the functionality described in this section, you can selectively disable it. * To disable all memory related tools (including onboarding), adding `no-memories` to the `base_modes` in Serena's [global configuration](050_configuration). * Similarly, to disable only onboarding, add `no-onboarding` to the `base_modes`. ================================================ FILE: docs/02-usage/050_configuration.md ================================================ # Configuration Serena is very flexible in terms of configuration. While for most users, the default configurations will work, you can fully adjust it to your needs. You can disable tools, change Serena's fundamental instructions (what we denote as the `system_prompt`), adjust the output of tools that just provide a prompt, and even adjust tool descriptions. Serena is configured in using a multi-layered approach: * **global configuration** (`serena_config.yml`, see below) * **project configuration** (`project.yml`, see [Project Configuration](project-config)) * **contexts and modes** for composable configuration, which can be enabled on a case-by-case basis (see below) * **command-line parameters** passed to the `start-mcp-server` server command (overriding/extending configured settings) See [MCP Server Command-Line Arguments](mcp-args) for further information. (global-config)= ## Global Configuration The global configuration file allows you to change general settings and defaults that will apply to all projects unless overridden. ### Settings Some of the configurable settings include: * the language backend to use by default (i.e., the JetBrains plugin or language servers); this can also be [overridden per project](per-project-language-backend) * UI settings affecting the [Serena Dashboard and GUI tool](060_dashboard.md) * the set of tools to enable/disable by default * the set of modes to use by default * tool execution parameters (timeout, max. answer length) * global ignore rules * logging settings * advanced settings specific to individual language servers (see [below](ls-specific-settings)) The global configuration settings apply to all projects. Some of the settings it contains can, however, be *extended* or *overridden* in project-specific settings, contexts and modes. For detailed information on the parameters and possible settings, see the [template file](https://github.com/oraios/serena/blob/main/src/serena/resources/serena_config.template.yml). ### Accessing the Configuration File The configuration file is auto-created when you first run Serena. It is stored in your user directory: * Linux/macOS/Git-Bash: `~/.serena/serena_config.yml` * Windows (CMD/PowerShell): `%USERPROFILE%\.serena\serena_config.yml` You can access it * through [Serena's dashboard](060_dashboard) while Serena is running (use the respective button) * directly, using your favourite text editor * using the command ```shell config edit ``` where `` is [your way of running Serena](020_running). ## Modes and Contexts Serena's behaviour and toolset can be adjusted using contexts and modes. These allow for a high degree of customization to best suit your workflow and the environment Serena is operating in. (contexts)= ### Contexts A **context** defines the general environment in which Serena is operating. It influences the initial system prompt and the set of available tools. A context is set at startup when launching Serena (e.g., via CLI options for an MCP server or in the agent script) and cannot be changed during an active session. Serena comes with pre-defined contexts: * `desktop-app`: Tailored for use with desktop applications like Claude Desktop. This is the default. The full set of Serena's tools is provided, as the application is assumed to have no prior coding-specific capabilities. * `claude-code`: Optimized for use with Claude Code, it disables tools that would duplicate Claude Code's built-in capabilities. * `codex`: Optimized for use with OpenAI Codex. * `ide`: Generic context for IDE assistants/coding agents, e.g. VSCode, Cursor, or Cline, focusing on augmenting existing capabilities. Basic file operations and shell execution are assumed to be handled by the assistant's own capabilities. * `agent`: Designed for scenarios where Serena acts as a more autonomous agent, for example, when used with Agno. Choose the context that best matches the type of integration you are using. Find the concrete definitions of the above contexts [here](https://github.com/oraios/serena/tree/main/src/serena/resources/config/contexts). Note that the contexts `ide` and `claude-code` are **single-project contexts** (defining `single_project: true`). For such contexts, if a project is provided at startup, the set of tools is limited to those required by the project's concrete configuration, and other tools are excluded completely, allowing the set of tools to be minimal. Tools explicitly disabled by the project will not be available at all. Since changing the active project ceases to be a relevant operation in this case, the project activation tool is disabled. When launching Serena, specify the context using `--context `. Note that for cases where parameter lists are specified (e.g. Claude Desktop), you must add two parameters to the list. If you are using a local server (such as Llama.cpp) which requires you to use OpenAI-compatible tool descriptions, use context `oaicompat-agent` instead of `agent`. You can manage contexts using the `context` command, context --help context list context create context edit context delete where `` is [your way of running Serena](020_running). (modes)= ### Modes Modes further refine Serena's behavior for specific types of tasks or interaction styles. Multiple modes can be active simultaneously, allowing you to combine their effects. Modes influence the system prompt and can also alter the set of available tools by excluding certain ones. Examples of built-in modes include: * `planning`: Focuses Serena on planning and analysis tasks. * `editing`: Optimizes Serena for direct code modification tasks. * `interactive`: Suitable for a conversational, back-and-forth interaction style. * `one-shot`: Configures Serena for tasks that should be completed in a single response, often used with `planning` for generating reports or initial plans. * `no-onboarding`: Skips the initial onboarding process if it's not needed for a particular session but retains the memory tools (assuming initial memories were created externally). * `onboarding`: Focuses on the project onboarding process. * `no-memories`: Disables all memory tools (and tools building on memories such as onboarding tools) * `query-projects`: Enables tools for querying other Serena projects (without activating them); see section [Reading from External Projects](query-projects) Find the concrete definitions of these modes [here](https://github.com/oraios/serena/tree/main/src/serena/resources/config/modes). Active modes are configured in (from lowest to highest precedence): * the global configuration file (`serena_config.yml`) * the project configuration file (`project.yml`) * at startup via command-line parameters The two former sources define both **base modes** and **default modes**. Ultimately, the active modes are the union of base modes and default modes (after applying all overrides). Command-line parameters override default modes but not base modes. Base modes should thus be used to define modes that you always want to be active, regardless of command-line parameters. Command-line parameters for overriding default modes: When launching the MCP sever, specify modes using `--mode `; multiple modes can be specified, e.g. `--mode planning --mode no-onboarding`. :::{important} By default, Serena activates the two modes `interactive` and `editing` (as defined in the global configuration). As soon as you start to specify modes via the command line, only the modes you explicitly specify will be active, however. Therefore, if you want to keep the default modes, you must specify them as well. For example, to add mode `no-memories` to the default behaviour, specify ```shell --mode interactive --mode editing --mode no-memories ``` If you want to keep certain modes as always active, regardless of command-line parameters, define them as *base modes* in the global or project configuration. ::: Modes can also be _switched dynamically_ during a session. You can instruct the LLM to use the `switch_modes` tool to activate a different set of modes (e.g., "Switch to planning and one-shot modes"). Like command-line parameters, this only affects default modes, not base modes (which remain active). :::{note} **Mode Compatibility**: While you can combine modes, some may be semantically incompatible (e.g., `interactive` and `one-shot`). Serena currently does not prevent incompatible combinations; it is up to the user to choose sensible mode configurations. ::: You can manage modes using the `mode` command, mode --help mode list mode create mode edit mode delete where `` is [your way of running Serena](020_running). ## Advanced Configuration For advanced users, Serena's configuration can be further customized. ### Serena Data Directory The Serena user data directory (where configuration, language server files, logs, etc. are stored) defaults to `~/.serena`. You can change this location by setting the `SERENA_HOME` environment variable to your desired path. ### Per-Project Serena Folder Location By default, each project stores its Serena data (memories, caches, etc.) in a `.serena` folder inside the project root. You can customize this location globally via the `project_serena_folder_location` setting in `serena_config.yml`. The setting supports two placeholders: | Placeholder | Description | |----------------------|-------------------------------------------------| | `$projectDir` | The absolute path to the project root directory | | `$projectFolderName` | The name of the project folder | **Examples:** ```yaml # Default: data stored inside the project directory project_serena_folder_location: "$projectDir/.serena" # Central location: all project data under a shared directory project_serena_folder_location: "/projects-metadata/$projectFolderName/.serena" ``` When a project is loaded, Serena uses the following fallback logic: 1. Check if a `.serena` folder exists at the configured path. 2. If not, check if one exists in the project root (default/legacy location). 3. If neither exists, create the folder at the configured path. This ensures backward compatibility: existing projects that already have a `.serena` folder in the project root will continue to work, even after changing the `project_serena_folder_location` setting. (ls-specific-settings)= ### Language Server-Specific Settings :::{note} **Advanced Users Only**: The settings described in this section are intended for advanced users who need to fine-tune language server behavior. Most users will not need to adjust these settings. ::: Under the key `ls_specific_settings` in `serena_config.yml`, you can you pass per-language, language server-specific configuration. Structure: ```yaml ls_specific_settings: : # language-server-specific keys ``` :::{attention} Most settings are currently undocumented. Please refer to the [source code of the respective language server](https://github.com/oraios/serena/tree/main/src/solidlsp/language_servers) implementation to determine supported settings. ::: #### Overriding the Language Server Path Some language servers, particularly those that use a single core path for the language server (e.g. the main executable), support overriding that path via the `ls_path` setting. Therefore, if you have installed the language server yourself and want to use your installation instead of Serena's managed installation, you can set the `ls_path` setting as follows: ```yaml ls_specific_settings: : ls_path: "/path/to/language-server" ``` This is supported by all language servers deriving their dependency provider from `LanguageServerDependencyProviderSinglePath`. Currently, this includes the following languages: `bash`, `clojure`, `cpp`, `kotlin`, `markdown`, `php`, `php_phpactor`, `python`, `rust`, `toml`, `typescript`, `yaml`. We will add support for more languages over time. #### C# (Roslyn Language Server) Serena uses [Microsoft's Roslyn Language Server](https://github.com/dotnet/roslyn) for C# support. **Runtime Requirements:** - .NET 10 or higher is required. If not found in PATH, Serena automatically installs it using Microsoft's official install scripts. - The Roslyn Language Server is automatically downloaded from NuGet.org. **Supported Platforms:** Automatic download is supported for: Windows (x64, ARM64), macOS (x64, ARM64), Linux (x64, ARM64). **Configuration:** The `runtime_dependencies` setting allows you to override the download URLs for the Roslyn Language Server. This is useful if you need to use a private package mirror or a specific version. Example configuration to override the language server download URL: ```yaml ls_specific_settings: csharp: runtime_dependencies: - id: "CSharpLanguageServer" platform_id: "linux-x64" # or win-x64, win-arm64, osx-x64, osx-arm64, linux-arm64 url: "https://your-mirror.example.com/roslyn-language-server.linux-x64.5.5.0-2.26078.4.nupkg" package_version: "5.5.0-2.26078.4" ``` Available fields for `runtime_dependencies` entries: | Field | Description | | ----------------- | --------------------------------------------------------------------------- | | `id` | Dependency identifier (use `CSharpLanguageServer`) | | `platform_id` | Target platform: `win-x64`, `win-arm64`, `osx-x64`, `osx-arm64`, `linux-x64`, `linux-arm64` | | `url` | Download URL for the NuGet package | | `package_version` | Package version string | | `extract_path` | Path within the package to extract (default: `tools/net10.0/`) | Notes: - Only specify the platforms you want to override; others will use the defaults. - The language server package is a `.nupkg` file (ZIP format) downloaded from NuGet.org by default. - If you have .NET 10+ already installed, Serena will use your system installation. #### Go (`gopls`) Serena forwards `ls_specific_settings.go.gopls_settings` to `gopls` as LSP `initializationOptions` when the Go language server is started. Example: enable build tags and set a build environment: ```yaml ls_specific_settings: go: gopls_settings: buildFlags: - "-tags=foo" env: GOOS: "linux" GOARCH: "amd64" CGO_ENABLED: "0" ``` Notes: - To enable multiple tags, use `"-tags=foo,bar"`. - `gopls_settings.env` values are strings. - `GOFLAGS` (from the environment you start Serena in) may also affect the Go build context. Prefer `buildFlags` for tags. - Build context changes are only picked up when `gopls` starts. After changing `gopls_settings` (or relevant env vars like `GOFLAGS`), restart the Serena process (or server) that hosts the Go language server, or use your client's "Restart language server" action if it causes `gopls` to restart. #### Java (`eclipse.jdt.ls`) The following settings are supported for the Java language server: | Setting | Default | Description | |---|---|---| | `maven_user_settings` | `~/.m2/settings.xml` | Path to Maven `settings.xml` | | `gradle_user_home` | `~/.gradle` | Path to Gradle user home directory | | `gradle_wrapper_enabled` | `false` | Use the project's Gradle wrapper (`gradlew`) instead of the bundled Gradle distribution. Enable this for projects with custom plugins or repositories. | | `gradle_java_home` | `null` | Path to the JDK used by Gradle. When unset, Gradle uses the bundled JRE. | | `use_system_java_home` | `false` | Use the system's `JAVA_HOME` environment variable for JDTLS itself. Enable this if your project requires a specific JDK vendor or version for Gradle's JDK checks. | Example for a project with custom Gradle plugins and JDK requirements: ```yaml ls_specific_settings: java: gradle_wrapper_enabled: true use_system_java_home: true ``` #### Kotlin Serena uses [JetBrains' Kotlin Language Server](https://github.com/Kotlin/kotlin-lsp) for Kotlin support. **Runtime Requirements:** - Java 21 or higher is required. If not found, Serena automatically downloads an appropriate JRE. - The Kotlin Language Server is automatically downloaded from JetBrains' CDN. **Configuration:** ```yaml ls_specific_settings: kotlin: ls_path: "/path/to/kotlin-lsp.sh" # Override the Kotlin Language Server executable kotlin_lsp_version: "261.13587.0" # Override the Kotlin Language Server version jvm_options: "-Xmx4G -XX:+UseG1GC" # JVM options (default: -Xmx2G). Set to "" to disable. ``` #### Luau Serena uses [`luau-lsp`](https://github.com/JohnnyMorganz/luau-lsp) for Luau support. **Runtime Requirements:** - `luau-lsp` is used from PATH if available. - Otherwise, Serena downloads the pinned `luau-lsp` release for the current platform. **Configuration:** ```yaml ls_specific_settings: luau: ls_path: "/path/to/luau-lsp" # Optional: override the language server executable platform: "roblox" # "roblox" (default) or "standard" roblox_security_level: "PluginSecurity" # Roblox only: None, PluginSecurity, LocalUserSecurity, RobloxScriptSecurity ``` Notes: - In `roblox` mode, Serena downloads Roblox definitions and Roblox API docs and passes them to `luau-lsp`. - In `standard` mode, Serena skips Roblox definitions and only downloads the standard Luau docs bundle. #### Pascal (`pasls`) Serena uses [pasls](https://github.com/genericptr/pascal-language-server) (Pascal Language Server) for Pascal/Free Pascal support. **Language Server Installation:** 1. If `pasls` is found in your system PATH, Serena uses it directly 2. Otherwise, Serena automatically downloads a prebuilt binary from GitHub releases Supported platforms for automatic download: Linux (x64, arm64), macOS (x64, arm64), Windows (x64). **Auto-Update:** Serena automatically checks for pasls updates every 24 hours. Updates include: - SHA256 checksum verification before installation - Atomic update with rollback on failure - Windows file locking detection (defers update if pasls is in use) **Configuration:** Configure pasls via `ls_specific_settings.pascal` in `serena_config.yml`: | Setting | Description | | ---------------- | --------------------------------------------------------------------------- | | `pp` | Path to FPC compiler driver (must be `fpc` or `fpc.exe`, not `ppc386.exe`) | | `fpcdir` | Path to FPC source directory | | `lazarusdir` | Path to Lazarus directory (required for LCL projects) | | `fpc_target` | Target OS override (e.g., `Win32`, `Win64`, `Linux`) | | `fpc_target_cpu` | Target CPU override (e.g., `i386`, `x86_64`, `aarch64`) | Example configuration: ```yaml ls_specific_settings: pascal: pp: "D:/laz32/fpc/bin/i386-win32/fpc.exe" fpcdir: "D:/laz32/fpcsrc" lazarusdir: "D:/laz32/lazarus" ``` Notes: - The `pp` setting is the most important for hover and navigation to work correctly. - Use the FPC compiler driver (`fpc`/`fpc.exe`), not backend compilers like `ppc386.exe`. - These settings are passed as environment variables to the pasls process. ### Custom Prompts All of Serena's prompts can be fully customized. We define prompt as jinja templates in yaml files, and you can inspect our default prompts [here](https://github.com/oraios/serena/tree/main/src/serena/resources/config/prompt_templates). To override a prompt, simply add a .yml file to the `prompt_templates` folder in your Serena data directory which defines the prompt with the same name as the default prompt you want to override. For example, to override the `system_prompt`, you could create a file `~/.serena/prompt_templates/system_prompt.yml` (assuming default Serena data folder location) with content like: ```yaml prompts: system_prompt: | Whatever you want ... ``` It is advisable to use the default prompt as a starting point and modify it to suit your needs. ================================================ FILE: docs/02-usage/060_dashboard.md ================================================ # The Dashboard and GUI Tool Serena comes with built-in tools for monitoring and managing the current session: * the **web-based dashboard** (enabled by default) The dashboard provides detailed information on your Serena session, the current configuration and provides access to logs. Some settings (e.g. the current set of active programming languages) can also be directly modified through the dashboard. The dashboard is supported on all platforms. By default, it will be accessible at `http://localhost:24282/dashboard/index.html`, but a higher port may be used if the default port is unavailable/multiple instances are running. **We recommend always enabling the dashboard**. If you don't want the browser to open automatically, you can disable it while still keeping the dashboard running in the background (see below). * the **GUI tool** (disabled by default) The GUI tool is a native application window which displays logs. It furthermore allows you to shut down the agent and to access the dashboard's URL (if it is running). This is mainly supported on Windows, but it may also work on Linux; macOS is unsupported. Both can be configured in Serena's [configuration](050_configuration) file (`serena_config.yml`). If enabled, they will automatically be opened as soon as the Serena agent/MCP server is started. For the dashboard, this can be disabled if desired (see below). ## Disabling Automatic Browser Opening If you prefer not to have the dashboard open automatically (e.g., to avoid focus stealing), you can disable it by setting `web_dashboard_open_on_launch: False` in your `serena_config.yml` or by passing `--open-web-dashboard False` to `start-mcp-server` CLI command. When automatic opening is disabled, you can still access the dashboard by: * asking the LLM to "open the Serena dashboard", which will open the dashboard in your default browser (the tool `open_dashboard` is enabled for this purpose, provided that the dashboard is active, not opened by default and the GUI tool, which can provide the URL, is not enabled) * navigating directly to the URL (see above) ================================================ FILE: docs/02-usage/065_logs.md ================================================ # Logs It can be vital to understand what is happening in Serena, especially when something goes wrong. You can access Serena's live logs via * the [Serena dashboard](060_dashboard) (tab "Logs") * the [GUI tool](060_dashboard). Additionally, logs are persisted in the Serena home directory, which, by default, is located at * `%USERPROFILE%\.serena\logs` on Windows * `~/.serena/logs` on Linux and macOS. You can adjust the log level via the [global configuration](global-config). You additionally have the option of enabling full tracing of language server communication (mostly for development purposes). ================================================ FILE: docs/02-usage/070_security.md ================================================ # Security Considerations As fundamental abilities for a coding agent, Serena contains tools for executing shell commands and modifying files. Therefore, if the respective tool calls are not monitored or restricted (and execution takes place in a sensitive environment), there is a risk of unintended consequences. Therefore, to reduce the risk of unintended consequences when using Serena, it is recommended to * back up your work regularly (e.g. use a version control system like Git), * monitor tool executions carefully (e.g. via your MCP client, provided that it supports it), * consider enabling read-only mode for your project (set `read_only: True` in project.yml) if you only want to analyze code without modifying it, * restrict the set of allowed tools via the [configuration](050_configuration), * use a sandboxed environment for running Serena (e.g. by [using Docker](docker)). ================================================ FILE: docs/02-usage/999_additional-usage.md ================================================ # Additional Usage Pointers ## Prompting Strategies We found that it is often a good idea to spend some time conceptualizing and planning a task before actually implementing it, especially for non-trivial task. This helps both in achieving better results and in increasing the feeling of control and staying in the loop. You can make a detailed plan in one session, where Serena may read a lot of your code to build up the context, and then continue with the implementation in another (potentially after creating suitable memories). ## Running Out of Context For long and complicated tasks, or tasks where Serena has read a lot of content, you may come close to the limits of context tokens. In that case, it is often a good idea to continue in a new conversation. Serena has a dedicated tool to create a summary of the current state of the progress and all relevant info for continuing it. You can request to create this summary and write it to a memory. Then, in a new conversation, you can just ask Serena to read the memory and continue with the task. In our experience, this worked really well. On the up-side, since in a single session there is no summarization involved, Serena does not usually get lost (unlike some other agents that summarize under the hood), and it is also instructed to occasionally check whether it's on the right track. Serena instructs the LLM to be economical in general, so the problem of running out of context should not occur too often, unless the task is very large or complicated. ## Serena and Git Worktrees [git-worktree](https://git-scm.com/docs/git-worktree) can be an excellent way to parallelize your work. More on this in [Anthropic: Run parallel Claude Code sessions with Git worktrees](https://docs.claude.com/en/docs/claude-code/common-workflows#run-parallel-claude-code-sessions-with-git-worktrees). When it comes to serena AND git-worktree AND larger projects (that take longer to index), the recommended way is to COPY your `$ORIG_PROJECT/.serena/cache` to `$GIT_WORKTREE/.serena/cache`. Perform [pre-indexing of your project](indexing) to avoid having to re-index per each worktree you create. ================================================ FILE: docs/03-special-guides/000_intro.md ================================================ # Special Guides This section contains special guides for certain topics that require more in-depth explanations. ================================================ FILE: docs/03-special-guides/cpp_setup.md ================================================ # C/C++ Setup Guide This guide explains how to prepare a C/C++ project so that Serena can provide reliable code intelligence via clangd or ccls language servers. This is only necessary if you use the language server variant of Serena, for users of the Serena JetBrains plugin no setup is required and the limitations described below do not apply. --- ## General Serena supports two C/C++ language servers, clangd (default) and ccls. Both have their pros and cons and require a properly configured `compile_commands.json` for cross-file reference finding, see below for details. Your project must have a `compile_commands.json` file at the repository root. This file is essential for correct parsing and cross-file reference finding. ## compile_commands.json Requirements For reliable cross-file reference finding with clangd, your `compile_commands.json` must: 1. **Include proper C++ standard flags** (e.g., `-std=c++17`) 2. **Include all necessary include paths** (`-I` flags) --- ### With clangd Serena automatically downloads and manages clangd. Since clangd does not properly work with relative paths in `compile_commands.json`, Serena will detect them and transform them into absolute paths automatically (writing a new `compile_commands.json` file), if needed. #### Customizing the Compilation Database Location By default, Serena creates the transformed compilation database at `.serena/compile_commands.json`. You can customize this location via project settings: ```yaml # .serena/project.yml language_servers: cpp: compile_commands_dir: custom/rel/path (defaults to .serena) ``` ### With ccls ccls requires manual installation and configuration. It may perform better in some situations. #### Installation **Linux:** ```bash # Ubuntu/Debian (22.04+) sudo apt-get install ccls # Fedora/RHEL sudo dnf install ccls # Arch Linux sudo pacman -S ccls ``` **macOS:** ```bash brew install ccls ``` **Windows:** ```bash choco install ccls ``` #### Configuration After installing ccls, configure Serena to use it via project settings (in `.serena/project.yml`) by adding `cpp_ccls` to the `languages` list. Replace `cpp` with `cpp_ccls` if you already have the `cpp` entry. ccls can handle relative paths in `compile_commands.json`, so no transformation is necessary and no transformed `compile_commands.json` file will be created. --- ## Known Limitations ### Files Created After Server Initialization Both clangd and ccls have a fundamental limitation: **files created by external mechanisms after the language server starts are not automatically indexed**. Cross-file references to newly created files will not work unless the new file is at some point opened by the language server (for example, by a symbol lookup in it), or until `compile_commands.json` is updated and the language server is restarted. --- ## Reference - Clangd official documentation: https://clangd.llvm.org/ - Clangd project setup: https://clangd.llvm.org/installation#project-setup - CCLS repository: https://github.com/MaskRay/ccls ================================================ FILE: docs/03-special-guides/custom_agent.md ================================================ # Custom Agents with Serena As a reference implementation, we provide an integration with the [Agno](https://docs.agno.com/introduction/playground) agent framework. Agno is a model-agnostic agent framework that allows you to turn Serena into an agent (independent of the MCP technology) with a large number of underlying LLMs. While Agno has recently added support for MCP servers out of the box, our Agno integration predates this and is a good illustration of how easy it is to integrate Serena into an arbitrary agent framework. Here's how it works: 1. Download the agent-ui code with npx ```shell npx create-agent-ui@latest ``` or, alternatively, clone it manually: ```shell git clone https://github.com/agno-agi/agent-ui.git cd agent-ui pnpm install pnpm dev ``` 2. Install serena with the optional requirements: ```shell # You can also only select agno,google or agno,anthropic instead of all-extras uv pip install --all-extras -r pyproject.toml -e . ``` 3. Copy `.env.example` to `.env` and fill in the API keys for the provider(s) you intend to use. 4. Start the agno agent app with ```shell uv run python scripts/agno_agent.py ``` By default, the script uses Claude as the model, but you can choose any model supported by Agno (which is essentially any existing model). 5. In a new terminal, start the agno UI with ```shell cd agent-ui pnpm dev ``` Connect the UI to the agent you started above and start chatting. You will have the same tools as in the MCP server version. Here is a short demo of Serena performing a small analysis task with the newest Gemini model: https://github.com/user-attachments/assets/ccfcb968-277d-4ca9-af7f-b84578858c62 ⚠️ IMPORTANT: In contrast to the MCP server approach, tool execution in the Agno UI does not ask for the user's permission. The shell tool is particularly critical, as it can perform arbitrary code execution. While we have never encountered any issues with this in our testing with Claude, allowing this may not be entirely safe. You may choose to disable certain tools for your setup in your Serena project's configuration file (`.yml`). ## Other Agent Frameworks It should be straightforward to incorporate Serena into any agent framework (like [pydantic-ai](https://ai.pydantic.dev/), [langgraph](https://langchain-ai.github.io/langgraph/tutorials/introduction/) or others). Typically, you need only to write an adapter for Serena's tools to the tool representation in the framework of your choice, as was done by us for Agno with `SerenaAgnoToolkit` (see `/src/serena/agno.py`). ================================================ FILE: docs/03-special-guides/groovy_setup_guide_for_serena.md ================================================ # Groovy Setup Guide for Serena The Groovy support in Serena is incomplete and requires the user to provide a functioning, JVM-based Groovy language server as a jar. This intermediate state allows the contributors of Groovy support to use Serena internally and hopefully to accelerate their open-source release of a Groovy language server in the future. If you happen to have a Groovy language server JAR file, you can configure Serena to use it by following the instructions below. --- ## Prerequisites - Groovy Language Server JAR file - Can be any open-source Groovy language server or your custom implementation - The JAR must be compatible with standard LSP protocol --- ## Configuration Configure Groovy Language Server by adding settings to your `~/.serena/serena_config.yml`: ### Basic Configuration ```yaml ls_specific_settings: groovy: ls_jar_path: '/path/to/groovy-language-server.jar' ls_jar_options: '-Xmx2G -Xms512m' ``` ### Custom Java Paths If you have specific Java installations: ```yaml ls_specific_settings: groovy: ls_jar_path: '/path/to/groovy-language-server.jar' ls_java_home_path: '/usr/lib/jvm/java-21-openjdk' # Custom JAVA_HOME directory ls_jar_options: '-Xmx2G -Xms512m' # Optional JVM options ``` ### Configuration Options - `ls_jar_path`: Absolute path to your Groovy Language Server JAR file (required) - `ls_java_home_path`: Custom JAVA_HOME directory for Java installation (optional) - When specified, Serena will use this Java installation instead of downloading bundled Java - Java executable path is automatically determined based on platform: - Windows: `{ls_java_home_path}/bin/java.exe` - Linux/macOS: `{ls_java_home_path}/bin/java` - Validates that Java executable exists at the expected location - `ls_jar_options`: JVM options for language server (optional) - Common options: - `-Xmx`: Maximum heap size (e.g., `-Xmx2G` for 2GB) - `-Xms`: Initial heap size (e.g., `-Xms512m` for 512MB) --- ## Project Structure Requirements For optimal Groovy Language Server performance, ensure your project follows standard Groovy/Gradle structure: ``` project-root/ ├── src/ │ ├── main/ │ │ ├── groovy/ │ │ └── resources/ │ └── test/ │ ├── groovy/ │ └── resources/ ├── build.gradle or build.gradle.kts ├── settings.gradle or settings.gradle.kts └── gradle/ └── wrapper/ ``` --- ## Using Serena with Groovy - Serena automatically detects Groovy files (`*.groovy`, `*.gvy`) and will start a Groovy Language Server JAR process per project when needed. - Optimal results require that your project compiles successfully via Gradle or Maven. If compilation fails, fix build errors in your build tool first. ## Reference - **Groovy Documentation**: [https://groovy-lang.org/documentation.html](https://groovy-lang.org/documentation.html) - **Gradle Documentation**: [https://docs.gradle.org](https://docs.gradle.org) - **Serena Configuration**: [../02-usage/050_configuration.md](../02-usage/050_configuration.md) ================================================ FILE: docs/03-special-guides/ocaml_setup_guide_for_serena.md ================================================ # OCaml Setup Guide for Serena This guide explains how to set up an OCaml project so that Serena can provide code intelligence via ocaml-lsp-server (ocamllsp). Unlike some other languages, Serena does not download the OCaml language server automatically. You must install it yourself via opam, as OCaml tooling is compiled from source against your specific environment. --- ## Prerequisites Install the following on your system and ensure they are available on `PATH`: - **opam** (OCaml package manager) - macOS: `brew install opam` - Ubuntu/Debian: `sudo apt install opam` - Fedora: `sudo dnf install opam` - Other: https://opam.ocaml.org/doc/Install.html - **OCaml compiler** (via opam) - OCaml < 5.1 or >= 5.1.1 (OCaml 5.1.0 is **not supported** by ocaml-lsp-server) - Recommended: OCaml 4.14.x (stable) or 5.2+ (for cross-file references) - **ocaml-lsp-server** (via opam) - **dune** (build system, via opam) --- ## Installation 1. Initialize opam if you haven't already: ```bash opam init eval $(opam env) ``` 2. Create an opam switch with a compatible OCaml version: ```bash # For cross-file reference support (recommended) opam switch create serena-ocaml ocaml-base-compiler.5.2.1 eval $(opam env) # Or for stable OCaml 4.14.x opam switch create serena-ocaml ocaml-base-compiler.4.14.2 eval $(opam env) ``` 3. Install the language server and build tools: ```bash opam install ocaml-lsp-server dune ``` 4. Verify the installation: ```bash opam exec -- ocamllsp --version opam exec -- ocaml -version ``` --- ## Cross-File References Cross-file reference support (finding all usages of a symbol across your project) requires: - OCaml >= 5.2 - ocaml-lsp-server >= 1.23.0 - dune >= 3.16.0 When these requirements are met, Serena automatically builds the cross-file index during startup via `dune build @ocaml-index`. Without these versions, references are limited to the current file. --- ## Using Serena with OCaml - Serena automatically detects OCaml files (`*.ml`, `*.mli`) and Reason files (`*.re`, `*.rei`). - The language server is started via `opam exec -- ocamllsp`, so your opam environment must be configured. - Ensure your project builds successfully with `dune build` before using Serena for best results. --- ## Troubleshooting | Problem | Solution | |---------|----------| | "opam not found" | Install opam and add it to PATH | | "OCaml 5.1.0 is incompatible" | Create a new switch: `opam switch create ocaml-base-compiler.5.2.1` | | "ocaml-lsp-server not found" | `opam install ocaml-lsp-server` | | Cross-file refs not working | Ensure OCaml >= 5.2 and ocaml-lsp-server >= 1.23.0; run `dune build` first | | Stale index | Rebuild with `dune build @ocaml-index` | --- ## Reference - opam: https://opam.ocaml.org - ocaml-lsp-server: https://github.com/ocaml/ocaml-lsp - Project-wide occurrences: https://discuss.ocaml.org/t/ann-project-wide-occurrences-in-merlin-and-lsp/14847 ================================================ FILE: docs/03-special-guides/scala_setup_guide_for_serena.md ================================================ # Scala Setup Guide for Serena This guide explains how to prepare a Scala project so that Serena can provide reliable code intelligence via Metals (Scala LSP) and how to run Scala tests manually. Serena automatically bootstraps the Metals language server using Coursier when needed. Your project, however, must be importable by a build server (BSP) — typically via Bloop or sbt’s built‑in BSP — so that Metals can compile and index your code. --- ## Prerequisites Install the following on your system and ensure they are available on `PATH`: - Java Development Kit (JDK). A modern LTS (e.g., 17 or 21) is recommended. - `sbt` - Coursier command (`cs`) or the legacy `coursier` launcher - Serena uses `cs` if available; if only `coursier` exists, it will attempt to install `cs`. If neither is present, install Coursier first. --- ## Quick Start (Recommended: VS Code + Metals auto‑import) 1. Open your Scala project in VS Code. 2. When prompted by Metals, accept “Import build”. Wait until the import and initial compile/indexing finish. 3. Run the “Connect to build server” command (id: `build.connect`). 4. Once the import completes, start Serena in your project root and use it. This flow ensures the `.bloop/` and (if applicable) `.metals/` directories are created and your build is known to the build server that Metals uses. --- ## Manual Setup (No VS Code) Follow these steps if you prefer a manual setup or you are not using VS Code: These instructions cover the setup for projects that use sbt as the build tool, with Bloop as the BSP server. 1. Add Bloop to `project/plugins.sbt` in your Scala project: ```scala // project/plugins.sbt addSbtPlugin("ch.epfl.scala" % "sbt-bloop" % "") ``` Replace `` with an appropriate current version from the Metals documentation. 2. Export Bloop configuration with sources: ```bash sbt -Dbloop.export-jar-classifiers=sources bloopInstall ``` This creates a `.bloop/` directory containing your project’s build metadata for the BSP server. 3. Compile from sbt to verify the build: ```bash sbt compile ``` 4. Start Serena in your project root. Serena will bootstrap Metals (if not already present) and connect to the build server using the configuration exported above. --- ## Using Serena with Scala - Serena automatically detects Scala files (`*.scala`, `*.sbt`) and will start a Metals process per project when needed. - On first run, you may see messages like “Bootstrapping metals…” in the Serena logs — this is expected. - Optimal results require that your project compiles successfully via the build server (BSP). If compilation fails, fix build errors in `sbt` first. Notes: - Ensure you completed the manual or auto‑import steps so that the build is compiled and indexed; otherwise, code navigation and references may be incomplete until the first successful compile. --- ## Running Multiple Metals Instances Serena can run alongside other Metals instances (e.g., VS Code with Metals extension) on the same project. This is **fully supported** by Metals via H2 AUTO_SERVER mode. ### How It Works Metals uses an H2 database (`.metals/metals.mv.db`) to cache semantic information. When multiple Metals instances run on the same project: - **H2 AUTO_SERVER**: The first instance becomes the TCP server; subsequent instances connect as clients - **Bloop Build Server**: All instances share a single Bloop process (port 8212) - **Compilation Results**: Shared via Bloop — no duplicate compilation ### Stale Lock Detection If a Metals process crashes without proper cleanup, it may leave a stale lock file (`.metals/metals.mv.db.lock.db`). This can prevent proper AUTO_SERVER coordination, causing new instances to fall back to in-memory database mode (degraded experience). Serena automatically detects and handles stale locks based on your configuration: ```yaml # ~/.serena/serena_config.yml or .serena/project.yml ls_specific_settings: scala: on_stale_lock: "auto-clean" # auto-clean | warn | fail log_multi_instance_notice: true # Log info when another Metals detected ``` #### Stale Lock Modes | Mode | Behavior | |------|----------| | `auto-clean` | **(Default, Recommended)** Automatically removes stale lock files and proceeds normally. | | `warn` | Logs a warning but proceeds. Metals may use in-memory database (slower). | | `fail` | Raises an error and refuses to start. Useful for debugging lock issues. | --- ## Reference - Metals + sbt: [https://scalameta.org/metals/docs/build-tools/sbt](https://scalameta.org/metals/docs/build-tools/sbt) ================================================ FILE: docs/03-special-guides/serena_on_chatgpt.md ================================================ # Connecting Serena MCP Server to ChatGPT via MCPO & Cloudflare Tunnel This guide explains how to expose a **locally running Serena MCP server** (powered by MCPO) to the internet using **Cloudflare Tunnel**, and how to connect it to **ChatGPT as a Custom GPT with tool access**. Once configured, ChatGPT becomes a powerful **coding agent** with direct access to your codebase, shell, and file system — so **read the security notes carefully**. --- ## Prerequisites Make sure you have [uv](https://docs.astral.sh/uv/getting-started/installation/) and [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) installed. ## 1. Start the Serena MCP Server via MCPO Run the following command to launch Serena as http server (assuming port 8000): ```bash uvx mcpo --port 8000 --api-key -- \ uvx --from git+https://github.com/oraios/serena \ serena start-mcp-server --context chatgpt --project $(pwd) ``` - `--api-key` is required to secure the server. - `--project` should point to the root of your codebase. You can also use other options, and you don't have to pass `--project` if you want to work on multiple projects or want to activate it later. See ```shell uvx --from git+https://github.com/oraios/serena serena start-mcp-server --help ``` --- ## 2. Expose the Server Using Cloudflare Tunnel Run: ```bash cloudflared tunnel --url http://localhost:8000 ``` This will give you a **public HTTPS URL** like: ``` https://serena-agent-tunnel.trycloudflare.com ``` Your server is now securely exposed to the internet. --- ## 3. Connect It to ChatGPT (Custom GPT) ### Steps: 1. Go to [ChatGPT → Explore GPTs → Create](https://chat.openai.com/gpts/editor) 2. During setup, click **“Add APIs”** 3. Set up **API Key authentication** with the auth type as **Bearer** and enter the api key you used to start the MCPO server. 4. In the **Schema** section, click on **import from URL** and paste `/openapi.json` with the URL you got from the previous step. 5. Add the following line to the top of the imported JSON schema: ``` "servers": ["url": ""], ``` **Important**: don't include a trailing slash at the end of the URL! ChatGPT will read the schema and create functions automatically. --- ## Security Warning — Read Carefully Depending on your configuration and enabled tools, Serena's MCP server may: - Execute **arbitrary shell commands** - Read, write, and modify **files in your codebase** This gives ChatGPT the same powers as a remote developer on your machine. ### ⚠️ Key Rules: - **NEVER expose your API key** - **Only expose this server when needed**, and monitor its use. In your project’s `.serena/project.yml` or global config, you can disable tools like: ```yaml excluded_tools: - execute_shell_command - ... read_only: true ``` This is strongly recommended if you want a read-only or safer agent. --- ## Final Thoughts With this setup, ChatGPT becomes a coding assistant **running on your local code** — able to index, search, edit, and even run shell commands depending on your configuration. Use responsibly, and keep security in mind. ================================================ FILE: docs/_config.yml ================================================ # Book settings # Learn more at https://jupyterbook.org/customize/config.html ####################################################################################### # A default configuration that will be loaded for all jupyter books # Users are expected to override these values in their own `_config.yml` file. # This is also the "master list" of all allowed keys and values. ####################################################################################### # Book settings title : Serena Documentation # The title of the book. Will be placed in the left navbar. author : Oraios AI & Oraios Software # The author of the book copyright : "2025 by Serena contributors" # Copyright year to be placed in the footer # Patterns to skip when building the book. Can be glob-style (e.g. "*skip.ipynb") exclude_patterns : ['**.ipynb_checkpoints', '.DS_Store', 'Thumbs.db', '_build', 'jupyter_execute', '.jupyter_cache', '.pytest_cache', 'docs/autogen_docs.py', 'docs/create_toc.py'] # Auto-exclude files not in the toc only_build_toc_files : true ####################################################################################### # Execution settings execute: # NOTE: Notebooks are not executed, because test_notebooks.py executes them and stores them with outputs in the docs/ folder # NOTE: If changed, repeat below in `nb_execution_mode`. execute_notebooks : "off" # Whether to execute notebooks at build time. Must be one of ("auto", "force", "cache", "off") cache : "" # A path to the jupyter cache that will be used to store execution artifacts. Defaults to `_build/.jupyter_cache/` exclude_patterns : [] # A list of patterns to *skip* in execution (e.g. a notebook that takes a really long time) timeout : 1000 # The maximum time (in seconds) each notebook cell is allowed to run. run_in_temp : false # If `True`, then a temporary directory will be created and used as the command working directory (cwd), # otherwise the notebook's parent directory will be the cwd. allow_errors : true # If `False`, when a code cell raises an error the execution is stopped, otherwise all cells are always run. stderr_output : show # One of 'show', 'remove', 'remove-warn', 'warn', 'error', 'severe' ####################################################################################### # Parse and render settings parse: myst_enable_extensions: # default extensions to enable in the myst parser. See https://myst-parser.readthedocs.io/en/latest/using/syntax-optional.html - amsmath - colon_fence # - deflist - dollarmath # - html_admonition # - html_image - linkify # - replacements # - smartquotes - substitution - tasklist - html_admonition - html_image myst_url_schemes: [ mailto, http, https ] # URI schemes that will be recognised as external URLs in Markdown links myst_dmath_double_inline: true # Allow display math ($$) within an inline context ####################################################################################### # HTML-specific settings html: favicon : "../src/serena/resources/dashboard/serena-icon-32.png" use_edit_page_button : false # Whether to add an "edit this page" button to pages. If `true`, repository information in repository: must be filled in use_repository_button : false # Whether to add a link to your repository button use_issues_button : false # Whether to add an "open an issue" button use_multitoc_numbering : true # Continuous numbering across parts/chapters use_darkmode_button : false extra_footer : "" home_page_in_navbar : true # Whether to include your home page in the left Navigation Bar baseurl : "https://oraios.github.io/serena/" comments: hypothesis : false utterances : false announcement : "" # A banner announcement at the top of the site. ####################################################################################### # LaTeX-specific settings latex: latex_engine : pdflatex # one of 'pdflatex', 'xelatex' (recommended for unicode), 'luatex', 'platex', 'uplatex' use_jupyterbook_latex : true # use sphinx-jupyterbook-latex for pdf builds as default targetname : book.tex # Add a bibtex file so that we can create citations #bibtex_bibfiles: # - refs.bib ####################################################################################### # Launch button settings launch_buttons: notebook_interface : classic # The interface interactive links will activate ["classic", "jupyterlab"] binderhub_url : "" # The URL of the BinderHub (e.g., https://mybinder.org) jupyterhub_url : "" # The URL of the JupyterHub (e.g., https://datahub.berkeley.edu) thebe : false # Add a thebe button to pages (requires the repository to run on Binder) colab_url : "https://colab.research.google.com" repository: url : https://github.com/oraios/serena # The URL to your book's repository path_to_book : docs # A path to your book's folder, relative to the repository root. branch : main # Which branch of the repository should be used when creating links ####################################################################################### # Advanced and power-user settings sphinx: extra_extensions : - sphinx.ext.autodoc - sphinx.ext.viewcode - sphinx_toolbox.more_autodoc.sourcelink #- sphinxcontrib.spelling local_extensions : # A list of local extensions to load by sphinx specified by "name: path" items recursive_update : false # A boolean indicating whether to overwrite the Sphinx config (true) or recursively update (false) config : # key-value pairs to directly over-ride the Sphinx configuration master_doc: "01-about/000_intro.md" html_theme_options: logo: image_light: ../resources/serena-logo.svg image_dark: ../resources/serena-logo-dark-mode.svg autodoc_typehints_format: "short" autodoc_member_order: "bysource" autoclass_content: "both" autodoc_default_options: show-inheritance: True autodoc_show_sourcelink: True add_module_names: False github_username: oraios github_repository: serena nb_execution_mode: "off" nb_merge_streams: True # This is important for cell outputs to appear as single blocks rather than one block per line python_use_unqualified_type_names: True nb_mime_priority_overrides: [ [ 'html', 'application/vnd.jupyter.widget-view+json', 10 ], [ 'html', 'application/javascript', 20 ], [ 'html', 'text/html', 30 ], [ 'html', 'text/latex', 40 ], [ 'html', 'image/svg+xml', 50 ], [ 'html', 'image/png', 60 ], [ 'html', 'image/jpeg', 70 ], [ 'html', 'text/markdown', 80 ], [ 'html', 'text/plain', 90 ], [ 'spelling', 'application/vnd.jupyter.widget-view+json', 10 ], [ 'spelling', 'application/javascript', 20 ], [ 'spelling', 'text/html', 30 ], [ 'spelling', 'text/latex', 40 ], [ 'spelling', 'image/svg+xml', 50 ], [ 'spelling', 'image/png', 60 ], [ 'spelling', 'image/jpeg', 70 ], [ 'spelling', 'text/markdown', 80 ], [ 'spelling', 'text/plain', 90 ], ] mathjax_path: https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js mathjax3_config: loader: { load: [ '[tex]/configmacros' ] } tex: packages: { '[+]': [ 'configmacros' ] } macros: vect: ["{\\mathbf{\\boldsymbol{#1}} }", 1] E: "{\\mathbb{E}}" P: "{\\mathbb{P}}" R: "{\\mathbb{R}}" abs: ["{\\left| #1 \\right|}", 1] simpl: ["{\\Delta^{#1} }", 1] amax: "{\\text{argmax}}" ================================================ FILE: docs/autogen_docs.py ================================================ import logging import os import shutil from pathlib import Path from typing import Optional, List from sensai.util.string import TextBuilder log = logging.getLogger(os.path.basename(__file__)) TOP_LEVEL_PACKAGE = "serena" PROJECT_NAME = "Serena" def module_template(module_qualname: str): module_name = module_qualname.split(".")[-1] title = module_name.replace("_", r"\_") return f"""{title} {"=" * len(title)} .. automodule:: {module_qualname} :members: :undoc-members: """ def index_template(package_name: str, doc_references: Optional[List[str]] = None, text_prefix=""): doc_references = doc_references or "" if doc_references: doc_references = "\n" + "\n".join(f"* :doc:`{ref}`" for ref in doc_references) + "\n" dirname = package_name.split(".")[-1] title = dirname.replace("_", r"\_") if title == TOP_LEVEL_PACKAGE: title = "API Reference" return f"{title}\n{'=' * len(title)}" + text_prefix + doc_references def write_to_file(content: str, path: str): os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w") as f: f.write(content) os.chmod(path, 0o666) _SUBTITLE = ( f"\n Here is the autogenerated documentation of the {PROJECT_NAME} API. \n \n " "The Table of Contents to the left has the same structure as the " "repository's package code. The links at each page point to the submodules and subpackages. \n" ) def make_rst(src_root, rst_root, clean=False, overwrite=False, package_prefix=""): """Creates/updates documentation in form of rst files for modules and packages. Does not delete any existing rst files. Thus, rst files for packages or modules that have been removed or renamed should be deleted by hand. This method should be executed from the project's top-level directory :param src_root: path to library base directory, typically "src/" :param rst_root: path to the root directory to which .rst files will be written :param clean: whether to completely clean the target directory beforehand, removing any existing .rst files :param overwrite: whether to overwrite existing rst files. This should be used with caution as it will delete all manual changes to documentation files :package_prefix: a prefix to prepend to each module (for the case where the src_root is not the base package), which, if not empty, should end with a "." :return: """ rst_root = os.path.abspath(rst_root) if clean and os.path.isdir(rst_root): shutil.rmtree(rst_root) base_package_name = package_prefix + os.path.basename(src_root) # TODO: reduce duplication with same logic for subpackages below files_in_dir = os.listdir(src_root) module_names = [f[:-3] for f in files_in_dir if f.endswith(".py") and not f.startswith("_")] subdir_refs = [ f"{f}/index" for f in files_in_dir if os.path.isdir(os.path.join(src_root, f)) and not f.startswith("_") and not f.startswith(".") ] package_index_rst_path = os.path.join( rst_root, "index.rst", ) log.info(f"Writing {package_index_rst_path}") write_to_file( index_template( base_package_name, doc_references=module_names + subdir_refs, text_prefix=_SUBTITLE, ), package_index_rst_path, ) for root, dirnames, filenames in os.walk(src_root): if os.path.basename(root).startswith("_"): continue base_package_relpath = os.path.relpath(root, start=src_root) base_package_qualname = package_prefix + os.path.relpath( root, start=os.path.dirname(src_root), ).replace(os.path.sep, ".") for dirname in dirnames: if dirname.startswith("_"): log.debug(f"Skipping {dirname}") continue files_in_dir = os.listdir(os.path.join(root, dirname)) module_names = [ f[:-3] for f in files_in_dir if f.endswith(".py") and not f.startswith("_") ] subdir_refs = [ f"{f}/index" for f in files_in_dir if os.path.isdir(os.path.join(root, dirname, f)) and not f.startswith("_") ] package_qualname = f"{base_package_qualname}.{dirname}" package_index_rst_path = os.path.join( rst_root, base_package_relpath, dirname, "index.rst", ) log.info(f"Writing {package_index_rst_path}") write_to_file( index_template(package_qualname, doc_references=module_names + subdir_refs), package_index_rst_path, ) for filename in filenames: base_name, ext = os.path.splitext(filename) if ext == ".py" and not filename.startswith("_"): module_qualname = f"{base_package_qualname}.{filename[:-3]}" module_rst_path = os.path.join(rst_root, base_package_relpath, f"{base_name}.rst") if os.path.exists(module_rst_path) and not overwrite: log.debug(f"{module_rst_path} already exists, skipping it") log.info(f"Writing module documentation to {module_rst_path}") write_to_file(module_template(module_qualname), module_rst_path) def autogen_tool_list(target_filename = "01-about/035_tools.md"): from serena.tools import ToolRegistry target_file = Path(__file__).parent / target_filename with open(target_file, "w") as f: f.write("\n\n") f.write("# Tools\n\n") f.write("Find the full list of Serena's tools below. \n") f.write("Note that in most configurations, only a subset of these tools will be enabled simultaneously.\n") f.write("Tools marked as optional are disabled by default.\n\n") tools_by_module = ToolRegistry().get_registered_tools_by_module() priority_modules = {"serena.tools.symbol_tools": 1, "serena.tools.jetbrains_tools": 2} text = TextBuilder() sorted_modules = sorted(tools_by_module.keys(), key=lambda m: (priority_modules.get(m, 3), m)) for module in sorted_modules: tools = tools_by_module[module] module = module.replace("serena.tools.", "") text.with_line(f"* **{module}**") for tool in tools: info = " *(optional)*" if tool.is_optional else "" text.with_line(f"* `{tool.tool_name}`{info}: {tool.class_docstring}", indent=2) f.write(text.build()) if __name__ == "__main__": logging.basicConfig(level=logging.INFO) docs_root = Path(__file__).parent enable_module_docs = False autogen_tool_list() if enable_module_docs: make_rst( docs_root / ".." / "src" / "serena", docs_root / "serena", clean=True, ) ================================================ FILE: docs/create_toc.py ================================================ import os from pathlib import Path # This script provides a platform-independent way of making the jupyter-book call (used in pyproject.toml) folder = Path(__file__).parent toc_file = folder / "_toc.yml" cmd = f"jupyter-book toc from-project docs -e .rst -e .md -e .ipynb >{toc_file}" print(cmd) os.system(cmd) ================================================ FILE: docs/index.md ================================================ If you are not redirected automatically, [click here](01-about/000_intro.html). ================================================ FILE: flake.nix ================================================ { description = "A powerful coding agent toolkit providing semantic retrieval and editing capabilities (MCP server & Agno integration)"; inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; flake-utils = { url = "github:numtide/flake-utils"; }; pyproject-nix = { url = "github:pyproject-nix/pyproject.nix"; inputs.nixpkgs.follows = "nixpkgs"; }; uv2nix = { url = "github:pyproject-nix/uv2nix"; inputs = { pyproject-nix.follows = "pyproject-nix"; nixpkgs.follows = "nixpkgs"; }; }; pyproject-build-systems = { url = "github:pyproject-nix/build-system-pkgs"; inputs = { pyproject-nix.follows = "pyproject-nix"; uv2nix.follows = "uv2nix"; nixpkgs.follows = "nixpkgs"; }; }; }; outputs = { nixpkgs, uv2nix, pyproject-nix, pyproject-build-systems, flake-utils, ... }: flake-utils.lib.eachDefaultSystem (system: let pkgs = import nixpkgs {inherit system;}; inherit (pkgs) lib; workspace = uv2nix.lib.workspace.loadWorkspace {workspaceRoot = ./.;}; overlay = workspace.mkPyprojectOverlay { sourcePreference = "wheel"; # or sourcePreference = "sdist"; }; pyprojectOverrides = final: prev: { # Add setuptools for packages that need it during build ruamel-yaml-clib = prev.ruamel-yaml-clib.overrideAttrs (old: { nativeBuildInputs = (old.nativeBuildInputs or []) ++ [ final.setuptools ]; }); }; python = pkgs.python311; pythonSet = (pkgs.callPackage pyproject-nix.build.packages { inherit python; }).overrideScope ( lib.composeManyExtensions [ pyproject-build-systems.overlays.default overlay pyprojectOverrides ] ); in rec { formatter = pkgs.alejandra; packages = { serena-env = pythonSet.mkVirtualEnv "serena-env" workspace.deps.default; serena = pkgs.runCommand "serena" { meta = { description = "A powerful coding agent toolkit providing semantic retrieval and editing capabilities (MCP server & Agno integration)"; homepage = "https://oraios.github.io/serena"; changelog = "https://github.com/oraios/serena/blob/main/CHANGELOG.md"; mainProgram = "serena"; license = pkgs.lib.licenses.mit; platforms = lib.platforms.all; }; } '' mkdir -p $out/bin ln -s ${packages.serena-env}/bin/serena $out/bin/serena ''; default = packages.serena; }; apps.default = { type = "app"; program = "${packages.default}/bin/serena"; }; devShells = { default = pkgs.mkShell { packages = [ python pkgs.uv ]; env = { UV_PYTHON_DOWNLOADS = "never"; UV_PYTHON = python.interpreter; } // lib.optionalAttrs pkgs.stdenv.isLinux { LD_LIBRARY_PATH = lib.makeLibraryPath pkgs.pythonManylinuxPackages.manylinux1; }; shellHook = '' unset PYTHONPATH ''; }; }; }); } ================================================ FILE: lessons_learned.md ================================================ # Lessons Learned In this document we briefly collect what we have learned while developing and using Serena, what works well and what doesn't. ## What Worked ### Separate Tool Logic From MCP Implementation MCP is just another protocol, one should let the details of it creep into the application logic. The official docs suggest using function annotations to define tools and prompts. While that may be useful for small projects to get going fast, it is not wise for more serious projects. In Serena, all tools are defined independently and then converted to instances of `MCPTool` using our `make_tool` function. ### Autogenerated PromptFactory Prompt templates are central for most LLM applications, so one needs good representations of them in the code, while at the same time they often need to be customizable and exposed to users. In Serena we address these conflicting needs by defining prompt templates (in jinja format) in separate yamls that users can easily modify and by autogenerated a `PromptFactory` class with meaningful method and parameter names from these yamls. The latter is committed to our code. We separated out the generation logic into the [interprompt](/src/interprompt/README.md) subpackage that can be used as a library. ### Tempfiles and Snapshots for Testing of Editing Tools We test most aspects of Serena by having a small "project" for each supported language in `tests/resources`. For the editing tools, which would change the code in these projects, we use tempfiles to copy over the code. The pretty awesome [syrupy](https://github.com/syrupy-project/syrupy) pytest plugin helped in developing snapshot tests. ### Dashboard and GUI for Logging It is very useful to know what the MCP Server is doing. We collect and display logs in a GUI or a web dashboard, which helps a lot in seeing what's going on and in identifying any issues. ### Unrestricted Bash Tool We know it's not particularly safe to permit unlimited shell commands outside a sandbox, but we did quite some evaluations and so far... nothing bad has happened. Seems like the current versions of the AI overlords rarely want to execute `sudo rm - rf /`. Still, we are working on a safer approach as well as better integration with sandboxing. ### Multilspy The [multilspy](https://github.com/microsoft/multilspy/) project helped us a lot in getting started and stands at the core of Serena. Many more well known python implementations of language servers were subpar in code quality and design (for example, missing types). ### Developing Serena with Serena We clearly notice that the better the tool gets, the easier it is to make it even better ## Prompting ### Shouting and Emotive Language May Be Needed When developing the `ReplaceRegexTool` we were initially not able to make Claude 4 (in Claude Desktop) use wildcards to save on output tokens. Neither examples nor explicit instructions helped. It was only after adding ``` IMPORTANT: REMEMBER TO USE WILDCARDS WHEN APPROPRIATE! I WILL BE VERY UNHAPPY IF YOU WRITE LONG REGEXES WITHOUT USING WILDCARDS INSTEAD! ``` to the initial instructions and to the tool description that Claude finally started following the instructions. ## What Didn't Work ### Lifespan Handling by MCP Clients The MCP technology is clearly very green. Even though there is a lifespan context in the MCP SDK, many clients, including Claude Desktop, fail to properly clean up, leaving zombie processes behind. We mitigate this through the GUI window and the dashboard, so the user sees whether Serena is running and can terminate it there. ### Trusting Asyncio Running multiple asyncio apps led to non-deterministic event loop contamination and deadlocks, which were very hard to debug and understand. We solved this with a large hammer, by putting all asyncio apps into a separate process. It made the code much more complex and slightly enhanced RAM requirements, but it seems like that was the only way to reliably overcome asyncio deadlock issues. ### Cross-OS Tkinter GUI Different OS have different limitations when it comes to starting a window or dealing with Tkinter installations. This was so messy to get right that we pivoted to a web-dashboard instead ### Editing Based on Line Numbers Not only are LLMs notoriously bad in counting, but also the line numbers change after edit operations, and LLMs are also often too dumb to understand that they should update the line numbers information they had received before. We pivoted to string-matching and symbol-name based editing. ================================================ FILE: llms-install.md ================================================ # MCP Installation instructions This document is mainly used as instructions for AI-assistants like Cline and others that try to do an automatic install based on freeform instructions. 0. Make sure `uv` is installed. If not, install it using either `curl -LsSf https://astral.sh/uv/install.sh | sh` (macOS, Linux) or `powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"` (Windows). Find the path to the `uv` executable, you'll need it later. 1. Clone the repo with `git clone git@github.com:oraios/serena.git` and change into its dir (e.g., `cd serena`) 2. Check if `serena_config.yml` exists. If not, create it with `cp serena_config.template.yml serena_config.yml`. Read the instructions in the config. 3. In the config, check if the path to your project was added. If not, add it to the `projects` section 4. In your project, create a `.serena` if needed and check whether `project.yml` exists there. 5. If no `project.yml` was found, create it using `cp /path/to/serena/myproject.template.yml /path/to/your/project/.serena/project.yml` 6. Read the instructions in `project.yml`. Make sure the `project.yml` has the correct project language configured. Remove the project_root entry there. 7. Finally, add the Serena MCP server config like this: ```json { "mcpServers": { ... "serena": { "command": "/abs/path/to/uv", "args": ["run", "--directory", "/abs/path/to/serena", "serena-mcp-server", "/path/to/your/project/.serena/project.yml"] } } } ``` ================================================ FILE: pyproject.toml ================================================ [build-system] build-backend = "hatchling.build" requires = ["hatchling"] [project] name = "serena-agent" version = "0.1.4" description = "" authors = [{ name = "Oraios AI", email = "info@oraios-ai.de" }] readme = "README.md" requires-python = ">=3.11, <3.12" classifiers = [ "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3.11", ] dependencies = [ "requests>=2.32.3,<3", "pyright>=1.1.396,<2", "fortls>=3.2.2", "overrides>=7.7.0,<8", "python-dotenv>=1.0.0, <2", "mcp==1.23.0", "flask>=3.0.0", "sensai-utils>=1.5.0", "pydantic>=2.10.6", "types-pyyaml>=6.0.12.20241230", "pyyaml>=6.0.2", "ruamel.yaml==0.18.14", "jinja2>=3.1.6", "dotenv>=0.9.9", "pathspec>=0.12.1", "psutil>=7.0.0", "docstring_parser>=0.16", "joblib>=1.5.1", "tqdm>=4.67.1", "tiktoken>=0.9.0", "anthropic>=0.54.0", "beautifulsoup4>=4.14.2", ] [[tool.uv.index]] name = "testpypi" url = "https://test.pypi.org/simple/" publish-url = "https://test.pypi.org/legacy/" explicit = true [project.scripts] serena = "serena.cli:top_level" serena-mcp-server = "serena.cli:start_mcp_server" index-project = "serena.cli:index_project" # deprecated [project.license] text = "MIT" [project.optional-dependencies] dev = [ "black[jupyter]>=23.7.0, <26", # black 26 is incompatible with our pathspec version "jinja2", # In version 1.0.4 we get a NoneType error related to some config conversion (yml_analytics is None and should be a list) "mypy>=1.16.1", "poethepoet>=0.20.0", "pytest>=8.0.2", "pytest-xdist>=3.5.0", "ruff==0.12.5", "toml-sort>=0.24.2", "types-pyyaml>=6.0.12.20241230", "syrupy>=4.9.1", "types-requests>=2.32.4.20241230", # docs "sphinx>=7,<8", "sphinx_rtd_theme>=0.5.1", "sphinx-toolbox==3.7.0", "jupyter-book>=1,<2", "nbsphinx", "pyinstrument", "pytest-timeout>=2.4.0", ] agno = ["agno>=2.2.1", "sqlalchemy>=2.0.40"] google = ["google-genai>=1.8.0"] [project.urls] Homepage = "https://github.com/oraios/serena" [tool.hatch.build.targets.wheel] packages = ["src/serena", "src/interprompt", "src/solidlsp"] [tool.black] line-length = 140 target-version = ["py311"] exclude = ''' /( src/solidlsp/language_servers/.*/static|src/multilspy )/ ''' [tool.doc8] max-line-length = 1000 [tool.mypy] allow_redefinition = true check_untyped_defs = true disallow_incomplete_defs = true disallow_untyped_defs = true ignore_missing_imports = true no_implicit_optional = true pretty = true show_error_codes = true show_error_context = true show_traceback = true strict_equality = true strict_optional = true warn_no_return = true warn_redundant_casts = true warn_unreachable = true warn_unused_configs = true warn_unused_ignores = false exclude = "^build/|^docs/|^test/resources/" [[tool.mypy.overrides]] module = "test.*" disallow_untyped_defs = false # untyped defs are OK in tests [tool.poe.env] PYDEVD_DISABLE_FILE_VALIDATION = "1" [tool.poe.executor] # This is important when using poe with uv, because otherwise poe will try to run commands through # uv, which in turn will try to recreate the environment, which in turn will fail if any process # using the environment is already running! Naturally, processes using the env are running all the # time (e.g. a Serena MCP server), so using the default executor (uv) is not an option! type = "simple" [tool.poe.tasks] # Uses PYTEST_MARKERS env var for default markers # For custom markers, one can either adjust the env var or just use -m option in the command line, # as the second -m option will override the first one. test = "pytest test -vv" _black_check = "black --check src scripts test" _ruff_check = "ruff check src scripts test" _black_format = "black src scripts test" _ruff_format = "ruff check --fix src scripts test" lint = ["_black_check", "_ruff_check"] format = ["_ruff_format", "_black_format"] _mypy_core = "mypy src/serena src/solidlsp" _mypy_test = "mypy --disable-error-code no-untyped-def test" type-check = ["_mypy_core", "_mypy_test"] # docs _autogen_docs = "python docs/autogen_docs.py" _sphinx_build = "sphinx-build -b html docs docs/_build -W --keep-going" _jb_generate_toc = "python docs/create_toc.py" _jb_generate_config = "jupyter-book config sphinx docs/" doc-clean = "rm -rf docs/_build docs/03_api" doc-generate-files = ["_autogen_docs", "_jb_generate_toc", "_jb_generate_config"] doc-build = ["doc-clean", "doc-generate-files", "_sphinx_build"] [tool.ruff] target-version = "py311" line-length = 140 exclude = ["src/solidlsp/language_servers/**/static", "src/multilspy"] [tool.ruff.format] quote-style = "double" indent-style = "space" line-ending = "auto" skip-magic-trailing-comma = false docstring-code-format = true [tool.ruff.lint] select = [ "ASYNC", "B", "C4", "C90", "COM", "D", "DTZ", "E", "F", "FLY", "G", "I", "ISC", "PIE", "PLC", "PLE", "PLW", "RET", "RUF", "RSE", "SIM", "TID", "UP", "W", "YTT", ] ignore = [ "PLC0415", "RUF002", "RUF005", "RUF059", "SIM118", "SIM108", "E501", "E741", "B008", "B011", "B028", "D100", "D101", "D102", "D103", "D104", "D105", "D107", "D200", "D203", "D213", "D401", "D402", "DTZ005", "E402", "E501", "E701", "E731", "C408", "E203", "G004", "RET505", "D106", "D205", "D212", "PLW2901", "B027", "D404", "D407", "D408", "D409", "D400", "D415", "COM812", "RET503", "RET504", "F403", "F405", "C401", "C901", "ASYNC230", "ISC003", "B024", "B007", "SIM102", "W291", "W293", "B009", "SIM103", # forbids multiple returns "SIM110", # requires use of any(...) instead of for-loop "G001", # forbids str.format in log statements "E722", # forbids unspecific except clause "SIM105", # forbids empty/general except clause "SIM113", # wants to enforce use of enumerate "E712", # forbids equality comparison with True/False "UP007", # forbids some uses of Union "TID252", # forbids relative imports "B904", # forces use of raise from other_exception "RUF012", # forbids mutable attributes as ClassVar "SIM117", # forbids nested with statements "C400", # wants to unnecessarily force use of list comprehension "UP037", # can incorrectly (!) convert quoted type to unquoted type, causing an error "UP045", # imposes T | None instead of Optional[T] "UP031", # forbids % operator to format strings "UP042", # wants str,Enum -> StrEnum (breaking change) "PLW0108", # unnecessary lambda (style preference) "PLC0207", # split vs rsplit optimization (style preference) ] unfixable = ["F841", "F601", "F602", "B018"] extend-fixable = ["F401", "B905", "W291"] [tool.ruff.lint.mccabe] max-complexity = 20 [tool.ruff.lint.per-file-ignores] "tests/**" = ["D103"] "scripts/**" = ["D103"] [tool.pytest.ini_options] addopts = "--snapshot-patch-pycharm-diff" markers = [ "clojure: language server running for Clojure", "python: language server running for Python", "go: language server running for Go", "java: language server running for Java", "kotlin: language server running for kotlin", "groovy: language server running for Groovy", "rust: language server running for Rust", "typescript: language server running for TypeScript", "vue: language server running for Vue (uses TypeScript LSP)", "php: language server running for PHP", "perl: language server running for Perl", "csharp: language server running for C#", "elixir: language server running for Elixir", "elm: language server running for Elm", "terraform: language server running for Terraform", "swift: language server running for Swift", "bash: language server running for Bash", "r: language server running for R", "snapshot: snapshot tests for symbolic editing operations", "ruby: language server running for Ruby (uses ruby-lsp)", "zig: language server running for Zig", "lua: language server running for Lua", "luau: language server running for Luau", "nix: language server running for Nix", "dart: language server running for Dart", "erlang: language server running for Erlang", "ocaml: language server running for OCaml and Reason", "scala: language server running for Scala", "al: language server running for AL (Microsoft Dynamics 365 Business Central)", "fsharp: language server running for F#", "rego: language server running for Rego", "markdown: language server running for Markdown", "julia: Julia language server tests", "fortran: language server running for Fortran", "haskell: Haskell language server tests", "yaml: language server running for YAML", "powershell: language server running for PowerShell", "pascal: language server running for Pascal (Free Pascal/Lazarus)", "cpp: language server running for C/C++", "slow: tests that require additional Expert instances and have long startup times (~60-90s each)", "toml: language server running for TOML", "matlab: language server running for MATLAB (requires MATLAB R2021b+)", "systemverilog: language server running for SystemVerilog (uses verible-verilog-ls)", "hlsl: language server running for HLSL shaders (uses shader-language-server)", "lean4: language server running for Lean 4", "solidity: language server running for Solidity (uses @nomicfoundation/solidity-language-server)", "ansible: language server running for Ansible (uses @ansible/ansible-language-server)", ] [tool.codespell] # Ref: https://github.com/codespell-project/codespell#using-a-config-file skip = '.git*,*.svg,*.lock,*.min.*' check-hidden = true ignore-regex = '\.\w+' ignore-words-list = 'paket' ================================================ FILE: repo_dir_sync.py ================================================ # -*- coding: utf-8 -*- import glob import os import shutil from subprocess import Popen, PIPE import re import sys from typing import List, Optional, Sequence import platform def popen(cmd): shell = platform.system() != "Windows" p = Popen(cmd, shell=shell, stdin=PIPE, stdout=PIPE) return p def call(cmd): p = popen(cmd) return p.stdout.read().decode("utf-8") def execute(cmd, exceptionOnError=True): """ :param cmd: the command to execute :param exceptionOnError: if True, raise on exception on error (return code not 0); if False return whether the call was successful :return: True if the call was successful, False otherwise (if exceptionOnError==False) """ p = popen(cmd) p.wait() success = p.returncode == 0 if exceptionOnError: if not success: raise Exception("Command failed: %s" % cmd) else: return success def gitLog(path, arg): oldPath = os.getcwd() os.chdir(path) lg = call("git log --no-merges " + arg) os.chdir(oldPath) return lg def gitCommit(msg): with open(COMMIT_MSG_FILENAME, "wb") as f: f.write(msg.encode("utf-8")) gitCommitWithMessageFromFile(COMMIT_MSG_FILENAME) def gitCommitWithMessageFromFile(commitMsgFilename): if not os.path.exists(commitMsgFilename): raise FileNotFoundError(f"{commitMsgFilename} not found in {os.path.abspath(os.getcwd())}") os.system(f"git commit --file={commitMsgFilename}") os.unlink(commitMsgFilename) COMMIT_MSG_FILENAME = "commitmsg.txt" class OtherRepo: SYNC_COMMIT_ID_FILE_LIB_REPO = ".syncCommitId.remote" SYNC_COMMIT_ID_FILE_THIS_REPO = ".syncCommitId.this" SYNC_COMMIT_MESSAGE = f"Updated %s sync commit identifiers" SYNC_BACKUP_DIR = ".syncBackup" def __init__(self, name, branch, pathToLib): self.pathToLibInThisRepo = os.path.abspath(pathToLib) if not os.path.exists(self.pathToLibInThisRepo): raise ValueError(f"Repository directory '{self.pathToLibInThisRepo}' does not exist") self.name = name self.branch = branch self.libRepo: Optional[LibRepo] = None def isSyncEstablished(self): return os.path.exists(os.path.join(self.pathToLibInThisRepo, self.SYNC_COMMIT_ID_FILE_LIB_REPO)) def lastSyncIdThisRepo(self): with open(os.path.join(self.pathToLibInThisRepo, self.SYNC_COMMIT_ID_FILE_THIS_REPO), "r") as f: commitId = f.read().strip() return commitId def lastSyncIdLibRepo(self): with open(os.path.join(self.pathToLibInThisRepo, self.SYNC_COMMIT_ID_FILE_LIB_REPO), "r") as f: commitId = f.read().strip() return commitId def gitLogThisRepoSinceLastSync(self): lg = gitLog(self.pathToLibInThisRepo, '--name-only HEAD "^%s" .' % self.lastSyncIdThisRepo()) lg = re.sub(r'commit [0-9a-z]{8,40}\n.*\n.*\n\s*\n.*\n\s*(\n.*\.syncCommitId\.(this|remote))+', r"", lg, flags=re.MULTILINE) # remove commits with sync commit id update indent = " " lg = indent + lg.replace("\n", "\n" + indent) return lg def gitLogLibRepoSinceLastSync(self, libRepo: "LibRepo"): syncIdFile = os.path.join(self.pathToLibInThisRepo, self.SYNC_COMMIT_ID_FILE_LIB_REPO) if not os.path.exists(syncIdFile): return "" with open(syncIdFile, "r") as f: syncId = f.read().strip() lg = gitLog(libRepo.libPath, '--name-only HEAD "^%s" .' % syncId) lg = re.sub(r"Sync (\w+)\n\s*\n", r"Sync\n\n", lg, flags=re.MULTILINE) indent = " " lg = indent + lg.replace("\n", "\n" + indent) return "\n\n" + lg def _userInputYesNo(self, question) -> bool: result = None while result not in ("y", "n"): result = input(question + " [y|n]: ").strip() return result == "y" def pull(self, libRepo: "LibRepo"): """ Pulls in changes from this repository into the lib repo """ # switch to branch in lib repo os.chdir(libRepo.rootPath) execute("git checkout %s" % self.branch) # check if the branch contains the commit that is referenced as the remote commit remoteCommitId = self.lastSyncIdLibRepo() remoteCommitExists = execute("git rev-list HEAD..%s" % remoteCommitId, exceptionOnError=False) if not remoteCommitExists: if not self._userInputYesNo(f"\nWARNING: The referenced remote commit {remoteCommitId} does not exist " f"in your {self.libRepo.name} branch '{self.branch}'!\nSomeone else may have " f"pulled/pushed in the meantime.\nIt is recommended that you do not continue. " f"Continue?"): return # check if this branch is clean lgLib = self.gitLogLibRepoSinceLastSync(libRepo).strip() if lgLib != "": print(f"The following changes have been added to this branch in the library:\n\n{lgLib}\n\n") print(f"ERROR: You must push these changes before you can pull or reset this branch to {remoteCommitId}") sys.exit(1) # get log with relevant commits in this repo that are to be pulled lg = self.gitLogThisRepoSinceLastSync() os.chdir(libRepo.rootPath) # create commit message file commitMsg = f"Sync {self.name}\n\n" + lg with open(COMMIT_MSG_FILENAME, "w") as f: f.write(commitMsg) # ask whether to commit these changes print("Relevant commits:\n\n" + lg + "\n\n") if not self._userInputYesNo(f"The above changes will be pulled from {self.name}.\n" f"You may change the commit message by editing {os.path.abspath(COMMIT_MSG_FILENAME)}.\n" "Continue?"): os.unlink(COMMIT_MSG_FILENAME) return # prepare restoration of ignored files self.prepare_restoration_of_ignored_files(libRepo.rootPath) # remove library tree in lib repo shutil.rmtree(self.libRepo.libDirectory) # copy tree from this repo to lib repo (but drop the sync commit id files) shutil.copytree(self.pathToLibInThisRepo, self.libRepo.libDirectory) for fn in (self.SYNC_COMMIT_ID_FILE_LIB_REPO, self.SYNC_COMMIT_ID_FILE_THIS_REPO): p = os.path.join(self.libRepo.libDirectory, fn) if os.path.exists(p): os.unlink(p) # restore ignored directories/files self.restore_ignored_files(libRepo.rootPath) # make commit in lib repo os.system("git add %s" % self.libRepo.libDirectory) gitCommitWithMessageFromFile(COMMIT_MSG_FILENAME) newSyncCommitIdLibRepo = call("git rev-parse HEAD").strip() # update commit ids in this repo os.chdir(self.pathToLibInThisRepo) newSyncCommitIdThisRepo = call("git rev-parse HEAD").strip() with open(self.SYNC_COMMIT_ID_FILE_LIB_REPO, "w") as f: f.write(newSyncCommitIdLibRepo) with open(self.SYNC_COMMIT_ID_FILE_THIS_REPO, "w") as f: f.write(newSyncCommitIdThisRepo) execute('git add %s %s' % (self.SYNC_COMMIT_ID_FILE_LIB_REPO, self.SYNC_COMMIT_ID_FILE_THIS_REPO)) execute(f'git commit -m "{self.SYNC_COMMIT_MESSAGE % self.libRepo.name} (pull)"') print(f"\n\nIf everything was successful, you should now push your changes to branch " f"'{self.branch}'\nand get your branch merged into develop (issuing a pull request where appropriate)") def push(self, libRepo: "LibRepo"): """ Pushes changes from the lib repo to this repo """ os.chdir(libRepo.rootPath) # switch to the source repo branch execute(f"git checkout {self.branch}") if self.isSyncEstablished(): # check if there are any commits that have not yet been pulled unpulledCommits = self.gitLogThisRepoSinceLastSync().strip() if unpulledCommits != "": print(f"\n{unpulledCommits}\n\n") if not self._userInputYesNo(f"WARNING: The above changes in repository '{self.name}' have not" f" yet been pulled.\nYou might want to pull them.\n" f"If you continue with the push, they will be lost. Continue?"): return # get change log in lib repo since last sync libLogSinceLastSync = self.gitLogLibRepoSinceLastSync(libRepo) print("Relevant commits:\n\n" + libLogSinceLastSync + "\n\n") if not self._userInputYesNo("The above changes will be pushed. Continue?"): return print() else: libLogSinceLastSync = "" # prepare restoration of ignored files in target repo base_dir_this_repo = os.path.join(self.pathToLibInThisRepo, "..") self.prepare_restoration_of_ignored_files(base_dir_this_repo) # remove the target repo tree and update it with the tree from the source repo shutil.rmtree(self.pathToLibInThisRepo) shutil.copytree(libRepo.libPath, self.pathToLibInThisRepo) # get the commit id of the source repo we just copied commitId = call("git rev-parse HEAD").strip() # restore ignored directories and files self.restore_ignored_files(base_dir_this_repo) # go to the target repo os.chdir(self.pathToLibInThisRepo) # commit new version in this repo execute("git add .") with open(self.SYNC_COMMIT_ID_FILE_LIB_REPO, "w") as f: f.write(commitId) execute("git add %s" % self.SYNC_COMMIT_ID_FILE_LIB_REPO) gitCommit(f"{self.libRepo.name} {commitId}" + libLogSinceLastSync) commitId = call("git rev-parse HEAD").strip() # update information on the commit id we just added with open(self.SYNC_COMMIT_ID_FILE_THIS_REPO, "w") as f: f.write(commitId) execute("git add %s" % self.SYNC_COMMIT_ID_FILE_THIS_REPO) execute(f'git commit -m "{self.SYNC_COMMIT_MESSAGE % self.libRepo.name} (push)"') os.chdir(libRepo.rootPath) print(f"\n\nIf everything was successful, you should now update the remote branch:\ngit push") def prepare_restoration_of_ignored_files(self, base_dir: str): """ :param base_dir: the directory containing the lib directory, to which ignored paths are relative """ cwd = os.getcwd() os.chdir(base_dir) # ensure backup dir exists and is empty if os.path.exists(self.SYNC_BACKUP_DIR): shutil.rmtree(self.SYNC_BACKUP_DIR) os.mkdir(self.SYNC_BACKUP_DIR) # backup ignored, unversioned directories for d in self.libRepo.fullyIgnoredUnversionedDirectories: if os.path.exists(d): shutil.copytree(d, os.path.join(self.SYNC_BACKUP_DIR, d)) os.chdir(cwd) def restore_ignored_files(self, base_dir: str): """ :param base_dir: the directory containing the lib directory, to which ignored paths are relative """ cwd = os.getcwd() os.chdir(base_dir) # remove fully ignored directories that were overwritten by the sync for d in self.libRepo.fullyIgnoredVersionedDirectories + self.libRepo.fullyIgnoredUnversionedDirectories: if os.path.exists(d): print("Removing overwritten content: %s" % d) shutil.rmtree(d) # restore directories and files that can be restored via git for d in self.libRepo.ignoredDirectories + self.libRepo.fullyIgnoredVersionedDirectories: restoration_cmd = "git checkout %s" % d print("Restoring: %s" % restoration_cmd) os.system(restoration_cmd) for pattern in self.libRepo.ignoredFileGlobPatterns: for path in glob.glob(pattern, recursive=True): print("Restoring via git: %s" % path) os.system("git checkout %s" % path) # restore directories that were backed up for d in self.libRepo.fullyIgnoredUnversionedDirectories: if os.path.exists(os.path.join(self.SYNC_BACKUP_DIR, d)): print("Restoring from backup: %s" % d) shutil.copytree(os.path.join(self.SYNC_BACKUP_DIR, d), d) # remove backup dir shutil.rmtree(self.SYNC_BACKUP_DIR) os.chdir(cwd) class LibRepo: def __init__(self, name: str, libDirectory: str, ignoredDirectories: Sequence[str] = (), fullyIgnoredVersionedDirectories: Sequence[str] = (), fullyIgnoredUnversionedDirectories: Sequence[str] = (), ignoredFileGlobPatterns: Sequence[str] = () ): """ :param name: name of the library being synced :param libDirectory: relative path to the library directory within this repo :param ignoredDirectories: ignored directories; existing files in ignored directories will be restored via 'git checkout' on pull/push, but new files will be added. This is useful for configuration-like files, where users may have local changes that should not be overwritten, but new files should still be added. :param fullyIgnoredVersionedDirectories: fully ignored versioned directories will be restored to original state after push/pull via git checkout :param fullyIgnoredUnversionedDirectories: fully ignored unversioned directories will be backed up and restored to original state after push/pull :param ignoredFileGlobPatterns: files matching ignored glob patterns will be restored via 'git checkout' on pull/push """ self.name = name self.rootPath = os.path.abspath(os.path.dirname(os.path.realpath(__file__))) self.libDirectory = libDirectory self.libPath = os.path.join(self.rootPath, self.libDirectory) self.ignoredDirectories: List[str] = list(ignoredDirectories) self.fullyIgnoredVersionedDirectories: List[str] = list(fullyIgnoredVersionedDirectories) self.fullyIgnoredUnversionedDirectories: List[str] = list(fullyIgnoredUnversionedDirectories) self.ignoredFileGlobPatterns: List[str] = list(ignoredFileGlobPatterns) self.otherRepos: List[OtherRepo] = [] def add(self, repo: OtherRepo): repo.libRepo = self self.otherRepos.append(repo) def runMain(self): repos = self.otherRepos args = sys.argv[1:] if len(args) != 2: print(f"usage: sync.py <{'|'.join([repo.name for repo in repos])}> ") else: repo = [r for r in repos if r.name == args[0]] if len(repo) != 1: raise ValueError(f"Unknown repo '{args[0]}'") repo = repo[0] if args[1] == "push": repo.push(self) elif args[1] == "pull": repo.pull(self) else: raise ValueError(f"Unknown command '{args[1]}'") ================================================ FILE: scripts/agno_agent.py ================================================ from agno.models.anthropic.claude import Claude from agno.models.google.gemini import Gemini from agno.os import AgentOS from sensai.util import logging from sensai.util.helper import mark_used from serena.agno import SerenaAgnoAgentProvider mark_used(Gemini, Claude) # initialize logging if __name__ == "__main__": logging.configure(level=logging.INFO) # Define the model to use (see Agno documentation for supported models; these are just examples) # model = Claude(id="claude-3-7-sonnet-20250219") model = Gemini(id="gemini-2.5-pro") # Create the Serena agent using the existing provider serena_agent = SerenaAgnoAgentProvider.get_agent(model) # Create AgentOS app with the Serena agent agent_os = AgentOS( description="Serena coding assistant powered by AgentOS", id="serena-agentos", agents=[serena_agent], ) app = agent_os.get_app() if __name__ == "__main__": # Start the AgentOS server agent_os.serve(app="agno_agent:app", reload=False) ================================================ FILE: scripts/demo_run_tools.py ================================================ """ This script demonstrates how to use Serena's tools locally, useful for testing or development. Here the tools will be operation the serena repo itself. """ import json from pprint import pprint from serena.agent import SerenaAgent from serena.config.serena_config import SerenaConfig from serena.constants import REPO_ROOT from serena.tools import ( FindFileTool, FindReferencingSymbolsTool, JetBrainsFindSymbolTool, JetBrainsGetSymbolsOverviewTool, SearchForPatternTool, ) if __name__ == "__main__": serena_config = SerenaConfig.from_config_file() serena_config.web_dashboard = False agent = SerenaAgent(project=REPO_ROOT, serena_config=serena_config) # apply a tool find_symbol_tool = agent.get_tool(JetBrainsFindSymbolTool) find_refs_tool = agent.get_tool(FindReferencingSymbolsTool) find_file_tool = agent.get_tool(FindFileTool) search_pattern_tool = agent.get_tool(SearchForPatternTool) overview_tool = agent.get_tool(JetBrainsGetSymbolsOverviewTool) result = agent.execute_task( lambda: find_symbol_tool.apply("SerenaAgent/get_tool_description_override"), ) pprint(json.loads(result)) # input("Press Enter to continue...") ================================================ FILE: scripts/gen_prompt_factory.py ================================================ """ Autogenerates the `prompt_factory.py` module """ from pathlib import Path from sensai.util import logging from interprompt import autogenerate_prompt_factory_module from serena.constants import PROMPT_TEMPLATES_DIR_INTERNAL, REPO_ROOT log = logging.getLogger(__name__) def main(): autogenerate_prompt_factory_module( prompts_dir=PROMPT_TEMPLATES_DIR_INTERNAL, target_module_path=str(Path(REPO_ROOT) / "src" / "serena" / "generated" / "generated_prompt_factory.py"), ) if __name__ == "__main__": logging.run_main(main) ================================================ FILE: scripts/mcp_server.py ================================================ from serena.cli import start_mcp_server if __name__ == "__main__": start_mcp_server() ================================================ FILE: scripts/print_language_list.py ================================================ """ Prints the list of supported languages, for use in the project.yml template """ from solidlsp.ls_config import Language if __name__ == "__main__": lang_strings = sorted([l.value for l in Language]) max_len = max(len(s) for s in lang_strings) fmt = f"%-{max_len+2}s" for i, l in enumerate(lang_strings): if i % 5 == 0: print("\n# ", end="") print(" " + fmt % l, end="") ================================================ FILE: scripts/print_mode_context_options.py ================================================ from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode if __name__ == "__main__": print("---------- Available modes: ----------") for mode_name in SerenaAgentMode.list_registered_mode_names(): mode = SerenaAgentMode.load(mode_name) mode.print_overview() print("\n") print("---------- Available contexts: ----------") for context_name in SerenaAgentContext.list_registered_context_names(): context = SerenaAgentContext.load(context_name) context.print_overview() print("\n") ================================================ FILE: scripts/print_tool_overview.py ================================================ from serena.agent import ToolRegistry if __name__ == "__main__": ToolRegistry().print_tool_overview() ================================================ FILE: scripts/profile_tool_call.py ================================================ import cProfile from pathlib import Path from typing import Literal from sensai.util import logging from sensai.util.logging import LogTime from sensai.util.profiling import profiled from serena.agent import SerenaAgent from serena.config.serena_config import SerenaConfig from serena.tools import FindSymbolTool log = logging.getLogger(__name__) if __name__ == "__main__": logging.configure() # The profiler to use: # Use pyinstrument for hierarchical profiling output # Use cProfile to determine which functions take the most time overall (and use snakeviz to visualize) profiler: Literal["pyinstrument", "cprofile"] = "cprofile" project_path = Path(__file__).parent.parent # Serena root serena_config = SerenaConfig.from_config_file() serena_config.log_level = logging.INFO serena_config.gui_log_window = False serena_config.web_dashboard = False agent = SerenaAgent(str(project_path), serena_config=serena_config) # wait for language server to be ready agent.execute_task(lambda: log.info("Language server is ready.")) def tool_call(): """This is the function we want to profile.""" # NOTE: We use apply (not apply_ex) to run the tool call directly on the main thread with LogTime("Tool call"): result = agent.get_tool(FindSymbolTool).apply(name_path="DQN") log.info("Tool result:\n%s", result) if profiler == "pyinstrument": @profiled(log_to_file=True) def profiled_tool_call(): tool_call() profiled_tool_call() elif profiler == "cprofile": cProfile.run("tool_call()", "tool_call.pstat") ================================================ FILE: src/README.md ================================================ Serena uses (modified) versions of other libraries/packages: * solidlsp (our fork of [microsoft/multilspy](https://github.com/microsoft/multilspy) for fully synchronous language server communication) * [interprompt](https://github.com/oraios/interprompt) (our prompt templating library) ================================================ FILE: src/interprompt/.syncCommitId.remote ================================================ 059d50b7d4d7e8fb9e7a13df7f7f33bae1aed5e2 ================================================ FILE: src/interprompt/.syncCommitId.this ================================================ 53ee7f47c08f29ae336567bcfaf89f79e7d447a2 ================================================ FILE: src/interprompt/__init__.py ================================================ from .prompt_factory import autogenerate_prompt_factory_module __all__ = ["autogenerate_prompt_factory_module"] ================================================ FILE: src/interprompt/jinja_template.py ================================================ from typing import Any import jinja2 import jinja2.meta import jinja2.nodes import jinja2.visitor from interprompt.util.class_decorators import singleton class ParameterizedTemplateInterface: def get_parameters(self) -> list[str]: ... @singleton class _JinjaEnvProvider: def __init__(self) -> None: self._env: jinja2.Environment | None = None def get_env(self) -> jinja2.Environment: if self._env is None: self._env = jinja2.Environment() return self._env class JinjaTemplate(ParameterizedTemplateInterface): def __init__(self, template_string: str) -> None: self._template_string = template_string self._template = _JinjaEnvProvider().get_env().from_string(self._template_string) parsed_content = self._template.environment.parse(self._template_string) self._parameters = sorted(jinja2.meta.find_undeclared_variables(parsed_content)) def render(self, **params: Any) -> str: """Renders the template with the given kwargs. You can find out which parameters are required by calling get_parameter_names().""" return self._template.render(**params) def get_parameters(self) -> list[str]: """A sorted list of parameter names that are extracted from the template string. It is impossible to know the types of the parameter values, they can be primitives, dicts or dict-like objects. :return: the list of parameter names """ return self._parameters ================================================ FILE: src/interprompt/multilang_prompt.py ================================================ import logging import os from enum import Enum from typing import Any, Generic, Literal, TypeVar import yaml from sensai.util.string import ToStringMixin from .jinja_template import JinjaTemplate, ParameterizedTemplateInterface log = logging.getLogger(__name__) class PromptTemplate(ToStringMixin, ParameterizedTemplateInterface): def __init__(self, name: str, jinja_template_string: str) -> None: self.name = name self._jinja_template = JinjaTemplate(jinja_template_string.strip()) def _tostring_exclude_private(self) -> bool: return True def render(self, **params: Any) -> str: return self._jinja_template.render(**params) def get_parameters(self) -> list[str]: return self._jinja_template.get_parameters() class PromptList: def __init__(self, items: list[str]) -> None: self.items = [x.strip() for x in items] def to_string(self) -> str: bullet = " * " indent = " " * len(bullet) items = [x.replace("\n", "\n" + indent) for x in self.items] return "\n * ".join(items) T = TypeVar("T") DEFAULT_LANG_CODE = "default" class LanguageFallbackMode(Enum): """ Defines what to do if there is no item for the given language. """ ANY = "any" """ Return the item for any language (the first one found) """ EXCEPTION = "exception" """ If the requested language is not found, raise an exception """ USE_DEFAULT_LANG = "use_default_lang" """ If the requested language is not found, use the default language """ class _MultiLangContainer(Generic[T], ToStringMixin): """ A container of items (usually, all having the same semantic meaning) which are associated with different languages. Can also be used for single-language purposes by always using the default language code. """ def __init__(self, name: str) -> None: self.name = name self._lang2item: dict[str, T] = {} """Maps language codes to items""" def _tostring_excludes(self) -> list[str]: return ["lang2item"] def _tostring_additional_entries(self) -> dict[str, Any]: return dict(languages=list(self._lang2item.keys())) def get_language_codes(self) -> list[str]: """The language codes for which items are registered in the container.""" return list(self._lang2item.keys()) def add_item(self, item: T, lang_code: str = DEFAULT_LANG_CODE, allow_overwrite: bool = False) -> None: """Adds an item to the container, representing the same semantic entity as the other items in the container but in a different language. :param item: the item to add :param lang_code: the language shortcode for which to add the item. Use the default for single-language use cases. :param allow_overwrite: if True, allow overwriting an existing entry for the same language """ if not allow_overwrite and lang_code in self._lang2item: raise KeyError(f"Item for language '{lang_code}' already registered for name '{self.name}'") self._lang2item[lang_code] = item def has_item(self, lang_code: str = DEFAULT_LANG_CODE) -> bool: return lang_code in self._lang2item def get_item(self, lang: str = DEFAULT_LANG_CODE, fallback_mode: LanguageFallbackMode = LanguageFallbackMode.EXCEPTION) -> T: """ Gets the item for the given language. :param lang: the language shortcode for which to obtain the prompt template. A default language can be specified. :param fallback_mode: defines what to do if there is no item for the given language :return: the item """ try: return self._lang2item[lang] except KeyError as outer_e: if fallback_mode == LanguageFallbackMode.EXCEPTION: raise KeyError(f"Item for language '{lang}' not found for name '{self.name}'") from outer_e if fallback_mode == LanguageFallbackMode.ANY: try: return next(iter(self._lang2item.values())) except StopIteration as e: raise KeyError(f"No items registered for any language in container '{self.name}'") from e if fallback_mode == LanguageFallbackMode.USE_DEFAULT_LANG: try: return self._lang2item[DEFAULT_LANG_CODE] except KeyError as e: raise KeyError( f"Item not found neither for {lang=} nor for the default language '{DEFAULT_LANG_CODE}' in container '{self.name}'" ) from e def __len__(self) -> int: return len(self._lang2item) class MultiLangPromptTemplate(ParameterizedTemplateInterface): """ Represents a prompt template with support for multiple languages. The parameters of all prompt templates (for all languages) are (must be) the same. """ def __init__(self, name: str) -> None: self._prompts_container = _MultiLangContainer[PromptTemplate](name) def __len__(self) -> int: return len(self._prompts_container) @property def name(self) -> str: return self._prompts_container.name def add_prompt_template( self, prompt_template: PromptTemplate, lang_code: str = DEFAULT_LANG_CODE, allow_overwrite: bool = False ) -> None: """ Adds a prompt template for a new language. The parameters of all prompt templates (for all languages) are (must be) the same, so if a prompt template is already registered, the parameters of the new prompt template should be the same as the existing ones. :param prompt_template: the prompt template to add :param lang_code: the language code for which to add the prompt template. For single-language use cases, you should always use the default language code. :param allow_overwrite: whether to allow overwriting an existing entry for the same language """ incoming_parameters = prompt_template.get_parameters() if len(self) > 0: parameters = self.get_parameters() if parameters != incoming_parameters: raise ValueError( f"Cannot add prompt template for language '{lang_code}' to MultiLangPromptTemplate '{self.name}'" f"because the parameters are inconsistent: {parameters} vs {prompt_template.get_parameters()}" ) self._prompts_container.add_item(prompt_template, lang_code, allow_overwrite) def get_prompt_template( self, lang_code: str = DEFAULT_LANG_CODE, fallback_mode: LanguageFallbackMode = LanguageFallbackMode.EXCEPTION ) -> PromptTemplate: return self._prompts_container.get_item(lang_code, fallback_mode) def get_parameters(self) -> list[str]: if len(self) == 0: raise RuntimeError( f"No prompt templates registered for MultiLangPromptTemplate '{self.name}', make sure to register a prompt template before accessing the parameters" ) first_prompt_template = next(iter(self._prompts_container._lang2item.values())) return first_prompt_template.get_parameters() def render( self, params: dict[str, Any], lang_code: str = DEFAULT_LANG_CODE, fallback_mode: LanguageFallbackMode = LanguageFallbackMode.EXCEPTION, ) -> str: prompt_template = self.get_prompt_template(lang_code, fallback_mode) return prompt_template.render(**params) def has_item(self, lang_code: str = DEFAULT_LANG_CODE) -> bool: return self._prompts_container.has_item(lang_code) class MultiLangPromptList(_MultiLangContainer[PromptList]): pass class MultiLangPromptCollection: """ Main class for managing a collection of prompt templates and prompt lists, with support for multiple languages. All data will be read from the yamls directly contained in the given directory on initialization. It is thus assumed that you manage one directory per prompt collection. The yamls are assumed to be either of the form ```yaml lang: # optional, defaults to "default" prompts: : : [, , ...] ``` When specifying prompt templates for multiple languages, make sure that the Jinja template parameters (inferred from the things inside the `{{ }}` in the template strings) are the same for all languages (you will get an exception otherwise). The prompt names must be unique (for the same language) within the collection. """ def __init__(self, prompts_dir: str | list[str], fallback_mode: LanguageFallbackMode = LanguageFallbackMode.EXCEPTION) -> None: """ :param prompts_dir: the directory containing the prompt templates and prompt lists. If a list is provided, will look for prompt templates in the dirs from left to right (first one containing the desired template wins). :param fallback_mode: the fallback mode to use when a prompt template or prompt list is not found for the requested language. May be reset after initialization. """ self._multi_lang_prompt_templates: dict[str, MultiLangPromptTemplate] = {} self._multi_lang_prompt_lists: dict[str, MultiLangPromptList] = {} if isinstance(prompts_dir, str): prompts_dir = [prompts_dir] # Add prompts from multiple directories, prioritizing names from the left. # If name collisions appear in the first directory, an error is raised (so the first directory should have no # internal collisions, this helps in avoiding errors) # For all following directories, on a collision the new value will be ignored. # This also means that for the following directories, there is no error check on collisions internal to them. # We assume that they are correct (i.e., they have no internal collisions). first_prompts_dir, fallback_prompt_dirs = prompts_dir[0], prompts_dir[1:] self._load_from_disc(first_prompts_dir, on_name_collision="raise") for fallback_prompt_dir in fallback_prompt_dirs: # already loaded prompts have priority self._load_from_disc(fallback_prompt_dir, on_name_collision="skip") self.fallback_mode = fallback_mode def _add_prompt_template( self, name: str, template_str: str, lang_code: str = DEFAULT_LANG_CODE, on_name_collision: Literal["skip", "overwrite", "raise"] = "raise", ) -> None: """ :param name: name of the prompt template :param template_str: the Jinja template string :param lang_code: the language code for which to add the prompt template. :param on_name_collision: how to deal with name/lang_code collisions """ allow_overwrite = False prompt_template = PromptTemplate(name, template_str) mlpt = self._multi_lang_prompt_templates.get(name) if mlpt is None: mlpt = MultiLangPromptTemplate(name) self._multi_lang_prompt_templates[name] = mlpt if mlpt.has_item(lang_code): if on_name_collision == "raise": raise KeyError(f"Prompt '{name}' for {lang_code} already exists!") if on_name_collision == "skip": log.debug(f"Skipping prompt '{name}' since it already exists.") return elif on_name_collision == "overwrite": allow_overwrite = True mlpt.add_prompt_template(prompt_template, lang_code=lang_code, allow_overwrite=allow_overwrite) def _add_prompt_list( self, name: str, prompt_list: list[str], lang_code: str = DEFAULT_LANG_CODE, on_name_collision: Literal["skip", "overwrite", "raise"] = "raise", ) -> None: """ :param name: name of the prompt list :param prompt_list: a list of prompts :param lang_code: the language code for which to add the prompt list. :param on_name_collision: how to deal with name/lang_code collisions """ allow_overwrite = False multilang_prompt_list = self._multi_lang_prompt_lists.get(name) if multilang_prompt_list is None: multilang_prompt_list = MultiLangPromptList(name) self._multi_lang_prompt_lists[name] = multilang_prompt_list if multilang_prompt_list.has_item(lang_code): if on_name_collision == "raise": raise KeyError(f"Prompt '{name}' for {lang_code} already exists!") if on_name_collision == "skip": log.debug(f"Skipping prompt '{name}' since it already exists.") return elif on_name_collision == "overwrite": allow_overwrite = True multilang_prompt_list.add_item(PromptList(prompt_list), lang_code=lang_code, allow_overwrite=allow_overwrite) def _load_from_disc(self, prompts_dir: str, on_name_collision: Literal["skip", "overwrite", "raise"] = "raise") -> None: """Loads all prompt templates and prompt lists from yaml files in the given directory. :param prompts_dir: :param on_name_collision: how to deal with name/lang_code collisions """ for fn in os.listdir(prompts_dir): if not fn.endswith((".yml", ".yaml")): log.debug(f"Skipping non-YAML file: {fn}") continue path = os.path.join(prompts_dir, fn) with open(path, encoding="utf-8") as f: data = yaml.safe_load(f) try: prompts_data = data["prompts"] except KeyError as e: raise KeyError(f"Invalid yaml structure (missing 'prompts' key) in file {path}") from e lang_code = prompts_data.get("lang", DEFAULT_LANG_CODE) # add the data to the collection for prompt_name, prompt_template_or_list in prompts_data.items(): if isinstance(prompt_template_or_list, list): self._add_prompt_list(prompt_name, prompt_template_or_list, lang_code=lang_code, on_name_collision=on_name_collision) elif isinstance(prompt_template_or_list, str): self._add_prompt_template( prompt_name, prompt_template_or_list, lang_code=lang_code, on_name_collision=on_name_collision ) else: raise ValueError( f"Invalid prompt type for {prompt_name} in file {path} (should be str or list): {prompt_template_or_list}" ) def get_prompt_template_names(self) -> list[str]: return list(self._multi_lang_prompt_templates.keys()) def get_prompt_list_names(self) -> list[str]: return list(self._multi_lang_prompt_lists.keys()) def __len__(self) -> int: return len(self._multi_lang_prompt_templates) def get_multilang_prompt_template(self, prompt_name: str) -> MultiLangPromptTemplate: """The MultiLangPromptTemplate object for the given prompt name. For single-language use cases, you should use the `get_prompt_template` method instead.""" return self._multi_lang_prompt_templates[prompt_name] def get_multilang_prompt_list(self, prompt_name: str) -> MultiLangPromptList: return self._multi_lang_prompt_lists[prompt_name] def get_prompt_template( self, prompt_name: str, lang_code: str = DEFAULT_LANG_CODE, ) -> PromptTemplate: """The PromptTemplate object for the given prompt name and language code.""" return self.get_multilang_prompt_template(prompt_name).get_prompt_template(lang_code=lang_code, fallback_mode=self.fallback_mode) def get_prompt_template_parameters(self, prompt_name: str) -> list[str]: """The parameters of the PromptTemplate object for the given prompt name.""" return self.get_multilang_prompt_template(prompt_name).get_parameters() def get_prompt_list(self, prompt_name: str, lang_code: str = DEFAULT_LANG_CODE) -> PromptList: """The PromptList object for the given prompt name and language code.""" return self.get_multilang_prompt_list(prompt_name).get_item(lang_code) def _has_prompt_list(self, prompt_name: str, lang_code: str = DEFAULT_LANG_CODE) -> bool: multi_lang_prompt_list = self._multi_lang_prompt_lists.get(prompt_name) if multi_lang_prompt_list is None: return False return multi_lang_prompt_list.has_item(lang_code) def _has_prompt_template(self, prompt_name: str, lang_code: str = DEFAULT_LANG_CODE) -> bool: multi_lang_prompt_template = self._multi_lang_prompt_templates.get(prompt_name) if multi_lang_prompt_template is None: return False return multi_lang_prompt_template.has_item(lang_code) def render_prompt_template( self, prompt_name: str, params: dict[str, Any], lang_code: str = DEFAULT_LANG_CODE, ) -> str: """Renders the prompt template for the given prompt name and language code.""" return self.get_prompt_template(prompt_name, lang_code=lang_code).render(**params) ================================================ FILE: src/interprompt/prompt_factory.py ================================================ import logging import os from typing import Any from .multilang_prompt import DEFAULT_LANG_CODE, LanguageFallbackMode, MultiLangPromptCollection, PromptList log = logging.getLogger(__name__) class PromptFactoryBase: """Base class for auto-generated prompt factory classes.""" def __init__(self, prompts_dir: str | list[str], lang_code: str = DEFAULT_LANG_CODE, fallback_mode=LanguageFallbackMode.EXCEPTION): """ :param prompts_dir: the directory containing the prompt templates and prompt lists. If a list is provided, will look for prompt templates in the dirs from left to right (first one containing the desired template wins). :param lang_code: the language code to use for retrieving the prompt templates and prompt lists. Leave as `default` for single-language use cases. :param fallback_mode: the fallback mode to use when a prompt template or prompt list is not found for the requested language. Irrelevant for single-language use cases. """ self.lang_code = lang_code self._prompt_collection = MultiLangPromptCollection(prompts_dir, fallback_mode=fallback_mode) def _render_prompt(self, prompt_name: str, params: dict[str, Any]) -> str: del params["self"] return self._prompt_collection.render_prompt_template(prompt_name, params, lang_code=self.lang_code) def _get_prompt_list(self, prompt_name: str) -> PromptList: return self._prompt_collection.get_prompt_list(prompt_name, self.lang_code) def autogenerate_prompt_factory_module(prompts_dir: str, target_module_path: str) -> None: """ Auto-generates a prompt factory module for the given prompt directory. The generated `PromptFactory` class is meant to be the central entry class for retrieving and rendering prompt templates and prompt lists in your application. It will contain one method per prompt template and prompt list, and is useful for both single- and multi-language use cases. :param prompts_dir: the directory containing the prompt templates and prompt lists :param target_module_path: the path to the target module file (.py). Important: The module will be overwritten! """ generated_code = """ # ruff: noqa # black: skip # mypy: ignore-errors # NOTE: This module is auto-generated from interprompt.autogenerate_prompt_factory_module, do not edit manually! from interprompt.multilang_prompt import PromptList from interprompt.prompt_factory import PromptFactoryBase from typing import Any class PromptFactory(PromptFactoryBase): \""" A class for retrieving and rendering prompt templates and prompt lists. \""" """ # ---- add methods based on prompt template names and parameters and prompt list names ---- prompt_collection = MultiLangPromptCollection(prompts_dir) for template_name in prompt_collection.get_prompt_template_names(): template_parameters = prompt_collection.get_prompt_template_parameters(template_name) if len(template_parameters) == 0: method_params_str = "" else: method_params_str = ", *, " + ", ".join([f"{param}: Any" for param in template_parameters]) generated_code += f""" def create_{template_name}(self{method_params_str}) -> str: return self._render_prompt('{template_name}', locals()) """ for prompt_list_name in prompt_collection.get_prompt_list_names(): generated_code += f""" def get_list_{prompt_list_name}(self) -> PromptList: return self._get_prompt_list('{prompt_list_name}') """ os.makedirs(os.path.dirname(target_module_path), exist_ok=True) with open(target_module_path, "w", encoding="utf-8") as f: f.write(generated_code) log.info(f"Prompt factory generated successfully in {target_module_path}") ================================================ FILE: src/interprompt/util/__init__.py ================================================ ================================================ FILE: src/interprompt/util/class_decorators.py ================================================ from typing import Any def singleton(cls: type[Any]) -> Any: instance = None def get_instance(*args: Any, **kwargs: Any) -> Any: nonlocal instance if instance is None: instance = cls(*args, **kwargs) return instance return get_instance ================================================ FILE: src/serena/__init__.py ================================================ __version__ = "0.1.4" import logging log = logging.getLogger(__name__) def serena_version() -> str: """ :return: the version of the package, including git status if available. """ from serena.util.git import get_git_status version = __version__ try: git_status = get_git_status() if git_status is not None: version += f"-{git_status.commit[:8]}" if not git_status.is_clean: version += "-dirty" except: pass return version ================================================ FILE: src/serena/agent.py ================================================ """ The Serena Model Context Protocol (MCP) Server """ import os import platform import subprocess import sys from collections.abc import Callable, Iterator, Sequence from contextlib import contextmanager from logging import Logger from typing import TYPE_CHECKING, Optional, TypeVar from sensai.util import logging from sensai.util.logging import LogTime from sensai.util.string import dict_string from interprompt.jinja_template import JinjaTemplate from serena import serena_version from serena.analytics import RegisteredTokenCountEstimator, ToolUsageStats from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode from serena.config.serena_config import ( LanguageBackend, ModeSelectionDefinition, NamedToolInclusionDefinition, RegisteredProject, SerenaConfig, SerenaPaths, ToolInclusionDefinition, ) from serena.dashboard import SerenaDashboardAPI from serena.ls_manager import LanguageServerManager from serena.project import MemoriesManager, Project from serena.prompt_factory import SerenaPromptFactory from serena.task_executor import TaskExecutor from serena.tools import ActivateProjectTool, GetCurrentConfigTool, OpenDashboardTool, ReplaceContentTool, Tool, ToolMarker, ToolRegistry from serena.util.gui import system_has_usable_display from serena.util.inspection import iter_subclasses from serena.util.logging import MemoryLogHandler from solidlsp.ls_config import Language if TYPE_CHECKING: from serena.gui_log_viewer import GuiLogViewer log = logging.getLogger(__name__) TTool = TypeVar("TTool", bound="Tool") T = TypeVar("T") SUCCESS_RESULT = "OK" class ProjectNotFoundError(Exception): pass class AvailableTools: """ Represents the set of available/exposed tools of a SerenaAgent. """ def __init__(self, tools: list[Tool]): """ :param tools: the list of available tools """ self.tools = tools self.tool_names = sorted([tool.get_name_from_cls() for tool in tools]) """ the list of available tool names, sorted alphabetically """ self._tool_name_set = set(self.tool_names) self.tool_marker_names = set() for marker_class in iter_subclasses(ToolMarker): for tool in tools: if isinstance(tool, marker_class): self.tool_marker_names.add(marker_class.__name__) def __len__(self) -> int: return len(self.tools) def contains_tool_name(self, tool_name: str) -> bool: return tool_name in self._tool_name_set class ToolSet: """ Represents a set of tools by their names. """ LEGACY_TOOL_NAME_MAPPING = {"replace_regex": ReplaceContentTool.get_name_from_cls()} """ maps legacy tool names to their new names for backward compatibility """ def __init__(self, tool_names: set[str]) -> None: self._tool_names = tool_names def __len__(self) -> int: return len(self._tool_names) @classmethod def default(cls) -> "ToolSet": """ :return: the default tool set, which contains all tools that are enabled by default """ from serena.tools import ToolRegistry return cls(set(ToolRegistry().get_tool_names_default_enabled())) def apply(self, *tool_inclusion_definitions: "ToolInclusionDefinition") -> "ToolSet": """ Applies one or more tool inclusion definitions to this tool set, resulting in a new tool set. :param tool_inclusion_definitions: the definitions to apply :return: a new tool set with the definitions applied """ from serena.tools import ToolRegistry def get_updated_tool_name(tool_name: str) -> str: """Retrieves the updated tool name if the provided tool name is deprecated, logging a warning.""" if tool_name in self.LEGACY_TOOL_NAME_MAPPING: new_tool_name = self.LEGACY_TOOL_NAME_MAPPING[tool_name] log.warning("Tool name '%s' is deprecated, please use '%s' instead", tool_name, new_tool_name) return new_tool_name return tool_name registry = ToolRegistry() tool_names = set(self._tool_names) for definition in tool_inclusion_definitions: if definition.is_fixed_tool_set(): tool_names = set() for fixed_tool in definition.fixed_tools: fixed_tool = get_updated_tool_name(fixed_tool) if not registry.is_valid_tool_name(fixed_tool): raise ValueError(f"Invalid tool name '{fixed_tool}' provided for fixed tool set") tool_names.add(fixed_tool) log.info(f"{definition} defined a fixed tool set with {len(tool_names)} tools: {', '.join(tool_names)}") else: included_tools = [] excluded_tools = [] for included_tool in definition.included_optional_tools: included_tool = get_updated_tool_name(included_tool) if not registry.is_valid_tool_name(included_tool): raise ValueError(f"Invalid tool name '{included_tool}' provided for inclusion") if included_tool not in tool_names: tool_names.add(included_tool) included_tools.append(included_tool) for excluded_tool in definition.excluded_tools: excluded_tool = get_updated_tool_name(excluded_tool) if not registry.is_valid_tool_name(excluded_tool): raise ValueError(f"Invalid tool name '{excluded_tool}' provided for exclusion") if excluded_tool in tool_names: tool_names.remove(excluded_tool) excluded_tools.append(excluded_tool) if included_tools: log.info(f"{definition} included {len(included_tools)} tools: {', '.join(included_tools)}") if excluded_tools: log.info(f"{definition} excluded {len(excluded_tools)} tools: {', '.join(excluded_tools)}") return ToolSet(tool_names) def without_editing_tools(self) -> "ToolSet": """ :return: a new tool set that excludes all tools that can edit """ from serena.tools import ToolRegistry registry = ToolRegistry() tool_names = set(self._tool_names) for tool_name in self._tool_names: if registry.get_tool_class_by_name(tool_name).can_edit(): tool_names.remove(tool_name) return ToolSet(tool_names) def get_tool_names(self) -> set[str]: """ Returns the names of the tools that are currently included in the tool set. """ return self._tool_names def includes_name(self, tool_name: str) -> bool: return tool_name in self._tool_names def to_available_tools(self, all_tools: dict[type[Tool], Tool]) -> AvailableTools: return AvailableTools([t for t in all_tools.values() if self.includes_name(t.get_name())]) class ActiveModes: def __init__(self) -> None: self._base_modes: Sequence[str] | None = None self._default_modes: Sequence[str] | None = None self._active_mode_names: Sequence[str] | None = [] self._active_modes: Sequence[SerenaAgentMode] | None = [] def apply(self, mode_selection: ModeSelectionDefinition) -> None: # invalidate active modes self._active_mode_names = None self._active_modes = None # apply overrides log.debug("Applying mode selection: default_modes=%s, base_modes=%s", mode_selection.default_modes, mode_selection.base_modes) if mode_selection.base_modes is not None: self._base_modes = mode_selection.base_modes if mode_selection.default_modes is not None: self._default_modes = mode_selection.default_modes log.debug("Current mode selection: base_modes=%s, default_modes=%s", self._base_modes, self._default_modes) def get_mode_names(self) -> Sequence[str]: if self._active_mode_names is not None: return self._active_mode_names active_mode_names: set[str] = set() if self._base_modes is not None: active_mode_names.update(self._base_modes) if self._default_modes is not None: active_mode_names.update(self._default_modes) self._active_mode_names = sorted(active_mode_names) log.info("Active modes: %s", self._active_mode_names) return self._active_mode_names def get_modes(self) -> Sequence[SerenaAgentMode]: if self._active_modes is not None: return self._active_modes self._active_modes = [] for mode_name in self.get_mode_names(): mode = SerenaAgentMode.load(mode_name) self._active_modes.append(mode) return self._active_modes class SerenaAgent: def __init__( self, project: str | None = None, project_activation_callback: Callable[[], None] | None = None, serena_config: SerenaConfig | None = None, context: SerenaAgentContext | None = None, modes: ModeSelectionDefinition | None = None, memory_log_handler: MemoryLogHandler | None = None, ): """ :param project: the project to load immediately or None to not load any project; may be a path to the project or a name of an already registered project; :param project_activation_callback: a callback function to be called when a project is activated. :param serena_config: the Serena configuration or None to read the configuration from the default location. :param context: the context in which the agent is operating, None for default context. The context may adjust prompts, tool availability, and tool descriptions. :param modes: list of modes in which the agent is operating (they will be combined), None for default modes. The modes may adjust prompts, tool availability, and tool descriptions. :param memory_log_handler: a MemoryLogHandler instance from which to read log messages; if None, a new one will be created if necessary. """ # obtain serena configuration using the decoupled factory function self.serena_config = serena_config or SerenaConfig.from_config_file() # propagate configuration to other components self.serena_config.propagate_settings() # project-specific instances, which will be initialized upon project activation self._active_project: Project | None = None # determine registered project to be activated (if any) registered_project_to_activate: RegisteredProject | None = ( self.serena_config.get_registered_project(project, autoregister=True) if project is not None else None ) # dashboard URL (set when dashboard is started) self._dashboard_url: str | None = None # adjust log level serena_log_level = self.serena_config.log_level if Logger.root.level != serena_log_level: log.info(f"Changing the root logger level to {serena_log_level}") Logger.root.setLevel(serena_log_level) def get_memory_log_handler() -> MemoryLogHandler: nonlocal memory_log_handler if memory_log_handler is None: memory_log_handler = MemoryLogHandler(level=serena_log_level) Logger.root.addHandler(memory_log_handler) return memory_log_handler # open GUI log window if enabled self._gui_log_viewer: Optional["GuiLogViewer"] = None if self.serena_config.gui_log_window: log.info("Opening GUI window") if platform.system() == "Darwin": log.warning("GUI log window is not supported on macOS") else: # even importing on macOS may fail if tkinter dependencies are unavailable (depends on Python interpreter installation # which uv used as a base, unfortunately) from serena.gui_log_viewer import GuiLogViewer self._gui_log_viewer = GuiLogViewer("dashboard", title="Serena Logs", memory_log_handler=get_memory_log_handler()) self._gui_log_viewer.start() else: log.debug("GUI window is disabled") # set the agent context if context is None: context = SerenaAgentContext.load_default() self._context = context # instantiate all tool classes self._all_tools: dict[type[Tool], Tool] = {tool_class: tool_class(self) for tool_class in ToolRegistry().get_all_tool_classes()} tool_names = [tool.get_name_from_cls() for tool in self._all_tools.values()] # If GUI log window is enabled, set the tool names for highlighting if self._gui_log_viewer is not None: self._gui_log_viewer.set_tool_names(tool_names) token_count_estimator = RegisteredTokenCountEstimator[self.serena_config.token_count_estimator] log.info(f"Will record tool usage statistics with token count estimator: {token_count_estimator.name}.") self._tool_usage_stats = ToolUsageStats(token_count_estimator) # log fundamental information log.info( f"Starting Serena server (version={serena_version()}, process id={os.getpid()}, parent process id={os.getppid()}; " f"language backend={self.serena_config.language_backend.name})" ) log.info("Configuration file: %s", self.serena_config.config_file_path) log.info("Available projects: {}".format(", ".join(self.serena_config.project_names))) log.info(f"Loaded tools ({len(self._all_tools)}): {', '.join([tool.get_name_from_cls() for tool in self._all_tools.values()])}") self._check_shell_settings() # determine the effective language backend for this session. # If a startup project is provided and has a per-project override, use it; otherwise use the global config. # Since we don't want to change the toolset after startup, the language backend cannot be changed within a running Serena session self._language_backend = self.serena_config.language_backend if registered_project_to_activate is not None and registered_project_to_activate.project_config.language_backend is not None: self._language_backend = registered_project_to_activate.project_config.language_backend log.info(f"Using language backend as configured in project.yml: {self._language_backend.name}") else: log.info(f"Using language backend from global configuration: {self._language_backend.name}") # create executor for starting the language server and running tools in another thread # This executor is used to achieve linear task execution self._task_executor = TaskExecutor("SerenaAgentTaskExecutor") # Initialize the prompt factory self.prompt_factory = SerenaPromptFactory() self._project_activation_callback = project_activation_callback # activate the given project (if any), also updating the active modes # Note: We cannot update the active tools yet, because the base toolset has not been computed yet # (and its computation depends on the active project) self._active_modes: ActiveModes self._mode_overrides = modes if project is not None: try: self.activate_project_from_path_or_name(project, update_active_modes=False, update_active_tools=False) except Exception as e: log.error(f"Error activating project '{project}' at startup: {e}", exc_info=e) self._update_active_modes() # determine the base toolset defining the set of exposed tools (which e.g. the MCP shall see), self._base_toolset = self._create_base_toolset( self.serena_config, self._language_backend, self._context, self._active_modes, self._active_project ) self._exposed_tools = self._base_toolset.to_available_tools(self._all_tools) log.info(f"Number of exposed tools: {len(self._exposed_tools)}") # update the active tools (considering the active project, if any) self._active_tools: AvailableTools self._update_active_tools() # start the dashboard (web frontend), registering its log handler # should be the last thing to happen in the initialization since the dashboard # may access various parts of the agent if self.serena_config.web_dashboard: self._dashboard_thread, port = SerenaDashboardAPI( get_memory_log_handler(), tool_names, agent=self, tool_usage_stats=self._tool_usage_stats ).run_in_thread(host=self.serena_config.web_dashboard_listen_address) dashboard_host = self.serena_config.web_dashboard_listen_address if dashboard_host == "0.0.0.0": dashboard_host = "localhost" dashboard_url = f"http://{dashboard_host}:{port}/dashboard/index.html" self._dashboard_url = dashboard_url log.info("Serena web dashboard started at %s", dashboard_url) if self.serena_config.web_dashboard_open_on_launch: self.open_dashboard() # inform the GUI window (if any) if self._gui_log_viewer is not None: self._gui_log_viewer.set_dashboard_url(dashboard_url) @classmethod def _create_base_toolset( cls, serena_config: SerenaConfig, language_backend: LanguageBackend, context: SerenaAgentContext, modes: ActiveModes, project: Project | None, ) -> ToolSet: """ Determines the base toolset defining the set of exposed tools (which e.g. the MCP shall see). It depends on ... * dashboard availability/opening on launch * Serena config * the context (which is fixed for the session) * the optional tools enabled by initial modes * single-project mode reductions (if applicable) * JetBrains mode """ # determine whether to include the OpenDashboardTool based on the Serena configuration tool_inclusion_definitions: list[ToolInclusionDefinition] = [] if serena_config.web_dashboard and not serena_config.web_dashboard_open_on_launch and not serena_config.gui_log_window: tool_inclusion_definitions.append( NamedToolInclusionDefinition(name="OpenDashboard", included_optional_tools=[OpenDashboardTool.get_name_from_cls()]) ) # consider Serena configuration and the active context tool_inclusion_definitions.append(serena_config) tool_inclusion_definitions.append(context) # consider modes # Since modes can be dynamically turned on and off, we don't include their definitions directly, # but for the initially active modes, we make sure that the tools they enable are included. for mode in modes.get_modes(): tool_inclusion_definitions.append( NamedToolInclusionDefinition( name=f"InitialModeInclusions[{mode.name}]", included_optional_tools=mode.included_optional_tools ) ) # When in a single-project context, the agent is assumed to work on a single project, and we thus # want to apply that project's tool exclusions/inclusions from the get-go, limiting the set # of tools that will be exposed to the client. # Furthermore, we disable tools that are only relevant for project activation. # So if the project exists, we apply all the aforementioned exclusions. if context.single_project and project is not None: log.info( "Applying tool inclusion/exclusion definitions for single-project context based on project '%s'", project.project_name, ) tool_inclusion_definitions.append( NamedToolInclusionDefinition( name="SingleProjectExclusions", excluded_tools=[ActivateProjectTool.get_name_from_cls(), GetCurrentConfigTool.get_name_from_cls()], ) ) tool_inclusion_definitions.append(project.project_config) # enabled the internal 'jetbrains' mode for the JetBrains backend if language_backend == LanguageBackend.JETBRAINS: tool_inclusion_definitions.append(SerenaAgentMode.from_name_internal("jetbrains")) # compute the resulting tool set base_toolset = ToolSet.default().apply(*tool_inclusion_definitions) log.info(f"Number of exposed tools: {len(base_toolset)}") return base_toolset def get_language_backend(self) -> LanguageBackend: return self._language_backend def get_current_tasks(self) -> list[TaskExecutor.TaskInfo]: """ Gets the list of tasks currently running or queued for execution. The function returns a list of thread-safe TaskInfo objects (specifically created for the caller). :return: the list of tasks in the execution order (running task first) """ return self._task_executor.get_current_tasks() def get_last_executed_task(self) -> TaskExecutor.TaskInfo | None: """ Gets the last executed task. :return: the last executed task info or None if no task has been executed yet """ return self._task_executor.get_last_executed_task() def get_language_server_manager(self) -> LanguageServerManager | None: if self._active_project is not None: return self._active_project.language_server_manager return None def get_language_server_manager_or_raise(self) -> LanguageServerManager: active_project = self.get_active_project_or_raise() return active_project.get_language_server_manager_or_raise() def get_log_inspection_instructions(self) -> str: if self.serena_config.web_dashboard: return f"Live logs can be inspected via the dashboard at {self.get_dashboard_url()}" else: log_path = SerenaPaths().last_returned_log_file_path if log_path is not None: return f"Find the current log file here: f{log_path}" else: return "Unfortunately, logs are not available. We recommend enabling the web dashboard/logging in general." def get_context(self) -> SerenaAgentContext: return self._context def get_tool_description_override(self, tool_name: str) -> str | None: return self._context.tool_description_overrides.get(tool_name, None) def _check_shell_settings(self) -> None: # On Windows, Claude Code sets COMSPEC to Git-Bash (often even with a path containing spaces), # which causes all sorts of trouble, preventing language servers from being launched correctly. # So we make sure that COMSPEC is unset if it has been set to bash specifically. if platform.system() == "Windows": comspec = os.environ.get("COMSPEC", "") if "bash" in comspec: os.environ["COMSPEC"] = "" # force use of default shell log.info("Adjusting COMSPEC environment variable to use the default shell instead of '%s'", comspec) def record_tool_usage(self, input_kwargs: dict, tool_result: str | dict, tool: Tool) -> None: """ Record the usage of a tool with the given input and output strings if tool usage statistics recording is enabled. """ tool_name = tool.get_name() input_str = str(input_kwargs) output_str = str(tool_result) log.debug(f"Recording tool usage for tool '{tool_name}'") self._tool_usage_stats.record_tool_usage(tool_name, input_str, output_str) def get_dashboard_url(self) -> str | None: """ :return: the URL of the web dashboard, or None if the dashboard is not running """ return self._dashboard_url def open_dashboard(self) -> bool: """ Opens the Serena web dashboard in the default web browser. :return: a message indicating success or failure """ if self._dashboard_url is None: raise Exception("Dashboard is not running.") if not system_has_usable_display(): log.warning("Not opening the Serena web dashboard because no usable display was detected.") return False # Use a subprocess to avoid any output from webbrowser.open being written to stdout subprocess.Popen( [sys.executable, "-c", f"import webbrowser; webbrowser.open({self._dashboard_url!r})"], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, # Detach from parent process ) return True def get_exposed_tool_instances(self) -> list["Tool"]: """ :return: the tool instances which are exposed (e.g. to the MCP client). Note that the set of exposed tools is fixed for the session, as clients don't react to changes in the set of tools, so this is the superset of tools that can be offered during the session. If a client should attempt to use a tool that is dynamically disabled (e.g. because a project is activated that disables it), it will receive an error. """ return list(self._exposed_tools.tools) def get_active_project(self) -> Project | None: """ :return: the active project or None if no project is active """ return self._active_project def get_active_project_or_raise(self) -> Project: """ :return: the active project or raises an exception if no project is active """ project = self.get_active_project() if project is None: raise ValueError("No active project. Please activate a project first.") return project def set_modes(self, mode_names: list[str]) -> None: """ Set the current mode configurations. :param mode_names: List of mode names or paths to use """ self._mode_overrides = ModeSelectionDefinition(default_modes=mode_names) self._update_active_modes() self._update_active_tools() log.info(f"Set modes to {[mode.name for mode in self.get_active_modes()]}") def get_active_modes(self) -> list[SerenaAgentMode]: """ :return: the list of active modes """ return list(self._active_modes.get_modes()) def _format_prompt(self, prompt_template: str) -> str: template = JinjaTemplate(prompt_template) return template.render(available_tools=self._exposed_tools.tool_names, available_markers=self._exposed_tools.tool_marker_names) def create_system_prompt(self) -> str: available_tools = self._active_tools available_markers = available_tools.tool_marker_names global_memories = MemoriesManager( serena_data_folder=None, read_only_memory_patterns=self.serena_config.read_only_memory_patterns ).list_global_memories() global_memories_str = dict_string(global_memories.to_dict()) if len(global_memories) > 0 else "" log.info("Generating system prompt with available_tools=(see active tools), available_markers=%s", available_markers) system_prompt = self.prompt_factory.create_system_prompt( context_system_prompt=self._format_prompt(self._context.prompt), mode_system_prompts=[self._format_prompt(mode.prompt) for mode in self.get_active_modes()], available_tools=available_tools.tool_names, available_markers=available_markers, global_memories_list=global_memories_str, ) # If a project is active at startup, append its activation message if self._active_project is not None: system_prompt += "\n\n" + self._active_project.get_activation_message() log.info("System prompt:\n%s", system_prompt) return system_prompt def _update_active_modes(self) -> None: """ Updates the active modes based on the Serena configuration, the active project configuration (if any), and mode overrides (if any). """ self._active_modes = ActiveModes() self._active_modes.apply(self.serena_config) if self._active_project: self._active_modes.apply(self._active_project.project_config) if self._mode_overrides: self._active_modes.apply(self._mode_overrides) def _update_active_tools(self) -> None: """ Updates the active tools based on the active modes and the active project. The base tool set already takes the Serena configuration and the context into account (as well as many other aspects, such as JetBrains mode). """ # apply modes tool_set = self._base_toolset.apply(*self._active_modes.get_modes()) # apply active project configuration (if any) if self._active_project is not None: tool_set = tool_set.apply(self._active_project.project_config) if self._active_project.project_config.read_only: tool_set = tool_set.without_editing_tools() self._active_tools = tool_set.to_available_tools(self._all_tools) log.info(f"Active tools ({len(self._active_tools)}): {', '.join(self._active_tools.tool_names)}") # check if a tool was activated that is not in the exposed tool set and issue a warning if so active_tools_not_exposed = set(self._active_tools.tool_names) - set(self._exposed_tools.tool_names) if active_tools_not_exposed: log.warning( "The following active tools are not in the exposed tool set and thus won't be available to clients:\n" f"{active_tools_not_exposed}\n" "Consider adjusting your configuration to include these tools if you want to use them." ) def issue_task( self, task: Callable[[], T], name: str | None = None, logged: bool = True, timeout: float | None = None ) -> TaskExecutor.Task[T]: """ Issue a task to the executor for asynchronous execution. It is ensured that tasks are executed in the order they are issued, one after another. :param task: the task to execute :param name: the name of the task for logging purposes; if None, use the task function's name :param logged: whether to log management of the task; if False, only errors will be logged :param timeout: the maximum time to wait for task completion in seconds, or None to wait indefinitely :return: the task object, through which the task's future result can be accessed """ return self._task_executor.issue_task(task, name=name, logged=logged, timeout=timeout) def execute_task(self, task: Callable[[], T], name: str | None = None, logged: bool = True, timeout: float | None = None) -> T: """ Executes the given task synchronously via the agent's task executor. This is useful for tasks that need to be executed immediately and whose results are needed right away. :param task: the task to execute :param name: the name of the task for logging purposes; if None, use the task function's name :param logged: whether to log management of the task; if False, only errors will be logged :param timeout: the maximum time to wait for task completion in seconds, or None to wait indefinitely :return: the result of the task execution """ return self._task_executor.execute_task(task, name=name, logged=logged, timeout=timeout) def is_using_language_server(self) -> bool: """ :return: whether this agent uses language server-based code analysis """ return self._language_backend == LanguageBackend.LSP def _activate_project(self, project: Project, update_active_modes: bool = True, update_active_tools: bool = True) -> None: log.info(f"Activating {project.project_name} at {project.project_root}") # Check if the project requires a different language backend than the one initialized at startup project_backend = project.project_config.language_backend if project_backend is not None and project_backend != self._language_backend: raise ValueError( f"Cannot activate project '{project.project_name}': it requires the {project_backend.value} backend, " f"but this session was initialized with {self._language_backend.value}. " f"Workarounds: (1) Use project activation at startup via the --project flag, " f"(2) Configure one MCP server per backend in your client." ) self._active_project = project project.set_agent(self) if update_active_modes: self._update_active_modes() if update_active_tools: self._update_active_tools() def init_language_server_manager() -> None: # start the language server with LogTime("Language server initialization", logger=log): self.reset_language_server_manager() # initialize the language server in the background (if in language server mode) if self.get_language_backend().is_lsp(): self.issue_task(init_language_server_manager) if self._project_activation_callback is not None: self._project_activation_callback() def activate_project_from_path_or_name( self, project_root_or_name: str, update_active_modes: bool = True, update_active_tools: bool = True ) -> Project: """ Activate a project from a path or a name. If the project was already registered, it will just be activated. If the argument is a path at which no Serena project previously existed, the project will be created beforehand. Raises ProjectNotFoundError if the project could neither be found nor created. """ project_instance: Project | None = self.serena_config.get_project(project_root_or_name) if project_instance is not None: log.info(f"Found registered project '{project_instance.project_name}' at path {project_instance.project_root}") elif os.path.isdir(project_root_or_name): project_instance = self.serena_config.add_project_from_path(project_root_or_name) log.info(f"Added new project {project_instance.project_name} for path {project_instance.project_root}") if project_instance is None: raise ProjectNotFoundError( f"Project '{project_root_or_name}' not found: Not a valid project name or directory. " f"Existing project names: {self.serena_config.project_names}" ) self._activate_project(project_instance, update_active_modes=update_active_modes, update_active_tools=update_active_tools) return project_instance def get_active_tool_names(self) -> list[str]: """ :return: the list of names of the active tools for the current project, sorted alphabetically """ return self._active_tools.tool_names def tool_is_active(self, tool_name: str) -> bool: """ :param tool_class: the name of the tool to check :return: True if the tool is active, False otherwise """ return self._active_tools.contains_tool_name(tool_name) def get_current_config_overview(self) -> str: """ :return: a string overview of the current configuration, including the active and available configuration options """ result_str = "Current configuration:\n" result_str += f"Serena version: {serena_version()}\n" result_str += f"Loglevel: {self.serena_config.log_level}, trace_lsp_communication={self.serena_config.trace_lsp_communication}\n" if self._active_project is not None: result_str += f"Active project: {self._active_project.project_name}\n" else: result_str += "No active project\n" result_str += f"Language backend: {self._language_backend.value}" if self._active_project and self._active_project.project_config.language_backend is not None: result_str += " (project override)" result_str += f" (global default: {self.serena_config.language_backend.value})\n" result_str += "Available projects:\n" + "\n".join(list(self.serena_config.project_names)) + "\n" result_str += f"Active context: {self._context.name}\n" # Active modes active_mode_names = [mode.name for mode in self.get_active_modes()] result_str += "Active modes: {}\n".format(", ".join(active_mode_names)) + "\n" # Available but not active modes all_available_modes = SerenaAgentMode.list_registered_mode_names() inactive_modes = [mode for mode in all_available_modes if mode not in active_mode_names] if inactive_modes: result_str += "Available but not active modes: {}\n".format(", ".join(inactive_modes)) + "\n" # Active tools result_str += "Active tools (after all exclusions from the project, context, and modes):\n" active_tool_names = self.get_active_tool_names() # print the tool names in chunks chunk_size = 4 for i in range(0, len(active_tool_names), chunk_size): chunk = active_tool_names[i : i + chunk_size] result_str += " " + ", ".join(chunk) + "\n" # Available but not active tools all_tool_names = sorted([tool.get_name_from_cls() for tool in self._all_tools.values()]) inactive_tool_names = [tool for tool in all_tool_names if tool not in active_tool_names] if inactive_tool_names: result_str += "Available but not active tools:\n" for i in range(0, len(inactive_tool_names), chunk_size): chunk = inactive_tool_names[i : i + chunk_size] result_str += " " + ", ".join(chunk) + "\n" return result_str def reset_language_server_manager(self) -> None: """ Starts/resets the language server manager for the current project """ self.get_active_project_or_raise().create_language_server_manager() def add_language(self, language: Language) -> None: """ Adds a new language to the active project, spawning the respective language server and updating the project configuration. The addition is scheduled via the agent's task executor and executed synchronously, i.e. the method returns when the addition is complete. :param language: the language to add """ self.execute_task(lambda: self.get_active_project_or_raise().add_language(language), name=f"AddLanguage:{language.value}") def remove_language(self, language: Language) -> None: """ Removes a language from the active project, shutting down the respective language server and updating the project configuration. The removal is scheduled via the agent's task executor and executed asynchronously. :param language: the language to remove """ self.issue_task(lambda: self.get_active_project_or_raise().remove_language(language), name=f"RemoveLanguage:{language.value}") def get_tool(self, tool_class: type[TTool]) -> TTool: return self._all_tools[tool_class] # type: ignore def print_tool_overview(self) -> None: ToolRegistry().print_tool_overview(self._active_tools.tools) def __del__(self) -> None: self.shutdown() def shutdown(self, timeout: float = 2.0) -> None: """ Shuts down the agent, freeing resources and stopping background tasks. """ if not hasattr(self, "_is_initialized"): return log.info("SerenaAgent is shutting down ...") if self._active_project is not None: self._active_project.shutdown(timeout=timeout) self._active_project = None if self._gui_log_viewer: log.info("Stopping the GUI log window ...") self._gui_log_viewer.stop() self._gui_log_viewer = None def get_tool_by_name(self, tool_name: str) -> Tool: tool_class = ToolRegistry().get_tool_class_by_name(tool_name) return self.get_tool(tool_class) def get_active_lsp_languages(self) -> list[Language]: ls_manager = self.get_language_server_manager() if ls_manager is None: return [] return ls_manager.get_active_languages() @contextmanager def active_project_context(self, project: Project) -> Iterator[None]: """ Context manager for temporarily setting/overriding the active project :param project: the project to be active """ original_project = self._active_project self._active_project = project try: yield finally: self._active_project = original_project ================================================ FILE: src/serena/agno.py ================================================ import argparse import logging import os import threading from pathlib import Path from typing import Any from agno.agent import Agent from agno.db.sqlite import SqliteDb from agno.memory import MemoryManager from agno.models.base import Model from agno.tools.function import Function from agno.tools.toolkit import Toolkit from dotenv import load_dotenv from sensai.util.logging import LogTime from serena.agent import SerenaAgent, Tool from serena.config.context_mode import SerenaAgentContext from serena.constants import REPO_ROOT from serena.util.exception import show_fatal_exception_safe log = logging.getLogger(__name__) class SerenaAgnoToolkit(Toolkit): def __init__(self, serena_agent: SerenaAgent): super().__init__("Serena") for tool in serena_agent.get_exposed_tool_instances(): self.functions[tool.get_name_from_cls()] = self._create_agno_function(tool) log.info("Agno agent functions: %s", list(self.functions.keys())) @staticmethod def _create_agno_function(tool: Tool) -> Function: def entrypoint(**kwargs: Any) -> str: if "kwargs" in kwargs: # Agno sometimes passes a kwargs argument explicitly, so we merge it kwargs.update(kwargs["kwargs"]) del kwargs["kwargs"] log.info(f"Calling tool {tool}") return tool.apply_ex(log_call=True, catch_exceptions=True, **kwargs) function = Function.from_callable(tool.get_apply_fn()) function.name = tool.get_name_from_cls() function.entrypoint = entrypoint function.skip_entrypoint_processing = True return function class SerenaAgnoAgentProvider: _agent: Agent | None = None _lock = threading.Lock() @classmethod def get_agent(cls, model: Model) -> Agent: """ Returns the singleton instance of the Serena agent or creates it with the given parameters if it doesn't exist. NOTE: This is very ugly with poor separation of concerns, but the way in which the Agno UI works (reloading the module that defines the `app` variable) essentially forces us to do something like this. :param model: the large language model to use for the agent :return: the agent instance """ with cls._lock: if cls._agent is not None: return cls._agent # change to Serena root os.chdir(REPO_ROOT) load_dotenv() parser = argparse.ArgumentParser(description="Serena coding assistant") # Create a mutually exclusive group group = parser.add_mutually_exclusive_group() # Add arguments to the group, both pointing to the same destination group.add_argument( "--project-file", required=False, help="Path to the project (or project.yml file).", ) group.add_argument( "--project", required=False, help="Path to the project (or project.yml file).", ) args = parser.parse_args() args_project_file = args.project or args.project_file if args_project_file: project_file = Path(args_project_file).resolve() # If project file path is relative, make it absolute by joining with project root if not project_file.is_absolute(): # Get the project root directory (parent of scripts directory) project_root = Path(REPO_ROOT) project_file = project_root / args_project_file # Ensure the path is normalized and absolute project_file = str(project_file.resolve()) else: project_file = None with LogTime("Loading Serena agent"): try: serena_agent = SerenaAgent(project_file, context=SerenaAgentContext.load("agent")) except Exception as e: show_fatal_exception_safe(e) raise # Even though we don't want to keep history between sessions, # for agno-ui to work as a conversation, we use a persistent database on disk. # This database should be deleted between sessions. # Note that this might collide with custom options for the agent, like adding vector-search based tools. sql_db_path = (Path("temp") / "agno_agent_storage.db").absolute() sql_db_path.parent.mkdir(exist_ok=True) # delete the db file if it exists log.info(f"Deleting DB from PID {os.getpid()}") if sql_db_path.exists(): sql_db_path.unlink() agno_agent = Agent( name="Serena", model=model, # See explanation above on why database is needed db=SqliteDb(db_file=str(sql_db_path)), description="A fully-featured coding assistant", tools=[SerenaAgnoToolkit(serena_agent)], # Tool calls will be shown in the UI since that's configurable per tool # To see detailed logs, you should use the serena logger (configure it in the project file path) markdown=True, system_message=serena_agent.create_system_prompt(), telemetry=False, memory_manager=MemoryManager(), add_history_to_context=True, num_history_runs=100, # you might want to adjust this (expense vs. history awareness) ) cls._agent = agno_agent log.info(f"Agent instantiated: {agno_agent}") return agno_agent ================================================ FILE: src/serena/analytics.py ================================================ from __future__ import annotations import logging import threading from abc import ABC, abstractmethod from collections import defaultdict from copy import copy from dataclasses import asdict, dataclass from enum import Enum from anthropic.types import MessageParam, MessageTokensCount from dotenv import load_dotenv log = logging.getLogger(__name__) class TokenCountEstimator(ABC): @abstractmethod def estimate_token_count(self, text: str) -> int: """ Estimate the number of tokens in the given text. This is an abstract method that should be implemented by subclasses. """ class TiktokenCountEstimator(TokenCountEstimator): """ Approximate token count using tiktoken. """ def __init__(self, model_name: str = "gpt-4o"): """ The tokenizer will be downloaded on the first initialization, which may take some time. :param model_name: see `tiktoken.model` to see available models. """ import tiktoken log.info(f"Loading tiktoken encoding for model {model_name}, this may take a while on the first run.") self._encoding = tiktoken.encoding_for_model(model_name) def estimate_token_count(self, text: str) -> int: return len(self._encoding.encode(text)) class AnthropicTokenCount(TokenCountEstimator): """ The exact count using the Anthropic API. Counting is free, but has a rate limit and will require an API key, (typically, set through an env variable). See https://docs.anthropic.com/en/docs/build-with-claude/token-counting """ def __init__(self, model_name: str = "claude-sonnet-4-20250514", api_key: str | None = None): import anthropic self._model_name = model_name if api_key is None: load_dotenv() self._anthropic_client = anthropic.Anthropic(api_key=api_key) def _send_count_tokens_request(self, text: str) -> MessageTokensCount: return self._anthropic_client.messages.count_tokens( model=self._model_name, messages=[MessageParam(role="user", content=text)], ) def estimate_token_count(self, text: str) -> int: return self._send_count_tokens_request(text).input_tokens class CharCountEstimator(TokenCountEstimator): """ A naive character count estimator that estimates tokens based on character count. """ def __init__(self, avg_chars_per_token: int = 4): self._avg_chars_per_token = avg_chars_per_token def estimate_token_count(self, text: str) -> int: # Assuming an average of 4 characters per token return len(text) // self._avg_chars_per_token _registered_token_estimator_instances_cache: dict[RegisteredTokenCountEstimator, TokenCountEstimator] = {} class RegisteredTokenCountEstimator(Enum): TIKTOKEN_GPT4O = "TIKTOKEN_GPT4O" ANTHROPIC_CLAUDE_SONNET_4 = "ANTHROPIC_CLAUDE_SONNET_4" CHAR_COUNT = "CHAR_COUNT" @classmethod def get_valid_names(cls) -> list[str]: """ Get a list of all registered token count estimator names. """ return [estimator.name for estimator in cls] def _create_estimator(self) -> TokenCountEstimator: match self: case RegisteredTokenCountEstimator.TIKTOKEN_GPT4O: return TiktokenCountEstimator(model_name="gpt-4o") case RegisteredTokenCountEstimator.ANTHROPIC_CLAUDE_SONNET_4: return AnthropicTokenCount(model_name="claude-sonnet-4-20250514") case RegisteredTokenCountEstimator.CHAR_COUNT: return CharCountEstimator(avg_chars_per_token=4) case _: raise ValueError(f"Unknown token count estimator: {self}") def load_estimator(self) -> TokenCountEstimator: estimator_instance = _registered_token_estimator_instances_cache.get(self) if estimator_instance is None: estimator_instance = self._create_estimator() _registered_token_estimator_instances_cache[self] = estimator_instance return estimator_instance class ToolUsageStats: """ A class to record and manage tool usage statistics. """ def __init__(self, token_count_estimator: RegisteredTokenCountEstimator = RegisteredTokenCountEstimator.TIKTOKEN_GPT4O): self._token_count_estimator = token_count_estimator.load_estimator() self._token_estimator_name = token_count_estimator.value self._tool_stats: dict[str, ToolUsageStats.Entry] = defaultdict(ToolUsageStats.Entry) self._tool_stats_lock = threading.Lock() @property def token_estimator_name(self) -> str: """ Get the name of the registered token count estimator used. """ return self._token_estimator_name @dataclass(kw_only=True) class Entry: num_times_called: int = 0 input_tokens: int = 0 output_tokens: int = 0 def update_on_call(self, input_tokens: int, output_tokens: int) -> None: """ Update the entry with the number of tokens used for a single call. """ self.num_times_called += 1 self.input_tokens += input_tokens self.output_tokens += output_tokens def _estimate_token_count(self, text: str) -> int: return self._token_count_estimator.estimate_token_count(text) def get_stats(self, tool_name: str) -> ToolUsageStats.Entry: """ Get (a copy of) the current usage statistics for a specific tool. """ with self._tool_stats_lock: return copy(self._tool_stats[tool_name]) def record_tool_usage(self, tool_name: str, input_str: str, output_str: str) -> None: input_tokens = self._estimate_token_count(input_str) output_tokens = self._estimate_token_count(output_str) with self._tool_stats_lock: entry = self._tool_stats[tool_name] entry.update_on_call(input_tokens, output_tokens) def get_tool_stats_dict(self) -> dict[str, dict[str, int]]: with self._tool_stats_lock: return {name: asdict(entry) for name, entry in self._tool_stats.items()} def clear(self) -> None: with self._tool_stats_lock: self._tool_stats.clear() ================================================ FILE: src/serena/cli.py ================================================ import collections import glob import json import os import shutil import subprocess import sys from collections.abc import Iterator, Sequence from logging import Logger from pathlib import Path from typing import Any, Literal import click from sensai.util import logging from sensai.util.logging import FileLoggerContext, datetime_tag from sensai.util.string import dict_string from tqdm import tqdm from serena.agent import SerenaAgent from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode from serena.config.serena_config import ( LanguageBackend, ModeSelectionDefinition, ProjectConfig, RegisteredProject, SerenaConfig, SerenaPaths, ) from serena.constants import ( DEFAULT_CONTEXT, PROMPT_TEMPLATES_DIR_INTERNAL, SERENA_LOG_FORMAT, SERENAS_OWN_CONTEXT_YAMLS_DIR, SERENAS_OWN_MODE_YAMLS_DIR, ) from serena.mcp import SerenaMCPFactory from serena.project import Project from serena.tools import FindReferencingSymbolsTool, FindSymbolTool, GetSymbolsOverviewTool, SearchForPatternTool, ToolRegistry from serena.util.dataclass import get_dataclass_default from serena.util.logging import MemoryLogHandler from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind from solidlsp.util.subprocess_util import subprocess_kwargs log = logging.getLogger(__name__) _MAX_CONTENT_WIDTH = 100 _MODES_EXPLANATION = f"""\b\nBuilt-in mode names or paths to custom mode YAMLs with which to override the default modes defined in the global Serena configuration or the active project. For details on mode configuration, see https://oraios.github.io/serena/02-usage/050_configuration.html#modes. If no configuration changes were made, the base defaults are: {get_dataclass_default(SerenaConfig, "default_modes")}. Overriding them means that they no longer apply, so you will need to re-specify them in addition to further modes if you want to keep them.""" def find_project_root(root: str | Path | None = None) -> str | None: """Find project root by walking up from CWD. Checks for .serena/project.yml first (explicit Serena project), then .git (git root). :param root: If provided, constrains the search to this directory and below (acts as a virtual filesystem root). Search stops at this boundary. :return: absolute path to project root or None if not suitable root is found """ current = Path.cwd().resolve() boundary = Path(root).resolve() if root is not None else None def ancestors() -> Iterator[Path]: """Yield current directory and ancestors up to boundary.""" yield current for parent in current.parents: yield parent if boundary is not None and parent == boundary: return # First pass: look for .serena for directory in ancestors(): if (directory / ".serena" / "project.yml").is_file(): return str(directory) # Second pass: look for .git for directory in ancestors(): if (directory / ".git").exists(): # .git can be file (worktree) or dir return str(directory) return None # --------------------- Utilities ------------------------------------- def _open_in_editor(path: str) -> None: """Open the given file in the system's default editor or viewer.""" editor = os.environ.get("EDITOR") run_kwargs = subprocess_kwargs() try: if editor: subprocess.run([editor, path], check=False, **run_kwargs) elif sys.platform.startswith("win"): try: os.startfile(path) except OSError: subprocess.run(["notepad.exe", path], check=False, **run_kwargs) elif sys.platform == "darwin": subprocess.run(["open", path], check=False, **run_kwargs) else: subprocess.run(["xdg-open", path], check=False, **run_kwargs) except Exception as e: print(f"Failed to open {path}: {e}") class ProjectType(click.ParamType): """ParamType allowing either a project name or a path to a project directory.""" name = "[PROJECT_NAME|PROJECT_PATH]" def convert(self, value: str, param: Any, ctx: Any) -> str: path = Path(value).resolve() if path.exists() and path.is_dir(): return str(path) return value PROJECT_TYPE = ProjectType() class AutoRegisteringGroup(click.Group): """ A click.Group subclass that automatically registers any click.Command attributes defined on the class into the group. After initialization, it inspects its own class for attributes that are instances of click.Command (typically created via @click.command) and calls self.add_command(cmd) on each. This lets you define your commands as static methods on the subclass for IDE-friendly organization without manual registration. """ def __init__(self, name: str, help: str): super().__init__(name=name, help=help) # Scan class attributes for click.Command instances and register them. for attr in dir(self.__class__): cmd = getattr(self.__class__, attr) if isinstance(cmd, click.Command): self.add_command(cmd) class TopLevelCommands(AutoRegisteringGroup): """Root CLI group containing the core Serena commands.""" def __init__(self) -> None: super().__init__(name="serena", help="Serena CLI commands. You can run ` --help` for more info on each command.") @staticmethod @click.command("start-mcp-server", help="Starts the Serena MCP server.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH}) @click.option("--project", "project", type=PROJECT_TYPE, default=None, help="Path or name of project to activate at startup.") @click.option("--project-file", "project", type=PROJECT_TYPE, default=None, help="[DEPRECATED] Use --project instead.") @click.argument("project_file_arg", type=PROJECT_TYPE, required=False, default=None, metavar="") @click.option( "--context", type=str, default=DEFAULT_CONTEXT, show_default=True, help="Built-in context name or path to custom context YAML." ) @click.option( "--mode", "modes", type=str, multiple=True, default=(), show_default=False, help=_MODES_EXPLANATION, ) @click.option( "--language-backend", type=click.Choice([lb.value for lb in LanguageBackend]), default=None, help="Override the configured language backend.", ) @click.option( "--transport", type=click.Choice(["stdio", "sse", "streamable-http"]), default="stdio", show_default=True, help="Transport protocol.", ) @click.option( "--host", type=str, default="0.0.0.0", show_default=True, help="Listen address for the MCP server (when using corresponding transport).", ) @click.option( "--port", type=int, default=8000, show_default=True, help="Listen port for the MCP server (when using corresponding transport)." ) @click.option( "--enable-web-dashboard", type=bool, is_flag=False, default=None, help="Enable the web dashboard (overriding the setting in Serena's config). " "It is recommended to always enable the dashboard. If you don't want the browser to open on startup, set open-web-dashboard to False. " "For more information, see\nhttps://oraios.github.io/serena/02-usage/060_dashboard.html", ) @click.option( "--enable-gui-log-window", type=bool, is_flag=False, default=None, help="Enable the gui log window (currently only displays logs; overriding the setting in Serena's config).", ) @click.option( "--open-web-dashboard", type=bool, is_flag=False, default=None, help="Open Serena's dashboard in your browser after MCP server startup (overriding the setting in Serena's config).", ) @click.option( "--log-level", type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]), default=None, help="Override log level in config.", ) @click.option("--trace-lsp-communication", type=bool, is_flag=False, default=None, help="Whether to trace LSP communication.") @click.option("--tool-timeout", type=float, default=None, help="Override tool execution timeout in config.") @click.option( "--project-from-cwd", is_flag=True, default=False, help="Auto-detect project from current working directory (searches for .serena/project.yml or .git, falls back to CWD). Intended for CLI-based agents like Claude Code, Gemini and Codex.", ) def start_mcp_server( project: str | None, project_file_arg: str | None, project_from_cwd: bool | None, context: str, modes: Sequence[str], language_backend: str | None, transport: Literal["stdio", "sse", "streamable-http"], host: str, port: int, enable_web_dashboard: bool | None, open_web_dashboard: bool | None, enable_gui_log_window: bool | None, log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None, trace_lsp_communication: bool | None, tool_timeout: float | None, ) -> None: # initialize logging, using INFO level initially (will later be adjusted by SerenaAgent according to the config) # * memory log handler (for use by GUI/Dashboard) # * stream handler for stderr (for direct console output, which will also be captured by clients like Claude Desktop) # * file handler # (Note that stdout must never be used for logging, as it is used by the MCP server to communicate with the client.) Logger.root.setLevel(logging.INFO) formatter = logging.Formatter(SERENA_LOG_FORMAT) memory_log_handler = MemoryLogHandler() Logger.root.addHandler(memory_log_handler) stderr_handler = logging.StreamHandler(stream=sys.stderr) stderr_handler.formatter = formatter Logger.root.addHandler(stderr_handler) log_path = SerenaPaths().get_next_log_file_path("mcp") file_handler = logging.FileHandler(log_path, mode="w") file_handler.formatter = formatter Logger.root.addHandler(file_handler) log.info("Initializing Serena MCP server") log.info("Storing logs in %s", log_path) # Handle --project-from-cwd flag if project_from_cwd: if project is not None or project_file_arg is not None: raise click.UsageError("--project-from-cwd cannot be used with --project or positional project argument") project = find_project_root() if project is not None: log.info("Auto-detected project root: %s", project) else: log.warning("No project root found from %s; not activating any project", os.getcwd()) project_file = project_file_arg or project factory = SerenaMCPFactory(context=context, project=project_file, memory_log_handler=memory_log_handler) server = factory.create_mcp_server( host=host, port=port, modes=modes, language_backend=LanguageBackend.from_str(language_backend) if language_backend else None, enable_web_dashboard=enable_web_dashboard, open_web_dashboard=open_web_dashboard, enable_gui_log_window=enable_gui_log_window, log_level=log_level, trace_lsp_communication=trace_lsp_communication, tool_timeout=tool_timeout, ) if project_file_arg: log.warning( "Positional project arg is deprecated; use --project instead. Used: %s", project_file, ) log.info("Starting MCP server …") server.run(transport=transport) @staticmethod @click.command( "print-system-prompt", help="Print the system prompt for a project.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH} ) @click.argument("project", type=click.Path(exists=True), default=os.getcwd(), required=False) @click.option( "--log-level", type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]), default="WARNING", help="Log level for prompt generation.", ) @click.option("--only-instructions", is_flag=True, help="Print only the initial instructions, without prefix/postfix.") @click.option( "--context", type=str, default=DEFAULT_CONTEXT, show_default=True, help="Built-in context name or path to custom context YAML." ) @click.option( "--mode", "modes", type=str, multiple=True, default=(), show_default=False, help=_MODES_EXPLANATION, ) def print_system_prompt( project: str, log_level: str, only_instructions: bool, context: str, modes: Sequence[str] | None = None ) -> None: prefix = "You will receive access to Serena's symbolic tools. Below are instructions for using them, take them into account." postfix = "You begin by acknowledging that you understood the above instructions and are ready to receive tasks." from serena.tools.workflow_tools import InitialInstructionsTool lvl = logging.getLevelNamesMapping()[log_level.upper()] logging.configure(level=lvl) context_instance = SerenaAgentContext.load(context) modes_selection_def: ModeSelectionDefinition | None = None if modes: modes_selection_def = ModeSelectionDefinition(default_modes=modes) agent = SerenaAgent( project=os.path.abspath(project), serena_config=SerenaConfig(web_dashboard=False, log_level=lvl), context=context_instance, modes=modes_selection_def, ) tool = agent.get_tool(InitialInstructionsTool) instr = tool.apply() if only_instructions: print(instr) else: print(f"{prefix}\n{instr}\n{postfix}") @staticmethod @click.command( "start-project-server", help="Starts the Serena project server, which exposes project querying capabilities via HTTP.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH}, ) @click.option( "--host", type=str, default="127.0.0.1", show_default=True, help="Listen address for the project server.", ) @click.option( "--port", type=int, default=None, help="Listen port for the project server (default: ProjectServer.PORT).", ) @click.option( "--log-level", type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]), default=None, help="Override log level in config.", ) def start_project_server( host: str, port: int | None, log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None, ) -> None: from serena.project_server import ProjectServer # initialize logging Logger.root.setLevel(logging.INFO) formatter = logging.Formatter(SERENA_LOG_FORMAT) stderr_handler = logging.StreamHandler(stream=sys.stderr) stderr_handler.formatter = formatter Logger.root.addHandler(stderr_handler) log_path = SerenaPaths().get_next_log_file_path("project-server") file_handler = logging.FileHandler(log_path, mode="w") file_handler.formatter = formatter Logger.root.addHandler(file_handler) if log_level is not None: Logger.root.setLevel(logging.getLevelNamesMapping()[log_level]) log.info("Starting Serena project server") log.info("Storing logs in %s", log_path) server = ProjectServer() run_kwargs: dict[str, Any] = {"host": host} if port is not None: run_kwargs["port"] = port server.run(**run_kwargs) class ModeCommands(AutoRegisteringGroup): """Group for 'mode' subcommands.""" def __init__(self) -> None: super().__init__(name="mode", help="Manage Serena modes. You can run `mode --help` for more info on each command.") @staticmethod @click.command("list", help="List available modes.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH}) def list() -> None: mode_names = SerenaAgentMode.list_registered_mode_names() max_len_name = max(len(name) for name in mode_names) if mode_names else 20 for name in mode_names: mode_yml_path = SerenaAgentMode.get_path(name) is_internal = Path(mode_yml_path).is_relative_to(SERENAS_OWN_MODE_YAMLS_DIR) descriptor = "(internal)" if is_internal else f"(at {mode_yml_path})" name_descr_string = f"{name:<{max_len_name + 4}}{descriptor}" click.echo(name_descr_string) @staticmethod @click.command("create", help="Create a new mode or copy an internal one.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH}) @click.option( "--name", "-n", type=str, default=None, help="Name for the new mode. If --from-internal is passed may be left empty to create a mode of the same name, which will then override the internal mode.", ) @click.option("--from-internal", "from_internal", type=str, default=None, help="Copy from an internal mode.") def create(name: str, from_internal: str) -> None: if not (name or from_internal): raise click.UsageError("Provide at least one of --name or --from-internal.") mode_name = name or from_internal dest = os.path.join(SerenaPaths().user_modes_dir, f"{mode_name}.yml") src = ( os.path.join(SERENAS_OWN_MODE_YAMLS_DIR, f"{from_internal}.yml") if from_internal else os.path.join(SERENAS_OWN_MODE_YAMLS_DIR, "mode.template.yml") ) if not os.path.exists(src): raise FileNotFoundError( f"Internal mode '{from_internal}' not found in {SERENAS_OWN_MODE_YAMLS_DIR}. Available modes: {SerenaAgentMode.list_registered_mode_names()}" ) os.makedirs(os.path.dirname(dest), exist_ok=True) shutil.copyfile(src, dest) click.echo(f"Created mode '{mode_name}' at {dest}") _open_in_editor(dest) @staticmethod @click.command("edit", help="Edit a custom mode YAML file.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH}) @click.argument("mode_name") def edit(mode_name: str) -> None: path = os.path.join(SerenaPaths().user_modes_dir, f"{mode_name}.yml") if not os.path.exists(path): if mode_name in SerenaAgentMode.list_registered_mode_names(include_user_modes=False): click.echo( f"Mode '{mode_name}' is an internal mode and cannot be edited directly. " f"Use 'mode create --from-internal {mode_name}' to create a custom mode that overrides it before editing." ) else: click.echo(f"Custom mode '{mode_name}' not found. Create it with: mode create --name {mode_name}.") return _open_in_editor(path) @staticmethod @click.command("delete", help="Delete a custom mode file.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH}) @click.argument("mode_name") def delete(mode_name: str) -> None: path = os.path.join(SerenaPaths().user_modes_dir, f"{mode_name}.yml") if not os.path.exists(path): click.echo(f"Custom mode '{mode_name}' not found.") return os.remove(path) click.echo(f"Deleted custom mode '{mode_name}'.") class ContextCommands(AutoRegisteringGroup): """Group for 'context' subcommands.""" def __init__(self) -> None: super().__init__( name="context", help="Manage Serena contexts. You can run `context --help` for more info on each command." ) @staticmethod @click.command("list", help="List available contexts.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH}) def list() -> None: context_names = SerenaAgentContext.list_registered_context_names() max_len_name = max(len(name) for name in context_names) if context_names else 20 for name in context_names: context_yml_path = SerenaAgentContext.get_path(name) is_internal = Path(context_yml_path).is_relative_to(SERENAS_OWN_CONTEXT_YAMLS_DIR) descriptor = "(internal)" if is_internal else f"(at {context_yml_path})" name_descr_string = f"{name:<{max_len_name + 4}}{descriptor}" click.echo(name_descr_string) @staticmethod @click.command( "create", help="Create a new context or copy an internal one.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH} ) @click.option( "--name", "-n", type=str, default=None, help="Name for the new context. If --from-internal is passed may be left empty to create a context of the same name, which will then override the internal context", ) @click.option("--from-internal", "from_internal", type=str, default=None, help="Copy from an internal context.") def create(name: str, from_internal: str) -> None: if not (name or from_internal): raise click.UsageError("Provide at least one of --name or --from-internal.") ctx_name = name or from_internal dest = os.path.join(SerenaPaths().user_contexts_dir, f"{ctx_name}.yml") src = ( os.path.join(SERENAS_OWN_CONTEXT_YAMLS_DIR, f"{from_internal}.yml") if from_internal else os.path.join(SERENAS_OWN_CONTEXT_YAMLS_DIR, "context.template.yml") ) if not os.path.exists(src): raise FileNotFoundError( f"Internal context '{from_internal}' not found in {SERENAS_OWN_CONTEXT_YAMLS_DIR}. Available contexts: {SerenaAgentContext.list_registered_context_names()}" ) os.makedirs(os.path.dirname(dest), exist_ok=True) shutil.copyfile(src, dest) click.echo(f"Created context '{ctx_name}' at {dest}") _open_in_editor(dest) @staticmethod @click.command("edit", help="Edit a custom context YAML file.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH}) @click.argument("context_name") def edit(context_name: str) -> None: path = os.path.join(SerenaPaths().user_contexts_dir, f"{context_name}.yml") if not os.path.exists(path): if context_name in SerenaAgentContext.list_registered_context_names(include_user_contexts=False): click.echo( f"Context '{context_name}' is an internal context and cannot be edited directly. " f"Use 'context create --from-internal {context_name}' to create a custom context that overrides it before editing." ) else: click.echo(f"Custom context '{context_name}' not found. Create it with: context create --name {context_name}.") return _open_in_editor(path) @staticmethod @click.command("delete", help="Delete a custom context file.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH}) @click.argument("context_name") def delete(context_name: str) -> None: path = os.path.join(SerenaPaths().user_contexts_dir, f"{context_name}.yml") if not os.path.exists(path): click.echo(f"Custom context '{context_name}' not found.") return os.remove(path) click.echo(f"Deleted custom context '{context_name}'.") class SerenaConfigCommands(AutoRegisteringGroup): """Group for 'config' subcommands.""" def __init__(self) -> None: super().__init__(name="config", help="Manage Serena configuration.") @staticmethod @click.command( "edit", help="Edit serena_config.yml in your default editor. Will create a config file from the template if no config is found.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH}, ) def edit() -> None: serena_config = SerenaConfig.from_config_file() assert serena_config.config_file_path is not None _open_in_editor(serena_config.config_file_path) class ProjectCommands(AutoRegisteringGroup): """Group for 'project' subcommands.""" def __init__(self) -> None: super().__init__( name="project", help="Manage Serena projects. You can run `project --help` for more info on each command." ) @staticmethod def _create_project(project_path: str, name: str | None, language: tuple[str, ...]) -> RegisteredProject: """ Helper method to create a project configuration file. :param project_path: Path to the project directory :param name: Optional project name (defaults to directory name if not specified) :param language: Tuple of language names :raises FileExistsError: If project.yml already exists :raises ValueError: If an unsupported language is specified :return: the RegisteredProject instance """ project_root = Path(project_path).resolve() serena_config = SerenaConfig.from_config_file() yml_path = serena_config.get_project_yml_location(str(project_root)) if os.path.exists(yml_path): raise FileExistsError(f"Project file {yml_path} already exists.") languages: list[Language] = [] if language: for lang in language: try: languages.append(Language(lang.lower())) except ValueError: all_langs = [l.value for l in Language] raise ValueError(f"Unknown language '{lang}'. Supported: {all_langs}") generated_conf = ProjectConfig.autogenerate( project_root=project_path, serena_config=serena_config, project_name=name, languages=languages if languages else None, interactive=True, ) languages_str = ", ".join([lang.value for lang in generated_conf.languages]) if generated_conf.languages else "N/A" click.echo(f"Generated project with languages {{{languages_str}}} at {yml_path}.") registered_project = serena_config.get_registered_project(str(project_root)) if registered_project is None: registered_project = RegisteredProject(str(project_root), generated_conf) serena_config.add_registered_project(registered_project) return registered_project @staticmethod @click.command("create", help="Create a new Serena project configuration.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH}) @click.argument("project_path", type=click.Path(exists=True, file_okay=False), default=os.getcwd()) @click.option("--name", type=str, default=None, help="Project name; defaults to directory name if not specified.") @click.option( "--language", type=str, multiple=True, help="Programming language(s); inferred if not specified. Can be passed multiple times." ) @click.option("--index", is_flag=True, help="Index the project after creation.") @click.option( "--log-level", type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]), default="WARNING", help="Log level for indexing (only used if --index is set).", ) @click.option("--timeout", type=float, default=10, help="Timeout for indexing a single file (only used if --index is set).") def create(project_path: str, name: str | None, language: tuple[str, ...], index: bool, log_level: str, timeout: float) -> None: try: registered_project = ProjectCommands._create_project(project_path, name, language) if index: click.echo("Indexing project...") ProjectCommands._index_project(registered_project, log_level, timeout=timeout) except FileExistsError as e: raise click.ClickException(f"Project already exists: {e}\nUse 'serena project index' to index an existing project.") except ValueError as e: raise click.ClickException(str(e)) @staticmethod @click.command( "index", help="Index a project by saving symbols to the LSP cache. Auto-creates project.yml if it doesn't exist.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH}, ) @click.argument("project", type=PROJECT_TYPE, default=os.getcwd(), required=False) @click.option("--name", type=str, default=None, help="Project name (only used if auto-creating project.yml).") @click.option( "--language", type=str, multiple=True, help="Programming language(s) (only used if auto-creating project.yml). Inferred if not specified.", ) @click.option( "--log-level", type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]), default="WARNING", help="Log level for indexing.", ) @click.option("--timeout", type=float, default=10, help="Timeout for indexing a single file.") def index(project: str, name: str | None, language: tuple[str, ...], log_level: str, timeout: float) -> None: serena_config = SerenaConfig.from_config_file() registered_project = serena_config.get_registered_project(project, autoregister=True) if registered_project is None: # Project not found; auto-create it click.echo(f"No existing project found for '{project}'. Attempting auto-creation ...") try: registered_project = ProjectCommands._create_project(project, name, language) except Exception as e: raise click.ClickException(str(e)) ProjectCommands._index_project(registered_project, log_level, timeout=timeout) @staticmethod def _index_project(registered_project: RegisteredProject, log_level: str, timeout: float) -> None: lvl = logging.getLevelNamesMapping()[log_level.upper()] logging.configure(level=lvl) serena_config = SerenaConfig.from_config_file() proj = registered_project.get_project_instance(serena_config=serena_config) click.echo(f"Indexing symbols in {proj} …") ls_mgr = proj.create_language_server_manager() try: log_file = os.path.join(proj.project_root, ".serena", "logs", "indexing.txt") files = proj.gather_source_files() collected_exceptions: list[Exception] = [] files_failed = [] language_file_counts: dict[Language, int] = collections.defaultdict(lambda: 0) for i, f in enumerate(tqdm(files, desc="Indexing")): try: ls = ls_mgr.get_language_server(f) ls.request_document_symbols(f) language_file_counts[ls.language] += 1 except Exception as e: log.error(f"Failed to index {f}, continuing.") collected_exceptions.append(e) files_failed.append(f) if (i + 1) % 10 == 0: ls_mgr.save_all_caches() reported_language_file_counts = {k.value: v for k, v in language_file_counts.items()} click.echo(f"Indexed files per language: {dict_string(reported_language_file_counts, brackets=None)}") ls_mgr.save_all_caches() if len(files_failed) > 0: os.makedirs(os.path.dirname(log_file), exist_ok=True) with open(log_file, "w") as f: for file, exception in zip(files_failed, collected_exceptions, strict=True): f.write(f"{file}\n") f.write(f"{exception}\n") click.echo(f"Failed to index {len(files_failed)} files, see:\n{log_file}") finally: ls_mgr.stop_all() @staticmethod @click.command( "is_ignored_path", help="Check if a path is ignored by the project configuration.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH}, ) @click.argument("path", type=click.Path(exists=False, file_okay=True, dir_okay=True)) @click.argument("project", type=click.Path(exists=True, file_okay=False, dir_okay=True), default=os.getcwd()) def is_ignored_path(path: str, project: str) -> None: """ Check if a given path is ignored by the project configuration. :param path: The path to check. :param project: The path to the project directory, defaults to the current working directory. """ serena_config = SerenaConfig.from_config_file() proj = Project.load(os.path.abspath(project), serena_config=serena_config) if os.path.isabs(path): path = os.path.relpath(path, start=proj.project_root) is_ignored = proj.is_ignored_path(path) click.echo(f"Path '{path}' IS {'ignored' if is_ignored else 'IS NOT ignored'} by the project configuration.") @staticmethod @click.command( "index-file", help="Index a single file by saving its symbols to the LSP cache.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH}, ) @click.argument("file", type=click.Path(exists=True, file_okay=True, dir_okay=False)) @click.argument("project", type=click.Path(exists=True, file_okay=False, dir_okay=True), default=os.getcwd()) @click.option("--verbose", "-v", is_flag=True, help="Print detailed information about the indexed symbols.") def index_file(file: str, project: str, verbose: bool) -> None: """ Index a single file by saving its symbols to the LSP cache, useful for debugging. :param file: path to the file to index, must be inside the project directory. :param project: path to the project directory, defaults to the current working directory. :param verbose: if set, prints detailed information about the indexed symbols. """ serena_config = SerenaConfig.from_config_file() proj = Project.load(os.path.abspath(project), serena_config=serena_config) if os.path.isabs(file): file = os.path.relpath(file, start=proj.project_root) if proj.is_ignored_path(file, ignore_non_source_files=True): click.echo(f"'{file}' is ignored or declared as non-code file by the project configuration, won't index.") exit(1) ls_mgr = proj.create_language_server_manager() try: for ls in ls_mgr.iter_language_servers(): click.echo(f"Indexing for language {ls.language.value} …") document_symbols = ls.request_document_symbols(file) symbols, _ = document_symbols.get_all_symbols_and_roots() if verbose: click.echo(f"Symbols in file '{file}':") for symbol in symbols: click.echo(f" - {symbol['name']} at line {symbol['selectionRange']['start']['line']} of kind {symbol['kind']}") ls.save_cache() click.echo(f"Successfully indexed file '{file}', {len(symbols)} symbols saved to cache in {ls.cache_dir}.") finally: ls_mgr.stop_all() @staticmethod @click.command( "health-check", help="Perform a comprehensive health check of the project's tools and language server.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH}, ) @click.argument("project", type=click.Path(exists=True, file_okay=False, dir_okay=True), default=os.getcwd()) def health_check(project: str) -> None: """ Perform a comprehensive health check of the project's tools and language server. :param project: path to the project directory, defaults to the current working directory. """ # NOTE: completely written by Claude Code, only functionality was reviewed, not implementation logging.configure(level=logging.INFO) project_path = os.path.abspath(project) serena_config = SerenaConfig.from_config_file() serena_config.language_backend = LanguageBackend.LSP serena_config.gui_log_window = False serena_config.web_dashboard = False proj = Project.load(project_path, serena_config=serena_config) # Create log file with timestamp timestamp = datetime_tag() log_dir = os.path.join(project_path, ".serena", "logs", "health-checks") os.makedirs(log_dir, exist_ok=True) log_file = os.path.join(log_dir, f"health_check_{timestamp}.log") with FileLoggerContext(log_file, append=False, enabled=True): log.info("Starting health check for project: %s", project_path) try: # Create SerenaAgent with dashboard disabled log.info("Creating SerenaAgent with disabled dashboard...") agent = SerenaAgent(project=project_path, serena_config=serena_config) log.info("SerenaAgent created successfully") # Find first non-empty file that can be analyzed log.info("Searching for analyzable files...") files = proj.gather_source_files() target_file = None for file_path in files: try: full_path = os.path.join(project_path, file_path) if os.path.getsize(full_path) > 0: target_file = file_path log.info("Found analyzable file: %s", target_file) break except (OSError, FileNotFoundError): continue if not target_file: log.error("No analyzable files found in project") click.echo("❌ Health check failed: No analyzable files found") click.echo(f"Log saved to: {log_file}") return # Get tools from agent overview_tool = agent.get_tool(GetSymbolsOverviewTool) find_symbol_tool = agent.get_tool(FindSymbolTool) find_refs_tool = agent.get_tool(FindReferencingSymbolsTool) search_pattern_tool = agent.get_tool(SearchForPatternTool) # Test 1: Get symbols overview log.info("Testing GetSymbolsOverviewTool on file: %s", target_file) overview_data = agent.execute_task(lambda: overview_tool.get_symbol_overview(target_file)) log.info(f"GetSymbolsOverviewTool returned: {overview_data}") if not overview_data: log.error("No symbols found in file %s", target_file) click.echo("❌ Health check failed: No symbols found in target file") click.echo(f"Log saved to: {log_file}") return # Extract suitable symbol (prefer class or function over variables) preferred_kinds = {SymbolKind.Class.name, SymbolKind.Function.name, SymbolKind.Method.name, SymbolKind.Constructor.name} selected_symbol = None for symbol in overview_data: if symbol.get("kind") in preferred_kinds: selected_symbol = symbol break # If no preferred symbol found, use first available if not selected_symbol: selected_symbol = overview_data[0] log.info("No class or function found, using first available symbol") symbol_name = selected_symbol["name"] symbol_kind = selected_symbol["kind"] log.info("Using symbol for testing: %s (kind: %s)", symbol_name, symbol_kind) # Test 2: FindSymbolTool log.info("Testing FindSymbolTool for symbol: %s", symbol_name) find_symbol_result = agent.execute_task( lambda: find_symbol_tool.apply(symbol_name, relative_path=target_file, include_body=True) ) find_symbol_data = json.loads(find_symbol_result) log.info("FindSymbolTool found %d matches for symbol %s", len(find_symbol_data), symbol_name) # Test 3: FindReferencingSymbolsTool log.info("Testing FindReferencingSymbolsTool for symbol: %s", symbol_name) try: find_refs_result = agent.execute_task(lambda: find_refs_tool.apply(symbol_name, relative_path=target_file)) find_refs_data = json.loads(find_refs_result) log.info("FindReferencingSymbolsTool found %d references for symbol %s", len(find_refs_data), symbol_name) except Exception as e: log.warning("FindReferencingSymbolsTool failed for symbol %s: %s", symbol_name, str(e)) find_refs_data = [] # Test 4: SearchForPatternTool to verify references log.info("Testing SearchForPatternTool for pattern: %s", symbol_name) try: search_result = agent.execute_task( lambda: search_pattern_tool.apply(substring_pattern=symbol_name, restrict_search_to_code_files=True) ) search_data = json.loads(search_result) pattern_matches = sum(len(matches) for matches in search_data.values()) log.info("SearchForPatternTool found %d pattern matches for %s", pattern_matches, symbol_name) except Exception as e: log.warning("SearchForPatternTool failed for pattern %s: %s", symbol_name, str(e)) pattern_matches = 0 # Verify tools worked as expected tools_working = True if not find_symbol_data: log.error("FindSymbolTool returned no results") tools_working = False if len(find_refs_data) == 0 and pattern_matches == 0: log.warning("Both FindReferencingSymbolsTool and SearchForPatternTool found no matches - this might indicate an issue") log.info("Health check completed successfully") if tools_working: click.echo("✅ Health check passed - All tools working correctly") else: click.echo("⚠️ Health check completed with warnings - Check log for details") except Exception as e: log.exception("Health check failed with exception: %s", str(e)) click.echo(f"❌ Health check failed: {e!s}") finally: click.echo(f"Log saved to: {log_file}") class ToolCommands(AutoRegisteringGroup): """Group for 'tool' subcommands.""" def __init__(self) -> None: super().__init__( name="tools", help="Commands related to Serena's tools. You can run `serena tools --help` for more info on each command.", ) @staticmethod @click.command( "list", help="Prints an overview of the tools that are active by default (not just the active ones for your project). For viewing all tools, pass `--all / -a`", context_settings={"max_content_width": _MAX_CONTENT_WIDTH}, ) @click.option("--quiet", "-q", is_flag=True) @click.option("--all", "-a", "include_optional", is_flag=True, help="List all tools, including those not enabled by default.") @click.option("--only-optional", is_flag=True, help="List only optional tools (those not enabled by default).") def list(quiet: bool = False, include_optional: bool = False, only_optional: bool = False) -> None: tool_registry = ToolRegistry() if quiet: if only_optional: tool_names = tool_registry.get_tool_names_optional() elif include_optional: tool_names = tool_registry.get_tool_names() else: tool_names = tool_registry.get_tool_names_default_enabled() for tool_name in tool_names: click.echo(tool_name) else: ToolRegistry().print_tool_overview(include_optional=include_optional, only_optional=only_optional) @staticmethod @click.command( "description", help="Print the description of a tool, optionally with a specific context (the latter may modify the default description).", context_settings={"max_content_width": _MAX_CONTENT_WIDTH}, ) @click.argument("tool_name", type=str) @click.option("--context", type=str, default=None, help="Context name or path to context file.") def description(tool_name: str, context: str | None = None) -> None: # Load the context serena_context = None if context: serena_context = SerenaAgentContext.load(context) agent = SerenaAgent( project=None, serena_config=SerenaConfig(web_dashboard=False, log_level=logging.INFO), context=serena_context, ) tool = agent.get_tool_by_name(tool_name) mcp_tool = SerenaMCPFactory.make_mcp_tool(tool) click.echo(mcp_tool.description) class PromptCommands(AutoRegisteringGroup): def __init__(self) -> None: super().__init__(name="prompts", help="Commands related to Serena's prompts that are outside of contexts and modes.") @staticmethod def _get_user_prompt_yaml_path(prompt_yaml_name: str) -> str: templates_dir = SerenaPaths().user_prompt_templates_dir os.makedirs(templates_dir, exist_ok=True) return os.path.join(templates_dir, prompt_yaml_name) @staticmethod @click.command( "list", help="Lists yamls that are used for defining prompts.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH} ) def list() -> None: serena_prompt_yaml_names = [os.path.basename(f) for f in glob.glob(PROMPT_TEMPLATES_DIR_INTERNAL + "/*.yml")] for prompt_yaml_name in serena_prompt_yaml_names: user_prompt_yaml_path = PromptCommands._get_user_prompt_yaml_path(prompt_yaml_name) if os.path.exists(user_prompt_yaml_path): click.echo(f"{user_prompt_yaml_path} merged with default prompts in {prompt_yaml_name}") else: click.echo(prompt_yaml_name) @staticmethod @click.command( "create-override", help="Create an override of an internal prompts yaml for customizing Serena's prompts", context_settings={"max_content_width": _MAX_CONTENT_WIDTH}, ) @click.argument("prompt_yaml_name") def create_override(prompt_yaml_name: str) -> None: """ :param prompt_yaml_name: The yaml name of the prompt you want to override. Call the `list` command for discovering valid prompt yaml names. :return: """ # for convenience, we can pass names without .yml if not prompt_yaml_name.endswith(".yml"): prompt_yaml_name = prompt_yaml_name + ".yml" user_prompt_yaml_path = PromptCommands._get_user_prompt_yaml_path(prompt_yaml_name) if os.path.exists(user_prompt_yaml_path): raise FileExistsError(f"{user_prompt_yaml_path} already exists.") serena_prompt_yaml_path = os.path.join(PROMPT_TEMPLATES_DIR_INTERNAL, prompt_yaml_name) shutil.copyfile(serena_prompt_yaml_path, user_prompt_yaml_path) _open_in_editor(user_prompt_yaml_path) @staticmethod @click.command( "edit-override", help="Edit an existing prompt override file", context_settings={"max_content_width": _MAX_CONTENT_WIDTH} ) @click.argument("prompt_yaml_name") def edit_override(prompt_yaml_name: str) -> None: """ :param prompt_yaml_name: The yaml name of the prompt override to edit. :return: """ # for convenience, we can pass names without .yml if not prompt_yaml_name.endswith(".yml"): prompt_yaml_name = prompt_yaml_name + ".yml" user_prompt_yaml_path = PromptCommands._get_user_prompt_yaml_path(prompt_yaml_name) if not os.path.exists(user_prompt_yaml_path): click.echo(f"Override file '{prompt_yaml_name}' not found. Create it with: prompts create-override {prompt_yaml_name}") return _open_in_editor(user_prompt_yaml_path) @staticmethod @click.command("list-overrides", help="List existing prompt override files", context_settings={"max_content_width": _MAX_CONTENT_WIDTH}) def list_overrides() -> None: user_templates_dir = SerenaPaths().user_prompt_templates_dir os.makedirs(user_templates_dir, exist_ok=True) serena_prompt_yaml_names = [os.path.basename(f) for f in glob.glob(PROMPT_TEMPLATES_DIR_INTERNAL + "/*.yml")] override_files = glob.glob(os.path.join(user_templates_dir, "*.yml")) for file_path in override_files: if os.path.basename(file_path) in serena_prompt_yaml_names: click.echo(file_path) @staticmethod @click.command("delete-override", help="Delete a prompt override file", context_settings={"max_content_width": _MAX_CONTENT_WIDTH}) @click.argument("prompt_yaml_name") def delete_override(prompt_yaml_name: str) -> None: """ :param prompt_yaml_name: The yaml name of the prompt override to delete." :return: """ # for convenience, we can pass names without .yml if not prompt_yaml_name.endswith(".yml"): prompt_yaml_name = prompt_yaml_name + ".yml" user_prompt_yaml_path = PromptCommands._get_user_prompt_yaml_path(prompt_yaml_name) if not os.path.exists(user_prompt_yaml_path): click.echo(f"Override file '{prompt_yaml_name}' not found.") return os.remove(user_prompt_yaml_path) click.echo(f"Deleted override file '{prompt_yaml_name}'.") # Expose groups so we can reference them in pyproject.toml mode = ModeCommands() context = ContextCommands() project = ProjectCommands() config = SerenaConfigCommands() tools = ToolCommands() prompts = PromptCommands() # Expose toplevel commands for the same reason top_level = TopLevelCommands() start_mcp_server = top_level.start_mcp_server # needed for the help script to work - register all subcommands to the top-level group for subgroup in (mode, context, project, config, tools, prompts): top_level.add_command(subgroup) def get_help() -> str: """Retrieve the help text for the top-level Serena CLI.""" return top_level.get_help(click.Context(top_level, info_name="serena")) ================================================ FILE: src/serena/code_editor.py ================================================ import json import logging import os from abc import ABC, abstractmethod from collections.abc import Iterable, Iterator, Reversible from contextlib import contextmanager from typing import Generic, TypeVar, cast from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient from serena.symbol import JetBrainsSymbol, LanguageServerSymbol, LanguageServerSymbolRetriever, PositionInFile, Symbol from solidlsp import SolidLanguageServer, ls_types from solidlsp.ls import LSPFileBuffer from solidlsp.ls_utils import PathUtils, TextUtils from .project import Project log = logging.getLogger(__name__) TSymbol = TypeVar("TSymbol", bound=Symbol) class CodeEditor(Generic[TSymbol], ABC): def __init__(self, project: Project) -> None: self.project_root = project.project_root self.encoding = project.project_config.encoding self.newline = project.line_ending.newline_str class EditedFile(ABC): def __init__(self, relative_path: str) -> None: self.relative_path = relative_path @abstractmethod def get_contents(self) -> str: """ :return: the contents of the file. """ @abstractmethod def set_contents(self, contents: str) -> None: """ Fully resets the contents of the file. :param contents: the new contents """ @abstractmethod def delete_text_between_positions(self, start_pos: PositionInFile, end_pos: PositionInFile) -> None: pass @abstractmethod def insert_text_at_position(self, pos: PositionInFile, text: str) -> None: pass @contextmanager def _open_file_context(self, relative_path: str) -> Iterator["CodeEditor.EditedFile"]: """ Context manager for opening a file """ raise NotImplementedError("This method must be overridden for each subclass") @contextmanager def edited_file_context(self, relative_path: str) -> Iterator["CodeEditor.EditedFile"]: """ Context manager for editing a file. """ with self._open_file_context(relative_path) as edited_file: yield edited_file # save the file self._save_edited_file(edited_file) def _save_edited_file(self, edited_file: "CodeEditor.EditedFile") -> None: abs_path = os.path.join(self.project_root, edited_file.relative_path) new_contents = edited_file.get_contents() with open(abs_path, "w", encoding=self.encoding, newline=self.newline) as f: f.write(new_contents) @abstractmethod def _find_unique_symbol(self, name_path: str, relative_file_path: str) -> TSymbol: """ Finds the unique symbol with the given name in the given file. If no such symbol exists, raises a ValueError. :param name_path: the name path :param relative_file_path: the relative path of the file in which to search for the symbol. :return: the unique symbol """ def replace_body(self, name_path: str, relative_file_path: str, body: str) -> None: """ Replaces the body of the symbol with the given name_path in the given file. :param name_path: the name path of the symbol to replace. :param relative_file_path: the relative path of the file in which the symbol is defined. :param body: the new body """ symbol = self._find_unique_symbol(name_path, relative_file_path) start_pos = symbol.get_body_start_position_or_raise() end_pos = symbol.get_body_end_position_or_raise() with self.edited_file_context(relative_file_path) as edited_file: # make sure the replacement adds no additional newlines (before or after) - all newlines # and whitespace before/after should remain the same, so we strip it entirely body = body.strip() edited_file.delete_text_between_positions(start_pos, end_pos) edited_file.insert_text_at_position(start_pos, body) @staticmethod def _count_leading_newlines(text: Iterable) -> int: cnt = 0 for c in text: if c == "\n": cnt += 1 elif c == "\r": continue else: break return cnt @classmethod def _count_trailing_newlines(cls, text: Reversible) -> int: return cls._count_leading_newlines(reversed(text)) def insert_after_symbol(self, name_path: str, relative_file_path: str, body: str) -> None: """ Inserts content after the symbol with the given name in the given file. """ symbol = self._find_unique_symbol(name_path, relative_file_path) # make sure body always ends with at least one newline if not body.endswith("\n"): body += "\n" pos = symbol.get_body_end_position_or_raise() # start at the beginning of the next line col = 0 line = pos.line + 1 # make sure a suitable number of leading empty lines is used (at least 0/1 depending on the symbol type, # otherwise as many as the caller wanted to insert) original_leading_newlines = self._count_leading_newlines(body) body = body.lstrip("\r\n") min_empty_lines = 0 if symbol.is_neighbouring_definition_separated_by_empty_line(): min_empty_lines = 1 num_leading_empty_lines = max(min_empty_lines, original_leading_newlines) if num_leading_empty_lines: body = ("\n" * num_leading_empty_lines) + body # make sure the one line break succeeding the original symbol, which we repurposed as prefix via # `line += 1`, is replaced body = body.rstrip("\r\n") + "\n" with self.edited_file_context(relative_file_path) as edited_file: edited_file.insert_text_at_position(PositionInFile(line, col), body) def insert_before_symbol(self, name_path: str, relative_file_path: str, body: str) -> None: """ Inserts content before the symbol with the given name in the given file. """ symbol = self._find_unique_symbol(name_path, relative_file_path) symbol_start_pos = symbol.get_body_start_position_or_raise() # insert position is the start of line where the symbol is defined line = symbol_start_pos.line col = 0 original_trailing_empty_lines = self._count_trailing_newlines(body) - 1 # ensure eol is present at end body = body.rstrip() + "\n" # add suitable number of trailing empty lines after the body (at least 0/1 depending on the symbol type, # otherwise as many as the caller wanted to insert) min_trailing_empty_lines = 0 if symbol.is_neighbouring_definition_separated_by_empty_line(): min_trailing_empty_lines = 1 num_trailing_newlines = max(min_trailing_empty_lines, original_trailing_empty_lines) body += "\n" * num_trailing_newlines # apply edit with self.edited_file_context(relative_file_path) as edited_file: edited_file.insert_text_at_position(PositionInFile(line=line, col=col), body) def insert_at_line(self, relative_path: str, line: int, content: str) -> None: """ Inserts content at the given line in the given file. :param relative_path: the relative path of the file in which to insert content :param line: the 0-based index of the line to insert content at :param content: the content to insert """ with self.edited_file_context(relative_path) as edited_file: edited_file.insert_text_at_position(PositionInFile(line, 0), content) def delete_lines(self, relative_path: str, start_line: int, end_line: int) -> None: """ Deletes lines in the given file. :param relative_path: the relative path of the file in which to delete lines :param start_line: the 0-based index of the first line to delete (inclusive) :param end_line: the 0-based index of the last line to delete (inclusive) """ start_col = 0 end_line_for_delete = end_line + 1 end_col = 0 with self.edited_file_context(relative_path) as edited_file: start_pos = PositionInFile(line=start_line, col=start_col) end_pos = PositionInFile(line=end_line_for_delete, col=end_col) edited_file.delete_text_between_positions(start_pos, end_pos) def delete_symbol(self, name_path: str, relative_file_path: str) -> None: """ Deletes the symbol with the given name in the given file. """ symbol = self._find_unique_symbol(name_path, relative_file_path) start_pos = symbol.get_body_start_position_or_raise() end_pos = symbol.get_body_end_position_or_raise() with self.edited_file_context(relative_file_path) as edited_file: edited_file.delete_text_between_positions(start_pos, end_pos) @abstractmethod def rename_symbol(self, name_path: str, relative_file_path: str, new_name: str) -> str: """ Renames the symbol with the given name throughout the codebase. :param name_path: the name path of the symbol to rename :param relative_file_path: the relative path of the file containing the symbol :param new_name: the new name for the symbol :return: a status message """ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]): def __init__(self, symbol_retriever: LanguageServerSymbolRetriever): super().__init__(project=symbol_retriever.project) self._symbol_retriever = symbol_retriever def _get_language_server(self, relative_path: str) -> SolidLanguageServer: return self._symbol_retriever.get_language_server(relative_path) class EditedFile(CodeEditor.EditedFile): def __init__(self, lang_server: SolidLanguageServer, relative_path: str, file_buffer: LSPFileBuffer): super().__init__(relative_path) self._lang_server = lang_server self._file_buffer = file_buffer def get_contents(self) -> str: return self._file_buffer.contents def set_contents(self, contents: str) -> None: self._file_buffer.contents = contents def delete_text_between_positions(self, start_pos: PositionInFile, end_pos: PositionInFile) -> None: self._lang_server.delete_text_between_positions(self.relative_path, start_pos.to_lsp_position(), end_pos.to_lsp_position()) def insert_text_at_position(self, pos: PositionInFile, text: str) -> None: self._lang_server.insert_text_at_position(self.relative_path, pos.line, pos.col, text) def apply_text_edits(self, text_edits: list[ls_types.TextEdit]) -> None: return self._lang_server.apply_text_edits_to_file(self.relative_path, text_edits) @contextmanager def _open_file_context(self, relative_path: str) -> Iterator["CodeEditor.EditedFile"]: lang_server = self._get_language_server(relative_path) with lang_server.open_file(relative_path) as file_buffer: yield self.EditedFile(lang_server, relative_path, file_buffer) def _get_code_file_content(self, relative_path: str) -> str: """Get the content of a file using the language server.""" lang_server = self._get_language_server(relative_path) return lang_server.language_server.retrieve_full_file_content(relative_path) def _find_unique_symbol(self, name_path: str, relative_file_path: str) -> LanguageServerSymbol: return self._symbol_retriever.find_unique(name_path, within_relative_path=relative_file_path) def _relative_path_from_uri(self, uri: str) -> str: return os.path.relpath(PathUtils.uri_to_path(uri), self.project_root) class EditOperation(ABC): @abstractmethod def apply(self) -> None: pass class EditOperationFileTextEdits(EditOperation): def __init__(self, code_editor: "LanguageServerCodeEditor", file_uri: str, text_edits: list[ls_types.TextEdit]): self._code_editor = code_editor self._relative_path = code_editor._relative_path_from_uri(file_uri) self._text_edits = text_edits def apply(self) -> None: with self._code_editor.edited_file_context(self._relative_path) as edited_file: edited_file = cast(LanguageServerCodeEditor.EditedFile, edited_file) edited_file.apply_text_edits(self._text_edits) class EditOperationRenameFile(EditOperation): def __init__(self, code_editor: "LanguageServerCodeEditor", old_uri: str, new_uri: str): self._code_editor = code_editor self._old_relative_path = code_editor._relative_path_from_uri(old_uri) self._new_relative_path = code_editor._relative_path_from_uri(new_uri) def apply(self) -> None: old_abs_path = os.path.join(self._code_editor.project_root, self._old_relative_path) new_abs_path = os.path.join(self._code_editor.project_root, self._new_relative_path) os.rename(old_abs_path, new_abs_path) def _workspace_edit_to_edit_operations(self, workspace_edit: ls_types.WorkspaceEdit) -> list["LanguageServerCodeEditor.EditOperation"]: operations: list[LanguageServerCodeEditor.EditOperation] = [] if "changes" in workspace_edit: for uri, edits in workspace_edit["changes"].items(): operations.append(self.EditOperationFileTextEdits(self, uri, edits)) if "documentChanges" in workspace_edit: for change in workspace_edit["documentChanges"]: if "textDocument" in change and "edits" in change: operations.append(self.EditOperationFileTextEdits(self, change["textDocument"]["uri"], change["edits"])) elif "kind" in change: if change["kind"] == "rename": operations.append(self.EditOperationRenameFile(self, change["oldUri"], change["newUri"])) else: raise ValueError(f"Unhandled document change kind: {change}; Please report to Serena developers.") else: raise ValueError(f"Unhandled document change format: {change}; Please report to Serena developers.") return operations def _apply_workspace_edit(self, workspace_edit: ls_types.WorkspaceEdit) -> int: """ Applies a WorkspaceEdit :param workspace_edit: the edit to apply :return: number of edit operations applied """ operations = self._workspace_edit_to_edit_operations(workspace_edit) for operation in operations: operation.apply() return len(operations) def rename_symbol(self, name_path: str, relative_file_path: str, new_name: str) -> str: symbol = self._find_unique_symbol(name_path, relative_file_path) if not symbol.location.has_position_in_file(): raise ValueError(f"Symbol '{name_path}' does not have a valid position in file for renaming") # After has_position_in_file check, line and column are guaranteed to be non-None assert symbol.location.line is not None assert symbol.location.column is not None lang_server = self._get_language_server(relative_file_path) rename_result = lang_server.request_rename_symbol_edit( relative_file_path=relative_file_path, line=symbol.location.line, column=symbol.location.column, new_name=new_name ) if rename_result is None: raise ValueError( f"Language server for {lang_server.language_id} returned no rename edits for symbol '{name_path}'. " f"The symbol might not support renaming." ) num_changes = self._apply_workspace_edit(rename_result) if num_changes == 0: raise ValueError( f"Renaming symbol '{name_path}' to '{new_name}' resulted in no changes being applied; renaming may not be supported." ) msg = f"Successfully renamed '{name_path}' to '{new_name}' ({num_changes} changes applied)" return msg class JetBrainsCodeEditor(CodeEditor[JetBrainsSymbol]): def __init__(self, project: Project) -> None: self._project = project super().__init__(project) class EditedFile(CodeEditor.EditedFile): def __init__(self, relative_path: str, project: Project): super().__init__(relative_path) path = os.path.join(project.project_root, relative_path) log.info("Editing file: %s", path) with open(path, encoding=project.project_config.encoding) as f: self._content = f.read() def get_contents(self) -> str: return self._content def set_contents(self, contents: str) -> None: self._content = contents def delete_text_between_positions(self, start_pos: PositionInFile, end_pos: PositionInFile) -> None: self._content, _ = TextUtils.delete_text_between_positions( self._content, start_pos.line, start_pos.col, end_pos.line, end_pos.col ) def insert_text_at_position(self, pos: PositionInFile, text: str) -> None: self._content, _, _ = TextUtils.insert_text_at_position(self._content, pos.line, pos.col, text) @contextmanager def _open_file_context(self, relative_path: str) -> Iterator["CodeEditor.EditedFile"]: yield self.EditedFile(relative_path, self._project) def _save_edited_file(self, edited_file: "CodeEditor.EditedFile") -> None: super()._save_edited_file(edited_file) with JetBrainsPluginClient.from_project(self._project) as client: client.refresh_file(edited_file.relative_path) def _find_unique_symbol(self, name_path: str, relative_file_path: str) -> JetBrainsSymbol: with JetBrainsPluginClient.from_project(self._project) as client: result = client.find_symbol(name_path, relative_path=relative_file_path, include_body=False, depth=0, include_location=True) symbols = result["symbols"] if not symbols: raise ValueError(f"No symbol with name {name_path} found in file {relative_file_path}") if len(symbols) > 1: raise ValueError( f"Found multiple {len(symbols)} symbols with name {name_path} in file {relative_file_path}: " + json.dumps(symbols, indent=2) ) return JetBrainsSymbol(symbols[0], self._project) def rename_symbol(self, name_path: str, relative_file_path: str, new_name: str) -> str: with JetBrainsPluginClient.from_project(self._project) as client: client.rename_symbol( name_path=name_path, relative_path=relative_file_path, new_name=new_name, rename_in_comments=False, rename_in_text_occurrences=False, ) return "Success" ================================================ FILE: src/serena/config/__init__.py ================================================ ================================================ FILE: src/serena/config/context_mode.py ================================================ """ Context and Mode configuration loader """ import os from dataclasses import dataclass, field from pathlib import Path from typing import TYPE_CHECKING, Self import yaml from sensai.util import logging from sensai.util.string import ToStringMixin from serena.config.serena_config import SerenaPaths, ToolInclusionDefinition from serena.constants import ( DEFAULT_CONTEXT, INTERNAL_MODE_YAMLS_DIR, SERENA_FILE_ENCODING, SERENAS_OWN_CONTEXT_YAMLS_DIR, SERENAS_OWN_MODE_YAMLS_DIR, ) if TYPE_CHECKING: pass log = logging.getLogger(__name__) @dataclass(kw_only=True) class SerenaAgentMode(ToolInclusionDefinition, ToStringMixin): """Represents a mode of operation for the agent, typically read off a YAML file. An agent can be in multiple modes simultaneously as long as they are not mutually exclusive. The modes can be adjusted after the agent is running, for example for switching from planning to editing. """ name: str prompt: str """ a Jinja2 template for the generation of the system prompt. It is formatted by the agent (see SerenaAgent._format_prompt()). """ description: str = "" _yaml_path: Path | None = field(default=None, repr=False, compare=False) """ Internal field storing the path to the YAML file this mode was loaded from. Used to support loading modes from arbitrary file paths. """ def _tostring_includes(self) -> list[str]: return ["name"] def print_overview(self) -> None: """Print an overview of the mode.""" print(f"{self.name}:\n {self.description}") if self.excluded_tools: print(" excluded tools:\n " + ", ".join(sorted(self.excluded_tools))) @classmethod def from_yaml(cls, yaml_path: str | Path) -> Self: """Load a mode from a YAML file.""" yaml_as_path = Path(yaml_path).resolve() with Path(yaml_as_path).open(encoding=SERENA_FILE_ENCODING) as f: data = yaml.safe_load(f) name = data.pop("name", yaml_as_path.stem) return cls(name=name, _yaml_path=yaml_as_path, **data) @classmethod def get_path(cls, name: str, instance: Self | None = None) -> str: """Get the path to the YAML file for a mode. :param name: The name of the mode :param instance: Optional mode instance. If provided and it has a stored path, that path is returned. :return: The path to the mode's YAML file """ # If we have an instance with a stored path, use that if instance is not None and instance._yaml_path is not None: return str(instance._yaml_path) fname = f"{name}.yml" custom_mode_path = os.path.join(SerenaPaths().user_modes_dir, fname) if os.path.exists(custom_mode_path): return custom_mode_path own_yaml_path = os.path.join(SERENAS_OWN_MODE_YAMLS_DIR, fname) if not os.path.exists(own_yaml_path): raise FileNotFoundError( f"Mode {name} not found in {SerenaPaths().user_modes_dir} or in {SERENAS_OWN_MODE_YAMLS_DIR}." f"Available modes:\n{cls.list_registered_mode_names()}" ) return own_yaml_path @classmethod def from_name(cls, name: str) -> Self: """Load a registered Serena mode.""" mode_path = cls.get_path(name) return cls.from_yaml(mode_path) @classmethod def from_name_internal(cls, name: str) -> Self: """Loads an internal Serena mode""" yaml_path = os.path.join(INTERNAL_MODE_YAMLS_DIR, f"{name}.yml") if not os.path.exists(yaml_path): raise FileNotFoundError(f"Internal mode '{name}' not found in {INTERNAL_MODE_YAMLS_DIR}") return cls.from_yaml(yaml_path) @classmethod def list_registered_mode_names(cls, include_user_modes: bool = True) -> list[str]: """Names of all registered modes (from the corresponding YAML files in the serena repo).""" modes = [f.stem for f in Path(SERENAS_OWN_MODE_YAMLS_DIR).glob("*.yml") if f.name != "mode.template.yml"] if include_user_modes: modes += cls.list_custom_mode_names() return sorted(set(modes)) @classmethod def list_custom_mode_names(cls) -> list[str]: """Names of all custom modes defined by the user.""" return [f.stem for f in Path(SerenaPaths().user_modes_dir).glob("*.yml")] @classmethod def load(cls, name_or_path: str | Path) -> Self: # Check if it's a file path that exists path = Path(name_or_path) if path.exists() and path.is_file(): return cls.from_yaml(name_or_path) # If it looks like a file path but doesn't exist, raise FileNotFoundError name_or_path_str = str(name_or_path) if os.sep in name_or_path_str or (os.altsep and os.altsep in name_or_path_str) or name_or_path_str.endswith((".yml", ".yaml")): raise FileNotFoundError(f"Mode file not found: {path.resolve()}") return cls.from_name(str(name_or_path)) @dataclass(kw_only=True) class SerenaAgentContext(ToolInclusionDefinition, ToStringMixin): """Represents a context where the agent is operating (an IDE, a chat, etc.), typically read off a YAML file. An agent can only be in a single context at a time. The contexts cannot be changed after the agent is running. """ name: str """the name of the context""" prompt: str """ a Jinja2 template for the generation of the system prompt. It is formatted by the agent (see SerenaAgent._format_prompt()). """ description: str = "" tool_description_overrides: dict[str, str] = field(default_factory=dict) """ maps tool names to custom descriptions, default descriptions are extracted from the tool docstrings. """ _yaml_path: Path | None = field(default=None, repr=False, compare=False) """ Internal field storing the path to the YAML file this context was loaded from. Used to support loading contexts from arbitrary file paths. """ single_project: bool = False """ whether to assume that Serena shall only work on a single project in this context (provided that a project is given when Serena is started). If set to true and a project is provided at startup, the set of tools is limited to those required by the project's concrete configuration, and other tools are excluded completely, allowing the set of tools to be minimal. The `activate_project` tool will, therefore, be disabled in this case, as project switching is not allowed. """ def _tostring_includes(self) -> list[str]: return ["name"] @classmethod def from_yaml(cls, yaml_path: str | Path) -> Self: """Load a context from a YAML file.""" yaml_as_path = Path(yaml_path).resolve() with yaml_as_path.open(encoding=SERENA_FILE_ENCODING) as f: data = yaml.safe_load(f) name = data.pop("name", yaml_as_path.stem) # Ensure backwards compatibility for tool_description_overrides if "tool_description_overrides" not in data: data["tool_description_overrides"] = {} return cls(name=name, _yaml_path=yaml_as_path, **data) @classmethod def get_path(cls, name: str, instance: Self | None = None) -> str: """Get the path to the YAML file for a context. :param name: The name of the context :param instance: Optional context instance. If provided and it has a stored path, that path is returned. :return: The path to the context's YAML file """ # If we have an instance with a stored path, use that if instance is not None and instance._yaml_path is not None: return str(instance._yaml_path) fname = f"{name}.yml" custom_context_path = os.path.join(SerenaPaths().user_contexts_dir, fname) if os.path.exists(custom_context_path): return custom_context_path own_yaml_path = os.path.join(SERENAS_OWN_CONTEXT_YAMLS_DIR, fname) if not os.path.exists(own_yaml_path): raise FileNotFoundError( f"Context {name} not found in {SerenaPaths().user_contexts_dir} or in {SERENAS_OWN_CONTEXT_YAMLS_DIR}." f"Available contexts:\n{cls.list_registered_context_names()}" ) return own_yaml_path @classmethod def from_name(cls, name: str) -> Self: """Load a registered Serena context.""" legacy_name_mapping = { "ide-assistant": "claude-code", } if name in legacy_name_mapping: log.warning( f"Context name '{name}' is deprecated and has been renamed to '{legacy_name_mapping[name]}'. " f"Please update your configuration; refer to the configuration guide for more details: " "https://oraios.github.io/serena/02-usage/050_configuration.html#contexts" ) name = legacy_name_mapping[name] context_path = cls.get_path(name) return cls.from_yaml(context_path) @classmethod def load(cls, name_or_path: str | Path) -> Self: # Check if it's a file path that exists path = Path(name_or_path) if path.exists() and path.is_file(): return cls.from_yaml(name_or_path) # If it looks like a file path but doesn't exist, raise FileNotFoundError name_or_path_str = str(name_or_path) if os.sep in name_or_path_str or (os.altsep and os.altsep in name_or_path_str) or name_or_path_str.endswith((".yml", ".yaml")): raise FileNotFoundError(f"Context file not found: {path.resolve()}") return cls.from_name(str(name_or_path)) @classmethod def list_registered_context_names(cls, include_user_contexts: bool = True) -> list[str]: """Names of all registered contexts (from the corresponding YAML files in the serena repo).""" contexts = [f.stem for f in Path(SERENAS_OWN_CONTEXT_YAMLS_DIR).glob("*.yml")] if include_user_contexts: contexts += cls.list_custom_context_names() return sorted(set(contexts)) @classmethod def list_custom_context_names(cls) -> list[str]: """Names of all custom contexts defined by the user.""" return [f.stem for f in Path(SerenaPaths().user_contexts_dir).glob("*.yml")] @classmethod def load_default(cls) -> Self: """Load the default context.""" return cls.from_name(DEFAULT_CONTEXT) def print_overview(self) -> None: """Print an overview of the mode.""" print(f"{self.name}:\n {self.description}") if self.excluded_tools: print(" excluded tools:\n " + ", ".join(sorted(self.excluded_tools))) ================================================ FILE: src/serena/config/serena_config.py ================================================ """ The Serena Model Context Protocol (MCP) Server """ import dataclasses import os import re import shutil from collections.abc import Iterator, Sequence from copy import deepcopy from dataclasses import dataclass, field from datetime import UTC, datetime from enum import Enum from functools import cached_property from pathlib import Path from typing import TYPE_CHECKING, Any, Optional, Self, TypeVar import yaml from ruamel.yaml.comments import CommentedMap from sensai.util import logging from sensai.util.logging import LogTime, datetime_tag from sensai.util.string import ToStringMixin from serena.constants import ( DEFAULT_SOURCE_FILE_ENCODING, PROJECT_LOCAL_TEMPLATE_FILE, PROJECT_TEMPLATE_FILE, REPO_ROOT, SERENA_CONFIG_TEMPLATE_FILE, SERENA_FILE_ENCODING, SERENA_MANAGED_DIR_NAME, ) from serena.util.inspection import determine_programming_language_composition from serena.util.yaml import YamlCommentNormalisation, load_yaml, normalise_yaml_comments, save_yaml, transfer_missing_yaml_comments from solidlsp.ls_config import Language from ..analytics import RegisteredTokenCountEstimator from ..util.class_decorators import singleton from ..util.cli_util import ask_yes_no from ..util.dataclass import get_dataclass_default if TYPE_CHECKING: from ..project import Project log = logging.getLogger(__name__) T = TypeVar("T") DEFAULT_TOOL_TIMEOUT: float = 240 DictType = dict | CommentedMap TDict = TypeVar("TDict", bound=DictType) @singleton class SerenaPaths: """ Provides paths to various Serena-related directories and files. """ def __init__(self) -> None: home_dir = os.getenv("SERENA_HOME") if home_dir is None or home_dir.strip() == "": home_dir = str(Path.home() / SERENA_MANAGED_DIR_NAME) else: home_dir = home_dir.strip() self.serena_user_home_dir: str = home_dir """ the path to the Serena home directory, where the user's configuration/data is stored. This is ~/.serena by default, but it can be overridden via the SERENA_HOME environment variable. """ self.user_prompt_templates_dir: str = os.path.join(self.serena_user_home_dir, "prompt_templates") """ directory containing prompt templates defined by the user. Prompts defined by the user take precedence over Serena's built-in prompt templates. """ self.user_contexts_dir: str = os.path.join(self.serena_user_home_dir, "contexts") """ directory containing contexts defined by the user. If a name of a context matches a name of a context in SERENAS_OWN_CONTEXT_YAMLS_DIR, the user context will override the default context definition. """ self.user_modes_dir: str = os.path.join(self.serena_user_home_dir, "modes") """ directory containing modes defined by the user. If a name of a mode matches a name of a mode in SERENAS_OWN_MODES_YAML_DIR, the user mode will override the default mode definition. """ self.news_snippet_id_file: str = os.path.join(self.serena_user_home_dir, "last_read_news_snippet_id.txt") """ file containing the ID of the last read news snippet """ global_memories_path = Path(os.path.join(self.serena_user_home_dir, "memories", "global")) global_memories_path.mkdir(parents=True, exist_ok=True) self.global_memories_path = global_memories_path """ directory where global memories are stored, i.e. memories that are available across all projects """ self.last_returned_log_file_path: str | None = None """ the path to the last log file returned by `get_next_log_file_path`. If this is not None, the logs are currently being written to this file """ def get_next_log_file_path(self, prefix: str) -> str: """ :param prefix: the filename prefix indicating the type of the log file :return: the full path to the log file to use """ log_dir = os.path.join(self.serena_user_home_dir, "logs", datetime.now().strftime("%Y-%m-%d")) os.makedirs(log_dir, exist_ok=True) self.last_returned_log_file_path = os.path.join(log_dir, prefix + "_" + datetime_tag() + f"_{os.getpid()}" + ".txt") return self.last_returned_log_file_path # TODO: Paths from constants.py should be moved here @dataclass class ToolInclusionDefinition: """ Defines which tools to include/exclude in Serena's operation. This can mean either * defining exclusions/inclusions to apply to an existing set of tools [incremental mode], or * defining a fixed set of tools to use [fixed mode]. """ excluded_tools: Sequence[str] = () """ the names of tools to exclude from use [incremental mode] """ included_optional_tools: Sequence[str] = () """ the names of optional tools to include [incremental mode] """ fixed_tools: Sequence[str] = () """ the names of tools to use as a fixed set of tools [fixed mode] """ def is_fixed_tool_set(self) -> bool: num_fixed = len(self.fixed_tools) num_incremental = len(self.excluded_tools) + len(self.included_optional_tools) if num_fixed > 0 and num_incremental > 0: raise ValueError("Cannot use both fixed_tools and excluded_tools/included_optional_tools at the same time.") return num_fixed > 0 @dataclass class NamedToolInclusionDefinition(ToolInclusionDefinition): name: str | None = None def __str__(self) -> str: return f"ToolInclusionDefinition[{self.name}]" @dataclass class ModeSelectionDefinition: base_modes: Sequence[str] | None = None default_modes: Sequence[str] | None = None class LanguageBackend(Enum): LSP = "LSP" """ Use the language server protocol (LSP), spawning freely available language servers via the SolidLSP library that is part of Serena """ JETBRAINS = "JetBrains" """ Use the Serena plugin in your JetBrains IDE. (requires the plugin to be installed and the project being worked on to be open in your IDE) """ @staticmethod def from_str(backend_str: str) -> "LanguageBackend": for backend in LanguageBackend: if backend.value.lower() == backend_str.lower(): return backend raise ValueError(f"Unknown language backend '{backend_str}': valid values are {[b.value for b in LanguageBackend]}") def is_lsp(self) -> bool: return self == LanguageBackend.LSP def is_jetbrains(self) -> bool: return self == LanguageBackend.JETBRAINS class LineEnding(Enum): """Line ending convention for file writes.""" LF = "lf" CRLF = "crlf" NATIVE = "native" @property def newline_str(self) -> str | None: """The newline parameter value for :func:`open` and :meth:`Path.write_text`. Returns ``None`` for native mode (platform default). """ if self is LineEnding.LF: return "\n" elif self is LineEnding.CRLF: return "\r\n" return None @classmethod def from_str(cls, value: str) -> "LineEnding": """Parse a string value into a :class:`LineEnding`.""" try: return cls(value.lower()) except ValueError as e: valid = [le.value for le in cls] raise ValueError(f"Invalid line_ending: {value!r}. Valid values are: {valid}") from e @dataclass class SharedConfig(ModeSelectionDefinition, ToolInclusionDefinition, ToStringMixin): """Shared between SerenaConfig and ProjectConfig, the latter used to override values in the form (same as in ModeSelectionDefinition). The defaults here shall be none and should be set to the global default values in SerenaConfig. """ symbol_info_budget: float | None = None language_backend: LanguageBackend | None = None line_ending: LineEnding | None = None read_only_memory_patterns: list[str] = field(default_factory=list) class SerenaConfigError(Exception): pass DEFAULT_PROJECT_SERENA_FOLDER_LOCATION = "$projectDir/" + SERENA_MANAGED_DIR_NAME """ The default template for the project Serena folder location. Uses $projectDir and $projectFolderName as placeholders. """ @dataclass(kw_only=True) class ProjectConfig(SharedConfig): project_name: str languages: list[Language] ignored_paths: list[str] = field(default_factory=list) read_only: bool = False ignore_all_files_in_gitignore: bool = True initial_prompt: str = "" encoding: str = DEFAULT_SOURCE_FILE_ENCODING # internal fields which are not mapped to/from the configuration file (must start with "_") _local_override_keys: list[str] = field(default_factory=list) # class-level constants SERENA_PROJECT_FILE = "project.yml" SERENA_LOCAL_PROJECT_FILE = "project.local.yml" FIELDS_WITHOUT_DEFAULTS = {"project_name", "languages"} YAML_COMMENT_NORMALISATION = YamlCommentNormalisation.LEADING """ the comment normalisation strategy to use when loading/saving project configuration files. The template file must match this configuration (i.e. it must use leading comments if this is set to LEADING). """ def _tostring_includes(self) -> list[str]: return ["project_name"] @classmethod def autogenerate( cls, project_root: str | Path, serena_config: "SerenaConfig", project_name: str | None = None, languages: list[Language] | None = None, save_to_disk: bool = True, interactive: bool = False, ) -> Self: """ Autogenerate a project configuration for a given project root. :param project_root: the path to the project root :param serena_config: the global Serena configuration :param project_name: the name of the project; if None, the name of the project will be the name of the directory containing the project :param languages: the languages of the project; if None, they will be determined automatically :param save_to_disk: whether to save the project configuration to disk :param interactive: whether to run in interactive CLI mode, asking the user for input where appropriate :return: the project configuration """ project_root = Path(project_root).resolve() if not project_root.exists(): raise FileNotFoundError(f"Project root not found: {project_root}") with LogTime("Project configuration auto-generation", logger=log): log.info("Project root: %s", project_root) project_folder_name = project_root.name project_name = project_name or project_folder_name if languages is None: # determine languages automatically log.info("Determining programming languages used in the project") language_composition = determine_programming_language_composition(str(project_root)) log.info("Language composition: %s", language_composition) if len(language_composition) == 0: log.warning( "No source files for supported language servers were found in %s. " "Creating project with no configured languages. " "Symbol-related tools (e.g. find_symbol, get_symbols_overview) will not work " "when using the LSP backend. You can add languages later via the Serena dashboard " "or by manually editing the project configuration.", project_root, ) languages_to_use: list[str] = [] else: # sort languages by number of files found languages_and_percentages = sorted( language_composition.items(), key=lambda item: (item[1], item[0].get_priority()), reverse=True ) # find the language with the highest percentage and enable it top_language_pair = languages_and_percentages[0] other_language_pairs = languages_and_percentages[1:] languages_to_use = [top_language_pair[0].value] # if in interactive mode, ask the user which other languages to enable if len(other_language_pairs) > 0 and interactive: print( "Detected and enabled main language '%s' (%.2f%% of source files)." % (top_language_pair[0].value, top_language_pair[1]) ) print(f"Additionally detected {len(other_language_pairs)} other language(s).\n") print("Note: Enable only languages you need symbolic retrieval/editing capabilities for.") print(" Additional language servers use resources and some languages may require additional") print(" system-level installations/configuration (see Serena documentation).") print("\nWhich additional languages do you want to enable?") for lang, perc in other_language_pairs: enable = ask_yes_no("Enable %s (%.2f%% of source files)?" % (lang.value, perc), default=False) if enable: languages_to_use.append(lang.value) print() log.info("Using languages: %s", languages_to_use) else: languages_to_use = [lang.value for lang in languages] config_with_comments, _ = cls._load_yaml_dict(PROJECT_TEMPLATE_FILE) config_with_comments["project_name"] = project_name config_with_comments["languages"] = languages_to_use if save_to_disk: project_yml_path = serena_config.get_project_yml_location(str(project_root)) log.info("Saving project configuration to %s", project_yml_path) save_yaml(project_yml_path, config_with_comments) project_local_yml_path = os.path.join(os.path.dirname(project_yml_path), cls.SERENA_LOCAL_PROJECT_FILE) shutil.copy(PROJECT_LOCAL_TEMPLATE_FILE, project_local_yml_path) return cls._from_dict(config_with_comments, local_override_keys=[]) @classmethod def default_project_yml_path(cls, project_root: str | Path) -> str: """ :return: the default path to the project.yml file (inside ``$projectDir/.serena/``). This is suitable as a fallback when no ``SerenaConfig`` is available to resolve a potentially customised location. """ return os.path.join(str(project_root), SERENA_MANAGED_DIR_NAME, cls.SERENA_PROJECT_FILE) @classmethod def _load_yaml_dict( cls, yml_path: str, comment_normalisation: YamlCommentNormalisation = YamlCommentNormalisation.NONE, apply_defaults: bool = True, ) -> tuple[CommentedMap, bool]: """ Load the project configuration as a CommentedMap, preserving comments and ensuring completeness of the configuration by applying default values for missing fields and backward compatibility adjustments. :param yml_path: the path to the project.yml file :param comment_normalisation: the strategy to use for normalising comments in the loaded YAML :param apply_defaults: whether to apply default values for missing fields :return: a tuple `(dict, was_complete)` where dict is a CommentedMap representing a full project configuration and `was_complete` indicates whether the loaded configuration was complete (i.e., did not require any default values to be applied) for the case where `apply_defaults` is True; If `apply_defaults` is False, the returned dict may be incomplete and `was_complete` will always be True. """ data = load_yaml(yml_path, comment_normalisation=comment_normalisation) # apply defaults was_complete = True if apply_defaults: for field_info in dataclasses.fields(cls): key = field_info.name if key.startswith("_"): continue if key in cls.FIELDS_WITHOUT_DEFAULTS: continue if key not in data: was_complete = False default_value = get_dataclass_default(cls, key) data.setdefault(key, default_value) # backward compatibility # NOTE: This must also work for project.local.yml files, which may be highly incomplete # * handle single "language" field if "languages" not in data and "language" in data: data["languages"] = [data["language"]] del data["language"] return data, was_complete @classmethod def _from_dict(cls, data: dict[str, Any], local_override_keys: list[str]) -> Self: """ Create a ProjectConfig instance from a (full) configuration dictionary :param data: the configuration dictionary; must contain all required fields and use the same field names as the ProjectConfig dataclass :param local_override_keys: the list of keys that have been overridden from project.local.yml """ lang_name_mapping = {"javascript": "typescript"} languages: list[Language] = [] for language_str in data["languages"]: orig_language_str = language_str try: language_str = language_str.lower() if language_str in lang_name_mapping: language_str = lang_name_mapping[language_str] language = Language(language_str) languages.append(language) except ValueError as e: raise ValueError( f"Invalid language: {orig_language_str}.\nValid language_strings are: {[l.value for l in Language]}" ) from e # Validate symbol_info_budget symbol_info_budget_raw = data["symbol_info_budget"] symbol_info_budget = symbol_info_budget_raw if symbol_info_budget is not None: try: symbol_info_budget = float(symbol_info_budget_raw) except (TypeError, ValueError) as e: raise ValueError(f"symbol_info_budget must be a number or null, got: {symbol_info_budget_raw}") from e if symbol_info_budget < 0: raise ValueError(f"symbol_info_budget cannot be negative, got: {symbol_info_budget}") language_backend_value = data.get("language_backend") language_backend = LanguageBackend.from_str(language_backend_value) if language_backend_value else None line_ending_value = data.get("line_ending") line_ending = LineEnding.from_str(line_ending_value) if line_ending_value else None return cls( project_name=data["project_name"], languages=languages, ignored_paths=data["ignored_paths"], excluded_tools=data["excluded_tools"], fixed_tools=data["fixed_tools"], included_optional_tools=data["included_optional_tools"], read_only=data["read_only"], read_only_memory_patterns=data.get("read_only_memory_patterns", []), ignore_all_files_in_gitignore=data["ignore_all_files_in_gitignore"], initial_prompt=data["initial_prompt"], encoding=data["encoding"], line_ending=line_ending, language_backend=language_backend, base_modes=data["base_modes"], default_modes=data["default_modes"], symbol_info_budget=symbol_info_budget, _local_override_keys=local_override_keys, ) def _to_yaml_dict(self) -> dict: """ :return: a yaml-serializable dictionary representation of this configuration """ d = dataclasses.asdict(self) # drop internal fields starting with underscore keys = list(d.keys()) for k in keys: if k.startswith("_"): del d[k] # map fields using non-primitive types to a YAML-compatible representation d["languages"] = [lang.value for lang in self.languages] d["language_backend"] = self.language_backend.value if self.language_backend is not None else None d["line_ending"] = self.line_ending.value if self.line_ending is not None else None return d @classmethod def _project_local_yml_path(cls, project_yml_path: str) -> str: return os.path.join(os.path.dirname(project_yml_path), cls.SERENA_LOCAL_PROJECT_FILE) @classmethod def load(cls, project_root: Path | str, serena_config: "SerenaConfig", autogenerate: bool = False) -> Self: """ Load a ProjectConfig instance from the path to the project root. :param project_root: the path to the project root :param serena_config: the global Serena configuration :param autogenerate: whether to auto-generate the configuration if it does not exist """ project_root = Path(project_root) project_folder_name = project_root.name yaml_path = serena_config.get_project_yml_location(project_root) log.debug("Loading project configuration from %s", yaml_path) # auto-generate if necessary if not os.path.exists(yaml_path): if autogenerate: return cls.autogenerate(project_root, serena_config) else: raise FileNotFoundError(f"Project configuration file not found: {yaml_path}") # load the configuration dictionary yaml_data, was_complete = cls._load_yaml_dict(str(yaml_path)) if "project_name" not in yaml_data: yaml_data["project_name"] = project_folder_name # apply overrides from project.local.yml, if present local_yaml_path = cls._project_local_yml_path(str(yaml_path)) local_override_keys = [] if os.path.exists(local_yaml_path): local_yaml_data, _ = cls._load_yaml_dict(local_yaml_path, apply_defaults=False) if local_yaml_data: local_override_keys = list(local_yaml_data.keys()) log.debug( "Applying project configuration overrides from %s with keys %s", local_yaml_path, local_override_keys, ) yaml_data.update(local_yaml_data) # instantiate the ProjectConfig project_config = cls._from_dict(yaml_data, local_override_keys=local_override_keys) # if the configuration was incomplete, re-save it to disk if not was_complete: log.info("Project configuration in %s was incomplete, re-saving with default values for missing fields", yaml_path) project_config.save(str(yaml_path), save_project_local_yml=False) return project_config def save(self, project_yml_path: str, save_project_local_yml: bool = True) -> None: """ Saves the project configuration to disk, updating both the project.yml file and, optionally, the project.local.yml file to reflect overridden keys. Keys that are overridden by project.local.yml are not updated in project.yml. Only keys that are overridden are updated in project.local.yml. :param project_yml_path: the path to the project.yml file :param save_project_local_yml: whether to also update the project.local.yml file to reflect overridden keys """ config_path = project_yml_path log.info("Saving updated project configuration to %s", config_path) # get the current configuration as a dictionary cur_dict = self._to_yaml_dict() # load commented map from the original file and update all non-overridden keys config_with_comments, _ = self._load_yaml_dict(config_path, self.YAML_COMMENT_NORMALISATION) for key in cur_dict: if key not in self._local_override_keys: config_with_comments[key] = cur_dict[key] # transfer missing comments from the template file template_config, _ = self._load_yaml_dict(PROJECT_TEMPLATE_FILE, self.YAML_COMMENT_NORMALISATION) transfer_missing_yaml_comments(template_config, config_with_comments, self.YAML_COMMENT_NORMALISATION) # save project.yml save_yaml(config_path, config_with_comments) # update project.local.yml to reflect overridden keys if necessary if save_project_local_yml: project_local_yml_path = self._project_local_yml_path(project_yml_path) if self._local_override_keys and os.path.exists(project_local_yml_path): log.info("Saving updated local project configuration to %s", project_local_yml_path) local_config_with_comments, _ = self._load_yaml_dict( project_local_yml_path, comment_normalisation=YamlCommentNormalisation.NONE, apply_defaults=False ) for key in self._local_override_keys: if key in cur_dict: local_config_with_comments[key] = cur_dict[key] save_yaml(project_local_yml_path, local_config_with_comments) class RegisteredProject(ToStringMixin): def __init__( self, project_root: str, project_config: "ProjectConfig", project_instance: Optional["Project"] = None, ) -> None: """ Represents a registered project in the Serena configuration. :param project_root: the root directory of the project :param project_config: the configuration of the project :param project_instance: an existing project instance (if already loaded) """ self.project_root = Path(project_root).resolve() self.project_config = project_config self._project_instance = project_instance def _tostring_exclude_private(self) -> bool: return True @property def project_name(self) -> str: return self.project_config.project_name @classmethod def from_project_instance(cls, project_instance: "Project") -> "RegisteredProject": return RegisteredProject( project_root=project_instance.project_root, project_config=project_instance.project_config, project_instance=project_instance, ) @classmethod def from_project_root(cls, project_root: str | Path, serena_config: "SerenaConfig") -> "RegisteredProject": project_config = ProjectConfig.load(project_root, serena_config=serena_config) return RegisteredProject( project_root=str(project_root), project_config=project_config, ) def matches_root_path(self, path: str | Path) -> bool: """ Check if the given path matches the project root path. :param path: the path to check :return: True if the path matches the project root, False otherwise """ return self.project_root.samefile(Path(path).resolve()) def get_project_instance(self, serena_config: "SerenaConfig") -> "Project": """ Returns the project instance for this registered project, loading it if necessary. """ if self._project_instance is None: from ..project import Project with LogTime(f"Loading project instance for {self}", logger=log): self._project_instance = Project( project_root=str(self.project_root), project_config=self.project_config, serena_config=serena_config, ) return self._project_instance @dataclass(kw_only=True) class SerenaConfig(SharedConfig): """ Holds the Serena agent configuration, which is typically loaded from a YAML configuration file (when instantiated via :method:`from_config_file`), which is updated when projects are added or removed. For testing purposes, it can also be instantiated directly with the desired parameters. """ # *** fields that are mapped directly to/from the configuration file (DO NOT RENAME) *** projects: list[RegisteredProject] = field(default_factory=list) gui_log_window: bool = False log_level: int = logging.INFO trace_lsp_communication: bool = False web_dashboard: bool = True web_dashboard_open_on_launch: bool = True web_dashboard_listen_address: str = "127.0.0.1" jetbrains_plugin_server_address: str = "127.0.0.1" tool_timeout: float = DEFAULT_TOOL_TIMEOUT token_count_estimator: str = RegisteredTokenCountEstimator.CHAR_COUNT.name """Only relevant if `record_tool_usage` is True; the name of the token count estimator to use for tool usage statistics. See the `RegisteredTokenCountEstimator` enum for available options. Note: some token estimators (like tiktoken) may require downloading data files on the first run, which can take some time and require internet access. Others, like the Anthropic ones, may require an API key and rate limits may apply. """ default_max_tool_answer_chars: int = 150_000 """Used as default for tools where the apply method has a default maximal answer length. Even though the value of the max_answer_chars can be changed when calling the tool, it may make sense to adjust this default through the global configuration. """ ls_specific_settings: dict = field(default_factory=dict) """Advanced configuration option allowing to configure language server implementation specific options, see SolidLSPSettings for more info.""" ignored_paths: list[str] = field(default_factory=list) """List of paths to ignore across all projects. Same syntax as gitignore, so you can use * and **. These patterns are merged additively with each project's own ignored_paths.""" project_serena_folder_location: str = DEFAULT_PROJECT_SERENA_FOLDER_LOCATION """ Template for the location of the per-project .serena data folder (memories, caches, etc.). Supports the following placeholders: - $projectDir: the absolute path to the project root directory - $projectFolderName: the name of the project folder Examples: - "$projectDir/.serena" (default, stores data inside the project) - "/projects-metadata/$projectFolderName/.serena" (stores data in a central location) """ # settings with overridden defaults language_backend: LanguageBackend = LanguageBackend.LSP """ the language backend to use for code understanding features """ default_modes: Sequence[str] | None = ("interactive", "editing") line_ending: LineEnding = LineEnding.NATIVE symbol_info_budget: float = 10.0 """ Time budget (seconds) for requests when tools request include_info (currently only supported for LSP-based tools). If the budget is exceeded, Serena stops issuing further requests and returns partial info results. 0 disables the budget (no early stopping). Negative values are invalid. """ # *** fields that are NOT mapped to/from the configuration file *** _loaded_commented_yaml: CommentedMap | None = None _config_file_path: str | None = None """ the path to the configuration file to which updates of the configuration shall be saved; if None, the configuration is not saved to disk """ # *** static members *** CONFIG_FILE = "serena_config.yml" CONFIG_FIELDS_WITH_TYPE_CONVERSION = {"projects", "language_backend", "line_ending"} # *** methods *** @classmethod def get_config_file_creation_date(cls) -> datetime | None: """ :return: the creation date of the configuration file, or None if the configuration file does not exist """ config_file_path = cls._determine_config_file_path() if not os.path.exists(config_file_path): return None # for unix systems st_ctime is the inode change time (change of metadata), # which is good enough for our purposes creation_timestamp = os.stat(config_file_path).st_ctime return datetime.fromtimestamp(creation_timestamp, UTC) @property def config_file_path(self) -> str | None: return self._config_file_path def _iter_config_file_mapped_fields_without_type_conversion(self) -> Iterator[str]: for field_info in dataclasses.fields(self): field_name = field_info.name if field_name.startswith("_"): continue if field_name in self.CONFIG_FIELDS_WITH_TYPE_CONVERSION: continue yield field_name def _tostring_includes(self) -> list[str]: return ["config_file_path"] @classmethod def _generate_config_file(cls, config_file_path: str) -> None: """ Generates a Serena configuration file at the specified path from the template file. :param config_file_path: the path where the configuration file should be generated """ log.info(f"Auto-generating Serena configuration file in {config_file_path}") loaded_commented_yaml = load_yaml(SERENA_CONFIG_TEMPLATE_FILE) save_yaml(config_file_path, loaded_commented_yaml) @classmethod def _determine_config_file_path(cls) -> str: """ :return: the location where the Serena configuration file is stored/should be stored """ config_path = os.path.join(SerenaPaths().serena_user_home_dir, cls.CONFIG_FILE) # if the config file does not exist, check if we can migrate it from the old location if not os.path.exists(config_path): old_config_path = os.path.join(REPO_ROOT, cls.CONFIG_FILE) if os.path.exists(old_config_path): log.info(f"Moving Serena configuration file from {old_config_path} to {config_path}") os.makedirs(os.path.dirname(config_path), exist_ok=True) shutil.move(old_config_path, config_path) return config_path @classmethod def from_config_file(cls, generate_if_missing: bool = True) -> "SerenaConfig": """ Static constructor to create SerenaConfig from the configuration file """ config_file_path = cls._determine_config_file_path() # create the configuration file from the template if necessary if not os.path.exists(config_file_path): if not generate_if_missing: raise FileNotFoundError(f"Serena configuration file not found: {config_file_path}") log.info(f"Serena configuration file not found at {config_file_path}, autogenerating...") cls._generate_config_file(config_file_path) # load the configuration log.info(f"Loading Serena configuration from {config_file_path}") try: loaded_commented_yaml = load_yaml(config_file_path) except Exception as e: raise ValueError(f"Error loading Serena configuration from {config_file_path}: {e}") from e # create the configuration instance instance = cls(_loaded_commented_yaml=loaded_commented_yaml, _config_file_path=config_file_path) num_migrations = 0 def get_value_or_default(field_name: str) -> Any: nonlocal num_migrations if field_name not in loaded_commented_yaml: num_migrations += 1 return loaded_commented_yaml.get(field_name, get_dataclass_default(SerenaConfig, field_name)) # transfer regular fields that do not require type conversion for field_name in instance._iter_config_file_mapped_fields_without_type_conversion(): assert hasattr(instance, field_name) setattr(instance, field_name, get_value_or_default(field_name)) # read projects if "projects" not in loaded_commented_yaml: raise SerenaConfigError("`projects` key not found in Serena configuration. Please update your `serena_config.yml` file.") instance.projects = [] for path in loaded_commented_yaml["projects"]: path = Path(path).resolve() try: path_exists = path.exists() except OSError as e: log.warning(f"Project path {path} is not accessible ({e}), skipping.") continue if not path_exists or (path.is_dir() and not os.path.isfile(instance.get_project_yml_location(str(path)))): log.warning(f"Project path {path} does not exist or no associated project configuration file found, skipping.") continue if path.is_file(): path = cls._migrate_out_of_project_config_file(path) if path is None: continue num_migrations += 1 project_config = ProjectConfig.load(path, serena_config=instance) # instance is sufficiently populated project = RegisteredProject( project_root=str(path), project_config=project_config, ) instance.projects.append(project) # determine language backend language_backend = get_dataclass_default(SerenaConfig, "language_backend") if "language_backend" in loaded_commented_yaml: backend_str = loaded_commented_yaml["language_backend"] language_backend = LanguageBackend.from_str(backend_str) else: # backward compatibility (migrate Boolean field "jetbrains") if "jetbrains" in loaded_commented_yaml: num_migrations += 1 if loaded_commented_yaml["jetbrains"]: language_backend = LanguageBackend.JETBRAINS del loaded_commented_yaml["jetbrains"] instance.language_backend = language_backend # determine line ending line_ending_value = loaded_commented_yaml.get("line_ending") if line_ending_value: instance.line_ending = LineEnding.from_str(line_ending_value) else: num_migrations += 1 instance.line_ending = get_dataclass_default(SerenaConfig, "line_ending") # migrate deprecated "gui_log_level" field if necessary if "gui_log_level" in loaded_commented_yaml: num_migrations += 1 if "log_level" not in loaded_commented_yaml: instance.log_level = loaded_commented_yaml["gui_log_level"] del loaded_commented_yaml["gui_log_level"] # migrate "edit_global_memories" if "edit_global_memories" in loaded_commented_yaml: num_migrations += 1 edit_global_memories = loaded_commented_yaml["edit_global_memories"] if not edit_global_memories: instance.read_only_memory_patterns.append("global/.*") del loaded_commented_yaml["edit_global_memories"] # re-save the configuration file if any migrations were performed if num_migrations > 0: log.info("Legacy configuration was migrated; re-saving configuration file") instance.save() return instance @classmethod def _migrate_out_of_project_config_file(cls, path: Path) -> Path | None: """ Migrates a legacy project configuration file (which is a YAML file containing the project root) to the in-project configuration file (project.yml) inside the project root directory. :param path: the path to the legacy project configuration file :return: the project root path if the migration was successful, None otherwise. """ log.info(f"Found legacy project configuration file {path}, migrating to in-project configuration.") try: with open(path, encoding=SERENA_FILE_ENCODING) as f: project_config_data = yaml.safe_load(f) if "project_name" not in project_config_data: project_name = path.stem with open(path, "a", encoding=SERENA_FILE_ENCODING) as f: f.write(f"\nproject_name: {project_name}") project_root = project_config_data["project_root"] shutil.move(str(path), ProjectConfig.default_project_yml_path(project_root)) return Path(project_root).resolve() except Exception as e: log.error(f"Error migrating configuration file: {e}") return None @cached_property def project_paths(self) -> list[str]: return sorted(str(project.project_root) for project in self.projects) @cached_property def project_names(self) -> list[str]: return sorted(project.project_config.project_name for project in self.projects) def get_registered_project(self, project_root_or_name: str, autoregister: bool = False) -> Optional[RegisteredProject]: """ :param project_root_or_name: path to the project root or the name of the project :param autoregister: whether to register the project if it exists but is not registered yet :return: the registered project, or None if not found """ # look for project by name project_candidates = [] for project in self.projects: if project.project_config.project_name == project_root_or_name: project_candidates.append(project) if len(project_candidates) == 1: return project_candidates[0] elif len(project_candidates) > 1: raise ValueError( f"Multiple projects found with name '{project_root_or_name}'. Please reference it by location instead. " f"Locations: {[p.project_root for p in project_candidates]}" ) # no project found by name; check if it's a path if os.path.isdir(project_root_or_name): for project in self.projects: if project.matches_root_path(project_root_or_name): return project # no registered project found; auto-register if project configuration exists if autoregister: config_path = self.get_project_yml_location(project_root_or_name) if os.path.isfile(config_path): registered_project = RegisteredProject.from_project_root(project_root_or_name, serena_config=self) self.add_registered_project(registered_project) return registered_project # nothing found return None def get_project(self, project_root_or_name: str) -> Optional["Project"]: registered_project = self.get_registered_project(project_root_or_name) if registered_project is None: return None else: return registered_project.get_project_instance(serena_config=self) def add_registered_project(self, registered_project: RegisteredProject) -> None: """ Adds a registered project, saving the configuration file. """ self.projects.append(registered_project) self.save() def add_project_from_path(self, project_root: Path | str) -> "Project": """ Add a new project to the Serena configuration from a given path, auto-generating the project with defaults if it does not exist. Will raise a FileExistsError if a project already exists at the path. :param project_root: the path to the project to add :return: the project that was added """ from ..project import Project project_root = Path(project_root).resolve() if not project_root.exists(): raise FileNotFoundError(f"Error: Path does not exist: {project_root}") if not project_root.is_dir(): raise FileNotFoundError(f"Error: Path is not a directory: {project_root}") for already_registered_project in self.projects: if str(already_registered_project.project_root) == str(project_root): raise FileExistsError( f"Project with path {project_root} was already added with name '{already_registered_project.project_name}'." ) project_config = ProjectConfig.load(project_root, serena_config=self, autogenerate=True) new_project = Project( project_root=str(project_root), project_config=project_config, is_newly_created=True, serena_config=self, ) self.add_registered_project(RegisteredProject.from_project_instance(new_project)) return new_project def remove_project(self, project_name: str) -> None: # find the index of the project with the desired name and remove it for i, project in enumerate(list(self.projects)): if project.project_name == project_name: del self.projects[i] break else: raise ValueError(f"Project '{project_name}' not found in Serena configuration; valid project names: {self.project_names}") self.save() def save(self) -> None: """ Saves the configuration to the file from which it was loaded (if any) """ if self.config_file_path is None: return assert self._loaded_commented_yaml is not None, "Cannot save configuration without loaded YAML" commented_yaml = deepcopy(self._loaded_commented_yaml) # update fields with current values for field_name in self._iter_config_file_mapped_fields_without_type_conversion(): commented_yaml[field_name] = getattr(self, field_name) # convert project objects into list of paths commented_yaml["projects"] = sorted({str(project.project_root) for project in self.projects}) # convert language backend to string commented_yaml["language_backend"] = self.language_backend.value # convert line ending to string commented_yaml["line_ending"] = self.line_ending.value # transfer comments from the template file # NOTE: The template file now uses leading comments, but we previously used trailing comments, # so we apply a conversion, which detects the old style and transforms it. # For some keys, we force updates, because old comments are problematic/misleading. normalise_yaml_comments(commented_yaml, YamlCommentNormalisation.LEADING_WITH_CONVERSION_FROM_TRAILING) template_yaml = load_yaml(SERENA_CONFIG_TEMPLATE_FILE, comment_normalisation=YamlCommentNormalisation.LEADING) transfer_missing_yaml_comments(template_yaml, commented_yaml, YamlCommentNormalisation.LEADING, forced_update_keys=["projects"]) save_yaml(self.config_file_path, commented_yaml) @staticmethod def _resolve_serena_folder_location(template: str, placeholders: dict[str, str]) -> str: """ Resolves a folder location template by replacing known ``$placeholder`` tokens and raising on any unrecognised ones. :param template: the template string (e.g. ``"$projectDir/.serena"``) :param placeholders: mapping from placeholder name (without ``$``) to replacement value :return: the resolved absolute path :raises SerenaConfigError: if the template contains an unknown ``$placeholder`` """ def _replace(match: re.Match[str]) -> str: name = match.group(1) if name not in placeholders: raise SerenaConfigError( f"Unknown placeholder '${name}' in project_serena_folder_location. " f"Supported placeholders: {', '.join('$' + k for k in placeholders)}" ) return placeholders[name] result = re.sub(r"\$([A-Za-z_]\w*)", _replace, template) return os.path.abspath(result) def get_configured_project_serena_folder(self, project_root: str | Path) -> str: """ Returns the resolved absolute path to the .serena data folder for a project, applying placeholder substitution to ``project_serena_folder_location`` without any fallback logic. :param project_root: the absolute path to the project root directory :return: the resolved absolute path to the project's .serena folder :raises SerenaConfigError: if the template contains an unknown placeholder """ project_folder_name = Path(project_root).name placeholders = { "projectDir": str(project_root), "projectFolderName": project_folder_name, } return self._resolve_serena_folder_location(self.project_serena_folder_location, placeholders) def get_project_serena_folder(self, project_root: str | Path) -> str: """ Resolves the location of the project's .serena data folder using fallback logic: 1. If the folder exists at the configured path (``project_serena_folder_location``), use it. 2. Otherwise, if it exists at the default location inside the project root, use that. 3. If neither exists, return the configured path (for creation). :param project_root: the absolute path to the project root directory :return: the resolved absolute path to the .serena data folder :raises SerenaConfigError: if the configured template contains an unknown placeholder """ configured_path = self.get_configured_project_serena_folder(project_root) if os.path.isdir(configured_path): return configured_path default_path = os.path.join(str(project_root), SERENA_MANAGED_DIR_NAME) if configured_path != default_path and os.path.isdir(default_path): return default_path return configured_path def get_project_yml_location(self, project_root: str | Path) -> str: """ Returns the resolved absolute path to the project.yml configuration file, based on the resolved .serena data folder (with fallback logic). :param project_root: the absolute path to the project root directory :return: the resolved absolute path to the project's project.yml file """ serena_folder = self.get_project_serena_folder(project_root) return os.path.join(serena_folder, ProjectConfig.SERENA_PROJECT_FILE) def propagate_settings(self) -> None: """ Propagate settings from this configuration to individual components that are statically configured """ from serena.tools import JetBrainsPluginClient JetBrainsPluginClient.set_server_address(self.jetbrains_plugin_server_address) ================================================ FILE: src/serena/constants.py ================================================ from pathlib import Path _repo_root_path = Path(__file__).parent.parent.parent.resolve() _serena_pkg_path = Path(__file__).parent.resolve() SERENA_MANAGED_DIR_NAME = ".serena" # TODO: Path-related constants should be moved to SerenaPaths; don't add further constants here. REPO_ROOT = str(_repo_root_path) PROMPT_TEMPLATES_DIR_INTERNAL = str(_serena_pkg_path / "resources" / "config" / "prompt_templates") SERENAS_OWN_CONTEXT_YAMLS_DIR = str(_serena_pkg_path / "resources" / "config" / "contexts") """The contexts that are shipped with the Serena package, i.e. the default contexts.""" SERENAS_OWN_MODE_YAMLS_DIR = str(_serena_pkg_path / "resources" / "config" / "modes") """The modes that are shipped with the Serena package, i.e. the default modes.""" INTERNAL_MODE_YAMLS_DIR = str(_serena_pkg_path / "resources" / "config" / "internal_modes") """Internal modes, never overridden by user modes.""" SERENA_DASHBOARD_DIR = str(_serena_pkg_path / "resources" / "dashboard") SERENA_ICON_DIR = str(_serena_pkg_path / "resources" / "icons") DEFAULT_SOURCE_FILE_ENCODING = "utf-8" """The default encoding assumed for project source files.""" DEFAULT_CONTEXT = "desktop-app" SERENA_FILE_ENCODING = "utf-8" """The encoding used for Serena's own files, such as configuration files and memories.""" PROJECT_TEMPLATE_FILE = str(_serena_pkg_path / "resources" / "project.template.yml") PROJECT_LOCAL_TEMPLATE_FILE = str(_serena_pkg_path / "resources" / "project.local.template.yml") SERENA_CONFIG_TEMPLATE_FILE = str(_serena_pkg_path / "resources" / "serena_config.template.yml") SERENA_LOG_FORMAT = "%(levelname)-5s %(asctime)-15s [%(threadName)s] %(name)s:%(funcName)s:%(lineno)d - %(message)s" LOG_MESSAGES_BUFFER_SIZE = 2500 """The maximum number of log messages to keep in the buffer (for the dashboard).""" ================================================ FILE: src/serena/dashboard.py ================================================ import os import socket import threading from collections.abc import Callable from pathlib import Path from typing import TYPE_CHECKING, Any, Self from flask import Flask, Response, request, send_from_directory from pydantic import BaseModel from sensai.util import logging from serena.analytics import ToolUsageStats from serena.config.serena_config import SerenaConfig, SerenaPaths from serena.constants import SERENA_DASHBOARD_DIR from serena.task_executor import TaskExecutor from serena.util.logging import MemoryLogHandler if TYPE_CHECKING: from serena.agent import SerenaAgent log = logging.getLogger(__name__) # disable Werkzeug's logging to avoid cluttering the output logging.getLogger("werkzeug").setLevel(logging.WARNING) class RequestLog(BaseModel): start_idx: int = 0 class ResponseLog(BaseModel): messages: list[str] max_idx: int active_project: str | None = None class ResponseToolNames(BaseModel): tool_names: list[str] class ResponseToolStats(BaseModel): stats: dict[str, dict[str, int]] class ResponseConfigOverview(BaseModel): active_project: dict[str, str | None] context: dict[str, str] modes: list[dict[str, str]] active_tools: list[str] tool_stats_summary: dict[str, dict[str, int]] registered_projects: list[dict[str, str | bool]] available_tools: list[dict[str, str | bool]] available_modes: list[dict[str, str | bool]] available_contexts: list[dict[str, str | bool]] available_memories: list[str] | None jetbrains_mode: bool languages: list[str] encoding: str | None current_client: str | None class ResponseAvailableLanguages(BaseModel): languages: list[str] class RequestAddLanguage(BaseModel): language: str class RequestRemoveLanguage(BaseModel): language: str class RequestGetMemory(BaseModel): memory_name: str class ResponseGetMemory(BaseModel): content: str memory_name: str class RequestSaveMemory(BaseModel): memory_name: str content: str class RequestDeleteMemory(BaseModel): memory_name: str class RequestRenameMemory(BaseModel): old_name: str new_name: str class ResponseGetSerenaConfig(BaseModel): content: str class RequestSaveSerenaConfig(BaseModel): content: str class RequestCancelTaskExecution(BaseModel): task_id: int class QueuedExecution(BaseModel): task_id: int is_running: bool name: str finished_successfully: bool logged: bool @classmethod def from_task_info(cls, task_info: TaskExecutor.TaskInfo) -> Self: return cls( task_id=task_info.task_id, is_running=task_info.is_running, name=task_info.name, finished_successfully=task_info.finished_successfully(), logged=task_info.logged, ) class SerenaDashboardAPI: log = logging.getLogger(__qualname__) def __init__( self, memory_log_handler: MemoryLogHandler, tool_names: list[str], agent: "SerenaAgent", shutdown_callback: Callable[[], None] | None = None, tool_usage_stats: ToolUsageStats | None = None, ) -> None: self._memory_log_handler = memory_log_handler self._tool_names = tool_names self._agent = agent self._shutdown_callback = shutdown_callback self._app = Flask(__name__) self._tool_usage_stats = tool_usage_stats self._setup_routes() @property def memory_log_handler(self) -> MemoryLogHandler: return self._memory_log_handler def _setup_routes(self) -> None: # Static files @self._app.route("/dashboard/") def serve_dashboard(filename: str) -> Response: return send_from_directory(SERENA_DASHBOARD_DIR, filename) @self._app.route("/dashboard/") def serve_dashboard_index() -> Response: return send_from_directory(SERENA_DASHBOARD_DIR, "index.html") # API routes @self._app.route("/heartbeat", methods=["GET"]) def get_heartbeat() -> dict[str, Any]: return {"status": "alive"} @self._app.route("/get_log_messages", methods=["POST"]) def get_log_messages() -> dict[str, Any]: request_data = request.get_json() if not request_data: request_log = RequestLog() else: request_log = RequestLog.model_validate(request_data) result = self._get_log_messages(request_log) return result.model_dump() @self._app.route("/get_tool_names", methods=["GET"]) def get_tool_names() -> dict[str, Any]: result = self._get_tool_names() return result.model_dump() @self._app.route("/get_tool_stats", methods=["GET"]) def get_tool_stats_route() -> dict[str, Any]: result = self._get_tool_stats() return result.model_dump() @self._app.route("/clear_tool_stats", methods=["POST"]) def clear_tool_stats_route() -> dict[str, str]: self._clear_tool_stats() return {"status": "cleared"} @self._app.route("/clear_logs", methods=["POST"]) def clear_logs() -> dict[str, str]: self._memory_log_handler.clear_log_messages() return {"status": "cleared"} @self._app.route("/get_token_count_estimator_name", methods=["GET"]) def get_token_count_estimator_name() -> dict[str, str]: estimator_name = self._tool_usage_stats.token_estimator_name if self._tool_usage_stats else "unknown" return {"token_count_estimator_name": estimator_name} @self._app.route("/get_config_overview", methods=["GET"]) def get_config_overview() -> dict[str, Any]: result = self._agent.execute_task(self._get_config_overview, logged=False) return result.model_dump() @self._app.route("/shutdown", methods=["PUT"]) def shutdown() -> dict[str, str]: self._shutdown() return {"status": "shutting down"} @self._app.route("/get_available_languages", methods=["GET"]) def get_available_languages() -> dict[str, Any]: result = self._get_available_languages() return result.model_dump() @self._app.route("/add_language", methods=["POST"]) def add_language() -> dict[str, str]: request_data = request.get_json() if not request_data: return {"status": "error", "message": "No data provided"} request_add_language = RequestAddLanguage.model_validate(request_data) try: self._add_language(request_add_language) return {"status": "success", "message": f"Language {request_add_language.language} added successfully"} except Exception as e: return {"status": "error", "message": str(e)} @self._app.route("/remove_language", methods=["POST"]) def remove_language() -> dict[str, str]: request_data = request.get_json() if not request_data: return {"status": "error", "message": "No data provided"} request_remove_language = RequestRemoveLanguage.model_validate(request_data) try: self._remove_language(request_remove_language) return {"status": "success", "message": f"Language {request_remove_language.language} removed successfully"} except Exception as e: return {"status": "error", "message": str(e)} @self._app.route("/get_memory", methods=["POST"]) def get_memory() -> dict[str, Any]: request_data = request.get_json() if not request_data: return {"status": "error", "message": "No data provided"} request_get_memory = RequestGetMemory.model_validate(request_data) try: result = self._get_memory(request_get_memory) return result.model_dump() except Exception as e: return {"status": "error", "message": str(e)} @self._app.route("/save_memory", methods=["POST"]) def save_memory() -> dict[str, str]: request_data = request.get_json() if not request_data: return {"status": "error", "message": "No data provided"} request_save_memory = RequestSaveMemory.model_validate(request_data) try: self._save_memory(request_save_memory) return {"status": "success", "message": f"Memory {request_save_memory.memory_name} saved successfully"} except Exception as e: return {"status": "error", "message": str(e)} @self._app.route("/delete_memory", methods=["POST"]) def delete_memory() -> dict[str, str]: request_data = request.get_json() if not request_data: return {"status": "error", "message": "No data provided"} request_delete_memory = RequestDeleteMemory.model_validate(request_data) try: self._delete_memory(request_delete_memory) return {"status": "success", "message": f"Memory {request_delete_memory.memory_name} deleted successfully"} except Exception as e: return {"status": "error", "message": str(e)} @self._app.route("/rename_memory", methods=["POST"]) def rename_memory() -> dict[str, str]: request_data = request.get_json() if not request_data: return {"status": "error", "message": "No data provided"} request_rename_memory = RequestRenameMemory.model_validate(request_data) try: result_message = self._rename_memory(request_rename_memory) return {"status": "success", "message": result_message} except Exception as e: return {"status": "error", "message": str(e)} @self._app.route("/get_serena_config", methods=["GET"]) def get_serena_config() -> dict[str, Any]: try: result = self._get_serena_config() return result.model_dump() except Exception as e: return {"status": "error", "message": str(e)} @self._app.route("/save_serena_config", methods=["POST"]) def save_serena_config() -> dict[str, str]: request_data = request.get_json() if not request_data: return {"status": "error", "message": "No data provided"} request_save_config = RequestSaveSerenaConfig.model_validate(request_data) try: self._save_serena_config(request_save_config) return {"status": "success", "message": "Serena config saved successfully"} except Exception as e: return {"status": "error", "message": str(e)} @self._app.route("/queued_task_executions", methods=["GET"]) def get_queued_executions() -> dict[str, Any]: try: current_executions = self._agent.get_current_tasks() response = [QueuedExecution.from_task_info(task_info).model_dump() for task_info in current_executions] return {"queued_executions": response, "status": "success"} except Exception as e: return {"status": "error", "message": str(e)} @self._app.route("/cancel_task_execution", methods=["POST"]) def cancel_task_execution() -> dict[str, Any]: request_data = request.get_json() try: request_cancel_task = RequestCancelTaskExecution.model_validate(request_data) for task in self._agent.get_current_tasks(): if task.task_id == request_cancel_task.task_id: task.cancel() return {"status": "success", "was_cancelled": True} return { "status": "success", "was_cancelled": False, "message": f"Task with id {request_data.get('task_id')} not found, maybe execution was already finished", } except Exception as e: return {"status": "error", "message": str(e), "was_cancelled": False} @self._app.route("/last_execution", methods=["GET"]) def get_last_execution() -> dict[str, Any]: try: last_execution_info = self._agent.get_last_executed_task() response = QueuedExecution.from_task_info(last_execution_info).model_dump() if last_execution_info is not None else None return {"last_execution": response, "status": "success"} except Exception as e: return {"status": "error", "message": str(e)} @self._app.route("/news_snippet_ids", methods=["GET"]) def get_news_snippet_ids() -> dict[str, str | list[int]]: def _get_unread_news_ids() -> list[int]: all_news_files = (Path(SERENA_DASHBOARD_DIR) / "news").glob("*.html") all_news_ids = [int(f.stem) for f in all_news_files] """News ids are ints of format YYYYMMDD (publication dates)""" # Filter news items by installation date serena_config_creation_date = SerenaConfig.get_config_file_creation_date() if serena_config_creation_date is None: # should not normally happen, since config file should exist when the dashboard is started # We assume a fresh installation in this case log.error("Serena config file not found when starting the dashboard") return [] serena_config_creation_date_int = int(serena_config_creation_date.strftime("%Y%m%d")) # Only include news items published on or after the installation date post_installation_news_ids = [news_id for news_id in all_news_ids if news_id >= serena_config_creation_date_int] news_snippet_id_file = SerenaPaths().news_snippet_id_file if not os.path.exists(news_snippet_id_file): return post_installation_news_ids with open(news_snippet_id_file, encoding="utf-8") as f: last_read_news_id = int(f.read().strip()) return [news_id for news_id in post_installation_news_ids if news_id > last_read_news_id] try: unread_news_ids = _get_unread_news_ids() return {"news_snippet_ids": unread_news_ids, "status": "success"} except Exception as e: return {"status": "error", "message": str(e)} @self._app.route("/mark_news_snippet_as_read", methods=["POST"]) def mark_news_snippet_as_read() -> dict[str, str]: try: request_data = request.get_json() news_snippet_id = int(request_data.get("news_snippet_id")) news_snippet_id_file = SerenaPaths().news_snippet_id_file with open(news_snippet_id_file, "w", encoding="utf-8") as f: f.write(str(news_snippet_id)) return {"status": "success", "message": f"Marked news snippet {news_snippet_id} as read"} except Exception as e: return {"status": "error", "message": str(e)} def _get_log_messages(self, request_log: RequestLog) -> ResponseLog: messages = self._memory_log_handler.get_log_messages(from_idx=request_log.start_idx) project = self._agent.get_active_project() project_name = project.project_name if project else None return ResponseLog(messages=messages.messages, max_idx=messages.max_idx, active_project=project_name) def _get_tool_names(self) -> ResponseToolNames: return ResponseToolNames(tool_names=self._tool_names) def _get_tool_stats(self) -> ResponseToolStats: if self._tool_usage_stats is not None: return ResponseToolStats(stats=self._tool_usage_stats.get_tool_stats_dict()) else: return ResponseToolStats(stats={}) def _clear_tool_stats(self) -> None: if self._tool_usage_stats is not None: self._tool_usage_stats.clear() def _get_config_overview(self) -> ResponseConfigOverview: from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode from serena.tools.tools_base import Tool # Get active project info project = self._agent.get_active_project() active_project_name = project.project_name if project else None project_info = { "name": active_project_name, "language": ", ".join([l.value for l in project.project_config.languages]) if project else None, "path": str(project.project_root) if project else None, } # Get context info context = self._agent.get_context() context_info = { "name": context.name, "description": context.description, "path": SerenaAgentContext.get_path(context.name, instance=context), } # Get active modes modes = self._agent.get_active_modes() modes_info = [ {"name": mode.name, "description": mode.description, "path": SerenaAgentMode.get_path(mode.name, instance=mode)} for mode in modes ] active_mode_names = [mode.name for mode in modes] # Get active tools active_tools = self._agent.get_active_tool_names() # Get registered projects registered_projects: list[dict[str, str | bool]] = [] for proj in self._agent.serena_config.projects: registered_projects.append( { "name": proj.project_name, "path": str(proj.project_root), "is_active": proj.project_name == active_project_name, } ) # Get all available tools (excluding active ones) all_tool_names = sorted([tool.get_name_from_cls() for tool in self._agent._all_tools.values()]) available_tools: list[dict[str, str | bool]] = [] for tool_name in all_tool_names: if tool_name not in active_tools: available_tools.append( { "name": tool_name, "is_active": False, } ) # Get all available modes all_mode_names = SerenaAgentMode.list_registered_mode_names() available_modes: list[dict[str, str | bool]] = [] for mode_name in all_mode_names: try: mode_path = SerenaAgentMode.get_path(mode_name) except FileNotFoundError: # Skip modes that can't be found (shouldn't happen for registered modes) continue available_modes.append( { "name": mode_name, "is_active": mode_name in active_mode_names, "path": mode_path, } ) # Get all available contexts all_context_names = SerenaAgentContext.list_registered_context_names() available_contexts: list[dict[str, str | bool]] = [] for context_name in all_context_names: try: context_path = SerenaAgentContext.get_path(context_name) except FileNotFoundError: # Skip contexts that can't be found (shouldn't happen for registered contexts) continue available_contexts.append( { "name": context_name, "is_active": context_name == context.name, "path": context_path, } ) # Get basic tool stats (just num_calls for overview) tool_stats_summary = {} if self._tool_usage_stats is not None: full_stats = self._tool_usage_stats.get_tool_stats_dict() tool_stats_summary = {name: {"num_calls": stats["num_times_called"]} for name, stats in full_stats.items()} # Get available memories if ReadMemoryTool is active available_memories = None if self._agent.tool_is_active("read_memory") and project is not None: available_memories = project.memories_manager.list_memories().get_full_list() # Get list of languages for the active project languages = [] if project is not None: languages = [lang.value for lang in project.project_config.languages] # Get file encoding for the active project encoding = None if project is not None: encoding = project.project_config.encoding return ResponseConfigOverview( active_project=project_info, context=context_info, modes=modes_info, active_tools=active_tools, tool_stats_summary=tool_stats_summary, registered_projects=registered_projects, available_tools=available_tools, available_modes=available_modes, available_contexts=available_contexts, available_memories=available_memories, jetbrains_mode=self._agent.get_language_backend().is_jetbrains(), languages=languages, encoding=encoding, current_client=Tool.get_last_tool_call_client_str(), ) def _shutdown(self) -> None: log.info("Shutting down Serena") if self._shutdown_callback: self._shutdown_callback() else: # noinspection PyProtectedMember # noinspection PyUnresolvedReferences os._exit(0) def _get_available_languages(self) -> ResponseAvailableLanguages: from solidlsp.ls_config import Language def run() -> ResponseAvailableLanguages: all_languages = [lang.value for lang in Language.iter_all(include_experimental=False)] # Filter out already added languages for the active project project = self._agent.get_active_project() if project: current_languages = [lang.value for lang in project.project_config.languages] available_languages = [lang for lang in all_languages if lang not in current_languages] else: available_languages = all_languages return ResponseAvailableLanguages(languages=sorted(available_languages)) return self._agent.execute_task(run, logged=False) def _get_memory(self, request_get_memory: RequestGetMemory) -> ResponseGetMemory: def run() -> ResponseGetMemory: project = self._agent.get_active_project() if project is None: raise ValueError("No active project") content = project.memories_manager.load_memory(request_get_memory.memory_name) return ResponseGetMemory(content=content, memory_name=request_get_memory.memory_name) return self._agent.execute_task(run, logged=False) def _save_memory(self, request_save_memory: RequestSaveMemory) -> None: def run() -> None: project = self._agent.get_active_project() if project is None: raise ValueError("No active project") project.memories_manager.save_memory(request_save_memory.memory_name, request_save_memory.content, is_tool_context=False) self._agent.execute_task(run, logged=True, name="SaveMemory") def _delete_memory(self, request_delete_memory: RequestDeleteMemory) -> None: def run() -> None: project = self._agent.get_active_project() if project is None: raise ValueError("No active project") project.memories_manager.delete_memory(request_delete_memory.memory_name, is_tool_context=False) self._agent.execute_task(run, logged=True, name="DeleteMemory") def _rename_memory(self, request_rename_memory: RequestRenameMemory) -> str: def run() -> str: project = self._agent.get_active_project() if project is None: raise ValueError("No active project") return project.memories_manager.move_memory( request_rename_memory.old_name, request_rename_memory.new_name, is_tool_context=False ) return self._agent.execute_task(run, logged=True, name="RenameMemory") def _get_serena_config(self) -> ResponseGetSerenaConfig: config_path = self._agent.serena_config.config_file_path if config_path is None or not os.path.exists(config_path): raise ValueError("Serena config file not found") with open(config_path, encoding="utf-8") as f: content = f.read() return ResponseGetSerenaConfig(content=content) def _save_serena_config(self, request_save_config: RequestSaveSerenaConfig) -> None: def run() -> None: config_path = self._agent.serena_config.config_file_path if config_path is None: raise ValueError("Serena config file path not set") with open(config_path, "w", encoding="utf-8") as f: f.write(request_save_config.content) self._agent.execute_task(run, logged=True, name="SaveSerenaConfig") def _add_language(self, request_add_language: RequestAddLanguage) -> None: from solidlsp.ls_config import Language try: language = Language(request_add_language.language) except ValueError: raise ValueError(f"Invalid language: {request_add_language.language}") # add_language is already thread-safe self._agent.add_language(language) def _remove_language(self, request_remove_language: RequestRemoveLanguage) -> None: from solidlsp.ls_config import Language try: language = Language(request_remove_language.language) except ValueError: raise ValueError(f"Invalid language: {request_remove_language.language}") # remove_language is already thread-safe self._agent.remove_language(language) @staticmethod def _find_first_free_port(start_port: int, host: str) -> int: port = start_port while port <= 65535: try: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.bind((host, port)) return port except OSError: port += 1 raise RuntimeError(f"No free ports found starting from {start_port}") def run(self, host: str, port: int) -> int: """ Runs the dashboard on the given host and port and returns the port number. """ # patch flask.cli.show_server to avoid printing the server info from flask import cli cli.show_server_banner = lambda *args, **kwargs: None self._app.run(host=host, port=port, debug=False, use_reloader=False, threaded=True) return port def run_in_thread(self, host: str) -> tuple[threading.Thread, int]: port = self._find_first_free_port(0x5EDA, host) log.info("Starting dashboard (listen_address=%s, port=%d)", host, port) thread = threading.Thread(target=lambda: self.run(host=host, port=port), daemon=True) thread.start() return thread, port ================================================ FILE: src/serena/generated/generated_prompt_factory.py ================================================ # ruff: noqa # black: skip # mypy: ignore-errors # NOTE: This module is auto-generated from interprompt.autogenerate_prompt_factory_module, do not edit manually! from interprompt.multilang_prompt import PromptList from interprompt.prompt_factory import PromptFactoryBase from typing import Any class PromptFactory(PromptFactoryBase): """ A class for retrieving and rendering prompt templates and prompt lists. """ def create_onboarding_prompt(self, *, system: Any) -> str: return self._render_prompt("onboarding_prompt", locals()) def create_think_about_collected_information(self) -> str: return self._render_prompt("think_about_collected_information", locals()) def create_think_about_task_adherence(self) -> str: return self._render_prompt("think_about_task_adherence", locals()) def create_think_about_whether_you_are_done(self) -> str: return self._render_prompt("think_about_whether_you_are_done", locals()) def create_summarize_changes(self) -> str: return self._render_prompt("summarize_changes", locals()) def create_prepare_for_new_conversation(self) -> str: return self._render_prompt("prepare_for_new_conversation", locals()) def create_system_prompt( self, *, available_markers: Any, available_tools: Any, context_system_prompt: Any, global_memories_list: Any, mode_system_prompts: Any, ) -> str: return self._render_prompt("system_prompt", locals()) ================================================ FILE: src/serena/gui_log_viewer.py ================================================ # mypy: ignore-errors import logging import os import queue import sys import threading import tkinter as tk import traceback from enum import Enum, auto from pathlib import Path from typing import Literal from serena import constants from serena.util.logging import MemoryLogHandler log = logging.getLogger(__name__) class LogLevel(Enum): DEBUG = auto() INFO = auto() WARNING = auto() ERROR = auto() DEFAULT = auto() class GuiLogViewer: """ A class that creates a Tkinter GUI for displaying log messages in a separate thread. The log viewer supports coloring based on log levels (DEBUG, INFO, WARNING, ERROR). It can also highlight tool names in boldface when they appear in log messages. """ def __init__( self, mode: Literal["dashboard", "error"], title="Log Viewer", memory_log_handler: MemoryLogHandler | None = None, width=800, height=600, ): """ :param mode: the mode; if "dashboard", run a dashboard with logs and some control options; if "error", run a simple error log viewer (for fatal exceptions) :param title: the window title :param memory_log_handler: an optional log handler from which to obtain log messages; If not provided, must pass the instance to a `GuiLogViewerHandler` to add log messages. :param width: the initial window width :param height: the initial window height """ self.mode = mode self.title = title self.width = width self.height = height self.message_queue = queue.Queue() self.running = False self.log_thread = None self.menubar: tk.Menu | None = None self.tool_names = [] # List to store tool names for highlighting # Define colors for different log levels self.log_colors = { LogLevel.DEBUG: "#808080", # Gray LogLevel.INFO: "#000000", # Black LogLevel.WARNING: "#FF8C00", # Dark Orange LogLevel.ERROR: "#FF0000", # Red LogLevel.DEFAULT: "#000000", # Black } if memory_log_handler is not None: for msg in memory_log_handler.get_log_messages().messages: self.message_queue.put(msg) memory_log_handler.add_emit_callback(lambda msg: self.message_queue.put(msg)) def start(self): """Start the log viewer in a separate thread.""" if not self.running: self.log_thread = threading.Thread(target=self.run_gui) self.log_thread.daemon = True self.log_thread.start() return True return False def stop(self): """Stop the log viewer.""" if self.running: # Add a sentinel value to the queue to signal the GUI to exit self.message_queue.put(None) return True return False def set_tool_names(self, tool_names): """ Set or update the list of tool names to be highlighted in log messages. Args: tool_names (list): A list of tool name strings to highlight """ self.tool_names = tool_names def set_dashboard_url(self, url: str) -> None: def copy_url(): self.root.clipboard_clear() self.root.clipboard_append(url) log.info(f"Copied dashboard URL to clipboard: {url}") if self.menubar is not None: dashboard_menu = tk.Menu(self.menubar, tearoff=0) dashboard_menu.add_command(label="Copy URL", command=copy_url) # type: ignore self.menubar.add_cascade(label="Dashboard", menu=dashboard_menu) def add_log(self, message): """ Add a log message to the viewer. Args: message (str): The log message to display """ self.message_queue.put(message) def _determine_log_level(self, message): """ Determine the log level from the message. Args: message (str): The log message Returns: LogLevel: The determined log level """ message_upper = message.upper() if message_upper.startswith("DEBUG"): return LogLevel.DEBUG elif message_upper.startswith("INFO"): return LogLevel.INFO elif message_upper.startswith("WARNING"): return LogLevel.WARNING elif message_upper.startswith("ERROR"): return LogLevel.ERROR else: return LogLevel.DEFAULT def _process_queue(self): """Process messages from the queue and update the text widget.""" try: while not self.message_queue.empty(): message = self.message_queue.get_nowait() # Check for sentinel value to exit if message is None: self.root.quit() return # Check if scrollbar is at the bottom before adding new text # Get current scroll position current_position = self.text_widget.yview() # If near the bottom (allowing for small floating point differences) was_at_bottom = current_position[1] > 0.99 log_level = self._determine_log_level(message) # Insert the message at the end of the text with appropriate log level tag self.text_widget.configure(state=tk.NORMAL) # Find tool names in the message and highlight them if self.tool_names: # Capture start position (before insertion) start_index = self.text_widget.index("end-1c") # Insert the message self.text_widget.insert(tk.END, message + "\n", log_level.name) # Convert start index to line/char format line, char = map(int, start_index.split(".")) # Search for tool names in the message string directly for tool_name in self.tool_names: start_offset = 0 while True: found_at = message.find(tool_name, start_offset) if found_at == -1: break # Calculate line/column from offset offset_line = line offset_char = char for c in message[:found_at]: if c == "\n": offset_line += 1 offset_char = 0 else: offset_char += 1 # Construct index positions start_pos = f"{offset_line}.{offset_char}" end_pos = f"{offset_line}.{offset_char + len(tool_name)}" # Add tag to highlight the tool name self.text_widget.tag_add("TOOL_NAME", start_pos, end_pos) start_offset = found_at + len(tool_name) else: # No tool names to highlight, just insert the message self.text_widget.insert(tk.END, message + "\n", log_level.name) self.text_widget.configure(state=tk.DISABLED) # Auto-scroll to the bottom only if it was already at the bottom if was_at_bottom: self.text_widget.see(tk.END) # Schedule to check the queue again if self.running: self.root.after(100, self._process_queue) except Exception as e: print(f"Error processing message queue: {e}", file=sys.stderr) if self.running: self.root.after(100, self._process_queue) def run_gui(self): """Run the GUI""" self.running = True try: # Set app id (avoid app being lumped together with other Python-based apps in Windows taskbar) if sys.platform == "win32": import ctypes ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("oraios.serena") self.root = tk.Tk() self.root.title(self.title) self.root.geometry(f"{self.width}x{self.height}") # Make the window resizable self.root.columnconfigure(0, weight=1) # We now have two rows - one for logo and one for text self.root.rowconfigure(0, weight=0) # Logo row self.root.rowconfigure(1, weight=1) # Text content row dashboard_path = Path(constants.SERENA_DASHBOARD_DIR) # Load and display the logo image try: # construct path relative to path of this file image_path = dashboard_path / "serena-logs.png" self.logo_image = tk.PhotoImage(file=image_path) # Create a label to display the logo self.logo_label = tk.Label(self.root, image=self.logo_image) self.logo_label.grid(row=0, column=0, sticky="ew") except Exception as e: print(f"Error loading logo image: {e}", file=sys.stderr) # Create frame to hold text widget and scrollbars frame = tk.Frame(self.root) frame.grid(row=1, column=0, sticky="nsew") frame.columnconfigure(0, weight=1) frame.rowconfigure(0, weight=1) # Create horizontal scrollbar h_scrollbar = tk.Scrollbar(frame, orient=tk.HORIZONTAL) h_scrollbar.grid(row=1, column=0, sticky="ew") # Create vertical scrollbar v_scrollbar = tk.Scrollbar(frame, orient=tk.VERTICAL) v_scrollbar.grid(row=0, column=1, sticky="ns") # Create text widget with horizontal scrolling self.text_widget = tk.Text( frame, wrap=tk.NONE, width=self.width, height=self.height, xscrollcommand=h_scrollbar.set, yscrollcommand=v_scrollbar.set ) self.text_widget.grid(row=0, column=0, sticky="nsew") self.text_widget.configure(state=tk.DISABLED) # Make it read-only # Configure scrollbars h_scrollbar.config(command=self.text_widget.xview) v_scrollbar.config(command=self.text_widget.yview) # Configure tags for different log levels with appropriate colors for level, color in self.log_colors.items(): self.text_widget.tag_configure(level.name, foreground=color) # Configure tag for tool names self.text_widget.tag_configure("TOOL_NAME", background="#ffff00") # Set up the queue processing self.root.after(100, self._process_queue) # Handle window close event depending on mode if self.mode == "dashboard": self.root.protocol("WM_DELETE_WINDOW", lambda: self.root.iconify()) else: self.root.protocol("WM_DELETE_WINDOW", self.stop) # Create menu bar if self.mode == "dashboard": self.menubar = tk.Menu(self.root) server_menu = tk.Menu(self.menubar, tearoff=0) server_menu.add_command(label="Shutdown", command=self._shutdown_server) # type: ignore self.menubar.add_cascade(label="Server", menu=server_menu) self.root.config(menu=self.menubar) # Configure icons icon_16 = tk.PhotoImage(file=dashboard_path / "serena-icon-16.png") icon_32 = tk.PhotoImage(file=dashboard_path / "serena-icon-32.png") icon_48 = tk.PhotoImage(file=dashboard_path / "serena-icon-48.png") self.root.iconphoto(False, icon_48, icon_32, icon_16) # Start the Tkinter event loop self.root.mainloop() except Exception as e: print(f"Error in GUI thread: {e}", file=sys.stderr) finally: self.running = False def _shutdown_server(self) -> None: log.info("Shutting down Serena") # noinspection PyUnresolvedReferences # noinspection PyProtectedMember os._exit(0) class GuiLogViewerHandler(logging.Handler): """ A logging handler that sends log records to a ThreadedLogViewer instance. This handler can be integrated with Python's standard logging module to direct log entries to a GUI log viewer. """ def __init__( self, log_viewer: GuiLogViewer, level=logging.NOTSET, format_string: str | None = "%(levelname)-5s %(asctime)-15s %(name)s:%(funcName)s:%(lineno)d - %(message)s", ): """ Initialize the handler with a ThreadedLogViewer instance. Args: log_viewer: A ThreadedLogViewer instance that will display the logs level: The logging level (default: NOTSET which captures all logs) format_string: the format string """ super().__init__(level) self.log_viewer = log_viewer self.formatter = logging.Formatter(format_string) # Start the log viewer if it's not already running if not self.log_viewer.running: self.log_viewer.start() @classmethod def is_instance_registered(cls) -> bool: for h in logging.Logger.root.handlers: if isinstance(h, cls): return True return False def emit(self, record): """ Emit a log record to the ThreadedLogViewer. Args: record: The log record to emit """ try: # Format the record according to the formatter msg = self.format(record) # Convert the level name to a standard format for the viewer level_prefix = record.levelname # Add the appropriate prefix if it's not already there if not msg.startswith(level_prefix): msg = f"{level_prefix}: {msg}" self.log_viewer.add_log(msg) except Exception: self.handleError(record) def close(self): """ Close the handler and optionally stop the log viewer. """ # We don't automatically stop the log viewer here as it might # be used by other handlers or directly by the application super().close() def stop_viewer(self): """ Explicitly stop the associated log viewer. """ if self.log_viewer.running: self.log_viewer.stop() def show_fatal_exception(e: Exception): """ Makes sure the given exception is shown in the GUI log viewer, either an existing instance or a new one. :param e: the exception to display """ # show in new window in main thread (user must close it) log_viewer = GuiLogViewer("error") exc_info = "".join(traceback.format_exception(type(e), e, e.__traceback__)) log_viewer.add_log(f"ERROR Fatal exception: {e}\n{exc_info}") log_viewer.run_gui() ================================================ FILE: src/serena/jetbrains/jetbrains_plugin_client.py ================================================ """ Client for the Serena JetBrains Plugin """ import concurrent import json import logging import re import threading from concurrent.futures.thread import ThreadPoolExecutor from dataclasses import dataclass from pathlib import Path from typing import Any, Literal, Optional, Self, TypeVar, cast import requests from requests import Response from sensai.util.string import ToStringMixin import serena.jetbrains.jetbrains_types as jb from serena.config.serena_config import RegisteredProject from serena.jetbrains.jetbrains_types import PluginStatusDTO from serena.project import Project from serena.util.class_decorators import singleton from serena.util.text_utils import render_html from serena.util.version import Version T = TypeVar("T") log = logging.getLogger(__name__) class SerenaClientError(Exception): """Base exception for Serena client errors.""" class ConnectionError(SerenaClientError): """Raised when connection to the service fails.""" class APIError(SerenaClientError): """Raised when the API returns an error response that shall be reported on the caller/LLM.""" def __init__(self, response: Response): try: error_data = response.json() message = error_data.get("error", response.text) except: message = response.text super().__init__(message) class PluginServerError(SerenaClientError): """Raised when the plugin's service encounters an internal error (which may need to be reported).""" def __init__(self, message: str): unrecoverable_error_prefix = ( "The request failed due to an error on the Serena JetBrains plugin side. This may be caused " "by a bug or an incompatibility in the plugin. In that case, please report the error below together with " "your setup (IDE + version, Serena plugin version, programming language) at https://github.com/oraios/serena/issues " "(if you are an LLM, tell the user about this). The Oraios team will then provide support on this.\n" ) super().__init__(unrecoverable_error_prefix + message) class ServerNotFoundError(Exception): """Raised when the plugin's service is not found.""" @dataclass class MatchedClient: client: "JetBrainsPluginClient" registered_project: RegisteredProject @singleton class JetBrainsPluginClientManager: """ Manager for JetBrainsPluginClient instances, responsible for scanning ports to find available plugin instances """ NUM_PORTS_TO_SCAN = 20 def __init__(self) -> None: self._clients: dict[int, "JetBrainsPluginClient"] = {} self._matched_clients: list[MatchedClient] = [] self._lock = threading.Lock() def _submit_scan(self) -> list[concurrent.futures.Future["JetBrainsPluginClient"]]: """ Performs a port scan to find available plugin instances in parallel. :return: futures that will resolve to plugin clients for every port """ def scan_port(port: int) -> JetBrainsPluginClient: client = JetBrainsPluginClient(port) with self._lock: self._clients[port] = client return client futures = [] with ThreadPoolExecutor(max_workers=self.NUM_PORTS_TO_SCAN) as executor: for i in range(self.NUM_PORTS_TO_SCAN): future = executor.submit(scan_port, JetBrainsPluginClient.BASE_PORT + i) futures.append(future) return futures def find_client(self, project_root: Path) -> "JetBrainsPluginClient": plugin_paths_found = [] for future in self._submit_scan(): client = future.result() if client.matches(project_root): return client elif client.project_root is not None: plugin_paths_found.append(client.project_root) log.warning( "Searched for Serena JetBrains plugin service for project at %s but found no matching service. " "Found plugin instances for the following project paths: %s", project_root, plugin_paths_found, ) raise ServerNotFoundError( f"Found no Serena service in a JetBrains IDE instance for the project at {project_root}. " "STOP. Do not attempt any other tools or workarounds. Ask the user to open this folder as a project in a JetBrains IDE " "with the Serena plugin installed and running!" ) def match_clients(self, registered_projects: list[RegisteredProject]) -> list[MatchedClient]: """ Scans for plugin instances and matches them against the given registered projects. :param registered_projects: the list of registered projects to match plugin instances against :return: the list of matched clients with their corresponding registered project """ matched_clients = [] for future in self._submit_scan(): client = future.result() if client.project_root is not None: for rp in registered_projects: if client.matches(Path(rp.project_root)): matched_clients.append(MatchedClient(client, rp)) break self._matched_clients = matched_clients return matched_clients def get_matched_client( self, registered_project: RegisteredProject, registered_projects: list[RegisteredProject] ) -> Optional["JetBrainsPluginClient"]: """ Gets the matched client for a given registered project, if any. :param registered_project: the registered project to get the matched client for :param registered_projects: the list of all registered projects (used to perform matching of all clients if no match is found for the given project) :return: the matched client or None if no match is found """ def find_match() -> Optional["JetBrainsPluginClient"]: for matched_client in self._matched_clients: if matched_client.registered_project.project_root == registered_project.project_root: return matched_client.client return None match = find_match() if match is None: self.match_clients(registered_projects) return find_match() class JetBrainsPluginClient(ToStringMixin): """ Python client for the Serena Backend Service. Provides simple methods to interact with all available endpoints. """ BASE_PORT = 0x5EA2 PLUGIN_REQUEST_TIMEOUT = 300 """ the timeout used for request handling within the plugin (a constant in the plugin) """ _last_port: int | None = None """ the last port that was successfully used to connect to a plugin instance in the current session """ _server_address: str = "127.0.0.1" """ the server address where to connect to the plugin service """ def __init__(self, port: int, timeout: int = PLUGIN_REQUEST_TIMEOUT): self._port = port self._timeout = timeout self._session = requests.Session() self._session.headers.update({"Content-Type": "application/json", "Accept": "application/json"}) # connect and obtain status self.project_root: str | None = None self._plugin_version: Version | None = None try: status_response: PluginStatusDTO = cast(jb.PluginStatusDTO, self._make_request("GET", "/status")) self.project_root = status_response["project_root"] self._plugin_version = Version(status_response["plugin_version"]) except ConnectionError: # expected if no server is running at the port pass except Exception as e: log.warning("Failed to obtain status from JetBrains plugin service at port %d: %s", port, e, exc_info=e) @property def _base_url(self) -> str: return f"http://{self._server_address}:{self._port}" @classmethod def set_server_address(cls, address: str) -> None: cls._server_address = address def _tostring_includes(self) -> list[str]: return ["_port", "project_root", "_plugin_version"] @classmethod def from_project(cls, project: Project) -> Self: resolved_path = Path(project.project_root).resolve() if cls._last_port is not None: client = JetBrainsPluginClient(cls._last_port) if client.matches(resolved_path): return client client = JetBrainsPluginClientManager().find_client(resolved_path) cls._last_port = client._port return client @staticmethod def _paths_match(resolved_serena_path: str, plugin_path: str) -> bool: """ Checks whether the resolved Serena path matches the plugin path, accounting for possible prefixes in the plugin path, different file system perspectives, and case sensitivity. Concrete aspects considered: - The plugin path may contain prefixes: - The plugin path may be a WSL UNC path, e.g. `//wsl.localhost/Ubuntu-24.04/home/user/project` or `//wsl$/Ubuntu/home/user/project` while Serena will just have `/home/user/project` - Other prefixes like `/workspaces/serena/C:/Users/user/projects/my-app` - One path may use a different file system perspective (particularly WSL vs Windows-native) but still point to the same location, e.g. `/mnt/c/` vs `C:/` - Case sensitivity :param resolved_serena_path: The resolved project root path from Serena's perspective :param plugin_path: The project root path reported by the plugin (which may be a WSL UNC path) :return: True if the paths match, False otherwise """ # try to resolve the plugin path, checking for a direct match # (this is robust against symlinks as long as there are no prefixes) try: resolved_plugin_path = str(Path(plugin_path).resolve()) if resolved_plugin_path == resolved_serena_path: return True except: pass def normalise_wsl_mnt(path_str: str) -> str: # normalise WSL /mnt/c/ to c:/ for comparison return re.sub(r"/mnt/([a-z])/", r"\1:/", path_str, flags=re.IGNORECASE) # standardise paths for comparison: normalise WSL /mnt/ to Windows paths and ignore case std_serena_path = normalise_wsl_mnt(str(resolved_serena_path)).lower() std_plugin_path = normalise_wsl_mnt(str(plugin_path)).lower() # At this point, the plugin path may still contain prefixes, so we check if the Serena path is a suffix of the plugin path return std_plugin_path.endswith(std_serena_path) def matches(self, resolved_path: Path) -> bool: """ :param resolved_path: the resolved project root path from Serena's perspective :return: whether this client instance matches the given project path """ if self.project_root is None: return False return self._paths_match(str(resolved_path), self.project_root) def is_version_at_least(self, *version_parts: int) -> bool: if self._plugin_version is None: return False return self._plugin_version.is_at_least(*version_parts) def _require_version_at_least(self, *version_parts: int) -> None: """ Ensures that the plugin version is at least the given version and raises an error otherwise. :param version_parts: the minimum required version parts (major, minor, patch) """ if not self.is_version_at_least(*version_parts): raise SerenaClientError( f"This operation requires Serena JetBrains plugin version " f"{'.'.join(map(str, version_parts))} or higher, but the installed version is " f"{self._plugin_version}. Ask the user to update the plugin!" ) def _make_request(self, method: str, endpoint: str, data: Optional[dict] = None) -> dict[str, Any]: url = f"{self._base_url}{endpoint}" response: Response | None = None try: if method.upper() == "GET": response = self._session.get(url, timeout=self._timeout) elif method.upper() == "POST": json_data = json.dumps(data) if data else None response = self._session.post(url, data=json_data, timeout=self._timeout) else: raise ValueError(f"Unsupported HTTP method: {method}") response.raise_for_status() # Try to parse JSON response try: return self._pythonify_response(response.json()) except json.JSONDecodeError: # If response is not JSON, return raw text return {"response": response.text} except requests.exceptions.ConnectionError as e: raise ConnectionError(f"Failed to connect to Serena service at {url}: {e}") except requests.exceptions.Timeout as e: raise ConnectionError(f"Request to {url} timed out: {e}") except requests.exceptions.HTTPError as e: if response is not None: # check for recoverable error (i.e. errors where the problem can be resolved by the caller or # other errors where the error text shall simply be passed on to the LLM). # The plugin returns 400 for such errors (typically illegal arguments, e.g. non-unique name path) # but only since version 2023.2.6 if self.is_version_at_least(2023, 2, 6): is_recoverable_error = response.status_code == 400 else: is_recoverable_error = True # assume recoverable for older versions (mix of errors) if is_recoverable_error: raise APIError(response) raise PluginServerError(f"API request failed with status {response.status_code}: {response.text}") raise PluginServerError(f"API request failed with HTTP error: {e}") except requests.exceptions.RequestException as e: raise SerenaClientError(f"Request failed: {e}") @staticmethod def _pythonify_response(response: T) -> T: """ Converts dictionary keys from camelCase to snake_case recursively. :response: the response in which to convert keys (dictionary or list) """ to_snake_case = lambda s: "".join(["_" + c.lower() if c.isupper() else c for c in s]) def convert(x): # type: ignore if isinstance(x, dict): return {to_snake_case(k): convert(v) for k, v in x.items()} elif isinstance(x, list): return [convert(item) for item in x] else: return x return convert(response) def _postprocess_symbol_collection_response(self, response_dict: jb.SymbolCollectionResponse) -> None: """ Postprocesses a symbol collection response in-place, converting HTML documentation to plain text. :param response_dict: the response dictionary """ def convert_html(key: Literal["documentation", "quick_info"], symbol: jb.SymbolDTO) -> None: if key in symbol: doc_html: str = symbol[key] doc_text = render_html(doc_html) if doc_text: symbol[key] = doc_text else: del symbol[key] def convert_symbol_list(l: list) -> None: for s in l: convert_html("documentation", s) convert_html("quick_info", s) if "children" in s: convert_symbol_list(s["children"]) convert_symbol_list(response_dict["symbols"]) def find_symbol( self, name_path: str, relative_path: str | None = None, include_body: bool = False, include_quick_info: bool = False, include_documentation: bool = False, include_num_usages: bool = False, depth: int = 0, include_location: bool = False, search_deps: bool = False, ) -> jb.SymbolCollectionResponse: """ Finds symbols by name. :param name_path: the name path to match :param relative_path: the relative path to which to restrict the search :param include_body: whether to include symbol body content (should typically not be combined with `include_quick_info` or `include_documentation` because the body includes everything) :param include_quick_info: whether to include quick info (typically the signature) :param include_documentation: whether to include documentation; note that this includes the quick info, so one should not pass both `include_quick_info` and this :param include_num_usages: whether to include the number of usages :param depth: depth up to which to include children (0 = no children) :param include_location: whether to include symbol location information :param search_deps: whether to also search in dependencies """ request_data = { "namePath": name_path, "relativePath": relative_path, "includeBody": include_body, "depth": depth, "includeLocation": include_location, "searchDeps": search_deps, "includeQuickInfo": include_quick_info, "includeDocumentation": include_documentation, "includeNumUsages": include_num_usages, } symbol_collection = cast(jb.SymbolCollectionResponse, self._make_request("POST", "/findSymbol", request_data)) self._postprocess_symbol_collection_response(symbol_collection) return symbol_collection def find_references(self, name_path: str, relative_path: str, include_quick_info: bool) -> jb.SymbolCollectionResponse: """ Finds references to a symbol. :param name_path: the name path of the symbol :param relative_path: the relative path :param include_quick_info: whether to include quick info about references """ request_data = {"namePath": name_path, "relativePath": relative_path, "includeQuickInfo": include_quick_info} symbol_collection = cast(jb.SymbolCollectionResponse, self._make_request("POST", "/findReferences", request_data)) self._postprocess_symbol_collection_response(symbol_collection) return symbol_collection def get_symbols_overview( self, relative_path: str, depth: int, include_file_documentation: bool = False ) -> jb.GetSymbolsOverviewResponse: """ :param relative_path: the relative path to a source file :param depth: the depth of children to include (0 = no children) :param include_file_documentation: whether to include the file's documentation string (if any) """ request_data = {"relativePath": relative_path, "depth": depth, "includeFileDocumentation": include_file_documentation} response = cast(jb.GetSymbolsOverviewResponse, self._make_request("POST", "/getSymbolsOverview", request_data)) self._postprocess_symbol_collection_response(response) # process file documentation if "documentation" in response: response["documentation"] = render_html(response["documentation"]) return response def get_supertypes( self, name_path: str, relative_path: str, depth: int | None = None, limit_children: int | None = None, ) -> jb.TypeHierarchyResponse: """ Gets the supertypes (parent classes/interfaces) of a symbol. :param name_path: the name path of the symbol :param relative_path: the relative path to the file containing the symbol :param depth: depth limit for hierarchy traversal (None or 0 for unlimited) :param limit_children: optional limit on children per level """ self._require_version_at_least(2023, 2, 6) request_data = { "namePath": name_path, "relativePath": relative_path, "depth": depth, "limitChildren": limit_children, } return cast(jb.TypeHierarchyResponse, self._make_request("POST", "/getSupertypes", request_data)) def get_subtypes( self, name_path: str, relative_path: str, depth: int | None = None, limit_children: int | None = None, ) -> jb.TypeHierarchyResponse: """ Gets the subtypes (subclasses/implementations) of a symbol. :param name_path: the name path of the symbol :param relative_path: the relative path to the file containing the symbol :param depth: depth limit for hierarchy traversal (None or 0 for unlimited) :param limit_children: optional limit on children per level """ self._require_version_at_least(2023, 2, 6) request_data = { "namePath": name_path, "relativePath": relative_path, "depth": depth, "limitChildren": limit_children, } return cast(jb.TypeHierarchyResponse, self._make_request("POST", "/getSubtypes", request_data)) def rename_symbol( self, name_path: str, relative_path: str, new_name: str, rename_in_comments: bool, rename_in_text_occurrences: bool ) -> None: """ Renames a symbol. :param name_path: the name path of the symbol :param relative_path: the relative path :param new_name: the new name for the symbol :param rename_in_comments: whether to rename in comments :param rename_in_text_occurrences: whether to rename in text occurrences """ request_data = { "namePath": name_path, "relativePath": relative_path, "newName": new_name, "renameInComments": rename_in_comments, "renameInTextOccurrences": rename_in_text_occurrences, } self._make_request("POST", "/renameSymbol", request_data) def refresh_file(self, relative_path: str) -> None: """ Triggers a refresh of the given file in the IDE. :param relative_path: the relative path """ request_data = { "relativePath": relative_path, } self._make_request("POST", "/refreshFile", request_data) def close(self) -> None: self._session.close() def __enter__(self) -> Self: return self def __exit__(self, exc_type, exc_val, exc_tb): # type: ignore self.close() ================================================ FILE: src/serena/jetbrains/jetbrains_types.py ================================================ from typing import Literal, NotRequired, TypedDict class PluginStatusDTO(TypedDict): project_root: str plugin_version: str class PositionDTO(TypedDict): line: int col: int class TextRangeDTO(TypedDict): start_pos: PositionDTO end_pos: PositionDTO class SymbolDTO(TypedDict): name_path: str relative_path: str type: str body: NotRequired[str] quick_info: NotRequired[str] """Quick info text (e.g., type signature) for the symbol, as HTML string.""" documentation: NotRequired[str] """Documentation text for the symbol (if available), as HTML string.""" text_range: NotRequired[TextRangeDTO] children: NotRequired[list["SymbolDTO"]] num_usages: NotRequired[int] SymbolDTOKey = Literal["name_path", "relative_path", "type", "body", "quick_info", "documentation", "text_range", "children", "num_usages"] class SymbolCollectionResponse(TypedDict): symbols: list[SymbolDTO] class GetSymbolsOverviewResponse(SymbolCollectionResponse): documentation: NotRequired[str] """Docstring of the collection (if applicable - usually present only if the collection is from a single file), as HTML string.""" class TypeHierarchyNodeDTO(TypedDict): symbol: SymbolDTO children: NotRequired[list["TypeHierarchyNodeDTO"]] class TypeHierarchyResponse(TypedDict): hierarchy: NotRequired[list[TypeHierarchyNodeDTO]] num_levels_not_included: NotRequired[int] ================================================ FILE: src/serena/ls_manager.py ================================================ import logging import os.path import threading from collections.abc import Iterator from sensai.util.logging import LogTime from serena.config.serena_config import SerenaPaths from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) class LanguageServerManagerInitialisationError(Exception): def __init__(self, message: str): super().__init__(message) class LanguageServerFactory: def __init__( self, project_root: str, project_data_path: str, encoding: str, ignored_patterns: list[str], ls_timeout: float | None = None, ls_specific_settings: dict | None = None, trace_lsp_communication: bool = False, ): self.project_root = project_root self.project_data_path = project_data_path self.encoding = encoding self.ignored_patterns = ignored_patterns self.ls_timeout = ls_timeout self.ls_specific_settings = ls_specific_settings self.trace_lsp_communication = trace_lsp_communication def create_language_server(self, language: Language) -> SolidLanguageServer: ls_config = LanguageServerConfig( code_language=language, ignored_paths=self.ignored_patterns, trace_lsp_communication=self.trace_lsp_communication, encoding=self.encoding, ) log.info(f"Creating language server instance for {self.project_root}, language={language}.") return SolidLanguageServer.create( ls_config, self.project_root, timeout=self.ls_timeout, solidlsp_settings=SolidLSPSettings( solidlsp_dir=SerenaPaths().serena_user_home_dir, project_data_path=self.project_data_path, ls_specific_settings=self.ls_specific_settings or {}, ), ) class LanguageServerManager: """ Manages one or more language servers for a project. """ def __init__( self, language_servers: dict[Language, SolidLanguageServer], language_server_factory: LanguageServerFactory | None = None, ) -> None: """ :param language_servers: a mapping from language to language server; the servers are assumed to be already started. The first server in the iteration order is used as the default server. All servers are assumed to serve the same project root. :param language_server_factory: factory for language server creation; if None, dynamic (re)creation of language servers is not supported """ self._language_servers = language_servers self._language_server_factory = language_server_factory @property def _default_language_server(self) -> SolidLanguageServer: if len(self._language_servers) == 0: raise ValueError("No language servers available in the manager") return next(iter(self._language_servers.values())) @staticmethod def from_languages(languages: list[Language], factory: LanguageServerFactory) -> "LanguageServerManager": """ Creates a manager with language servers for the given languages using the given factory. The language servers are started in parallel threads. :param languages: the languages for which to spawn language servers :param factory: the factory for language server creation :return: the instance """ class StartLSThread(threading.Thread): def __init__(self, language: Language): super().__init__(target=self._start_language_server, name="StartLS:" + language.value) self.language = language self.language_server: SolidLanguageServer | None = None self.exception: Exception | None = None def _start_language_server(self) -> None: try: with LogTime(f"Language server startup (language={self.language.value})"): self.language_server = factory.create_language_server(self.language) self.language_server.start() if not self.language_server.is_running(): raise RuntimeError(f"Failed to start the language server for language {self.language.value}") except Exception as e: log.error(f"Error starting language server for language {self.language.value}: {e}", exc_info=e) self.exception = e # start language servers in parallel threads threads = [] for language in languages: thread = StartLSThread(language) thread.start() threads.append(thread) # collect language servers and exceptions language_servers: dict[Language, SolidLanguageServer] = {} exceptions: dict[Language, Exception] = {} for thread in threads: thread.join() if thread.exception is not None: exceptions[thread.language] = thread.exception elif thread.language_server is not None: language_servers[thread.language] = thread.language_server # If any server failed to start up, raise an exception and stop all started language servers. # We intentionally fail fast here. The user's intention is to work with all the specified languages, # so if any of them is not available, it is better to make symbolic tool calls fail, bringing the issue to the # user's attention instead of silently continuing with a subset of the language servers and potentially # causing suboptimal agent behaviour. if exceptions: for ls in language_servers.values(): ls.stop() failure_messages = "\n".join([f"{lang.value}: {e}" for lang, e in exceptions.items()]) raise LanguageServerManagerInitialisationError(f"Failed to start {len(exceptions)} language server(s):\n{failure_messages}") return LanguageServerManager(language_servers, factory) def _ensure_functional_ls(self, ls: SolidLanguageServer) -> SolidLanguageServer: if not ls.is_running(): log.warning(f"Language server for language {ls.language} is not running; restarting ...") ls = self.restart_language_server(ls.language) return ls def _get_suitable_language_server(self, relative_path: str) -> SolidLanguageServer | None: """:param relative_path: relative path to a file""" for candidate in self._language_servers.values(): if not candidate.is_ignored_path(relative_path, ignore_unsupported_files=True): return candidate return None def get_language_server(self, relative_path: str) -> SolidLanguageServer: """:param relative_path: relative path to a file""" ls: SolidLanguageServer | None = None if len(self._language_servers) > 1: if os.path.isdir(relative_path): raise ValueError(f"Expected a file path, but got a directory: {relative_path}") ls = self._get_suitable_language_server(relative_path) if ls is None: ls = self._default_language_server return self._ensure_functional_ls(ls) def _create_and_start_language_server(self, language: Language) -> SolidLanguageServer: if self._language_server_factory is None: raise ValueError(f"No language server factory available to create language server for {language}") language_server = self._language_server_factory.create_language_server(language) language_server.start() self._language_servers[language] = language_server return language_server def restart_language_server(self, language: Language) -> SolidLanguageServer: """ Forces recreation and restart of the language server for the given language. It is assumed that the language server for the given language is no longer running. :param language: the language :return: the newly created language server """ if language not in self._language_servers: raise ValueError(f"No language server for language {language.value} present; cannot restart") return self._create_and_start_language_server(language) def add_language_server(self, language: Language) -> SolidLanguageServer: """ Dynamically adds a new language server for the given language. :param language: the language :param factory: the factory to create the language server :return: the newly created language server """ if language in self._language_servers: raise ValueError(f"Language server for language {language.value} already present") return self._create_and_start_language_server(language) def remove_language_server(self, language: Language, save_cache: bool = False) -> None: """ Removes the language server for the given language, stopping it if it is running. :param language: the language """ if language not in self._language_servers: raise ValueError(f"No language server for language {language.value} present; cannot remove") ls = self._language_servers.pop(language) self._stop_language_server(ls, save_cache=save_cache) def get_active_languages(self) -> list[Language]: """ Returns the list of languages for which language servers are currently managed. :return: list of languages """ return list(self._language_servers.keys()) @staticmethod def _stop_language_server(ls: SolidLanguageServer, save_cache: bool = False, timeout: float = 2.0) -> None: if ls.is_running(): if save_cache: ls.save_cache() log.info(f"Stopping language server for language {ls.language} ...") ls.stop(shutdown_timeout=timeout) def iter_language_servers(self) -> Iterator[SolidLanguageServer]: for ls in self._language_servers.values(): yield self._ensure_functional_ls(ls) def stop_all(self, save_cache: bool = False, timeout: float = 2.0) -> None: """ Stops all managed language servers. :param save_cache: whether to save the cache before stopping :param timeout: timeout for shutdown of each language server """ for ls in self.iter_language_servers(): self._stop_language_server(ls, save_cache=save_cache, timeout=timeout) def save_all_caches(self) -> None: """ Saves the caches of all managed language servers. """ for ls in self.iter_language_servers(): if ls.is_running(): ls.save_cache() def has_suitable_ls_for_file(self, relative_file_path: str) -> bool: return self._get_suitable_language_server(relative_file_path) is not None ================================================ FILE: src/serena/mcp.py ================================================ """ The Serena Model Context Protocol (MCP) Server """ import sys from collections.abc import AsyncIterator, Iterator, Sequence from contextlib import asynccontextmanager from copy import deepcopy from dataclasses import dataclass from typing import Any, Literal, cast import docstring_parser from mcp.server.fastmcp import server from mcp.server.fastmcp.server import FastMCP, Settings from mcp.server.fastmcp.tools.base import Tool as MCPTool from mcp.types import ToolAnnotations from pydantic_settings import SettingsConfigDict from sensai.util import logging from serena.agent import ( SerenaAgent, SerenaConfig, ) from serena.config.context_mode import SerenaAgentContext from serena.config.serena_config import LanguageBackend, ModeSelectionDefinition from serena.constants import DEFAULT_CONTEXT, SERENA_LOG_FORMAT from serena.tools import Tool from serena.util.exception import show_fatal_exception_safe from serena.util.logging import MemoryLogHandler log = logging.getLogger(__name__) def configure_logging(*args, **kwargs) -> None: # type: ignore # We only do something here if logging has not yet been configured. # Normally, logging is configured in the MCP server startup script. if not logging.is_enabled(): logging.basicConfig(level=logging.INFO, stream=sys.stderr, format=SERENA_LOG_FORMAT) # patch the logging configuration function in fastmcp, because it's hard-coded and broken server.configure_logging = configure_logging # type: ignore @dataclass class SerenaMCPRequestContext: agent: SerenaAgent class SerenaMCPFactory: """ Factory for the creation of the Serena MCP server with an associated SerenaAgent. """ def __init__(self, context: str = DEFAULT_CONTEXT, project: str | None = None, memory_log_handler: MemoryLogHandler | None = None): """ :param context: The context name or path to context file :param project: Either an absolute path to the project directory or a name of an already registered project. If the project passed here hasn't been registered yet, it will be registered automatically and can be activated by its name afterward. :param memory_log_handler: the in-memory log handler to use for the agent's logging """ self.context = SerenaAgentContext.load(context) self.project = project self.agent: SerenaAgent | None = None self.memory_log_handler = memory_log_handler @staticmethod def _sanitize_for_openai_tools(schema: dict) -> dict: """ This method was written by GPT-5, I have not reviewed it in detail. Only called when `openai_tool_compatible` is True. Make a Pydantic/JSON Schema object compatible with OpenAI tool schema. - 'integer' -> 'number' (+ multipleOf: 1) - remove 'null' from union type arrays - coerce integer-only enums to number - best-effort simplify oneOf/anyOf when they only differ by integer/number """ s = deepcopy(schema) def walk(node): # type: ignore if not isinstance(node, dict): # lists get handled by parent calls return node # ---- handle type ---- t = node.get("type") if isinstance(t, str): if t == "integer": node["type"] = "number" # preserve existing multipleOf but ensure it's integer-like if "multipleOf" not in node: node["multipleOf"] = 1 elif isinstance(t, list): # remove 'null' (OpenAI tools don't support nullables) t2 = [x if x != "integer" else "number" for x in t if x != "null"] if not t2: # fall back to object if it somehow becomes empty t2 = ["object"] node["type"] = t2[0] if len(t2) == 1 else t2 if "integer" in t or "number" in t2: # if integers were present, keep integer-like restriction node.setdefault("multipleOf", 1) # ---- enums of integers -> number ---- if "enum" in node and isinstance(node["enum"], list): vals = node["enum"] if vals and all(isinstance(v, int) for v in vals): node.setdefault("type", "number") # keep them as ints; JSON 'number' covers ints node.setdefault("multipleOf", 1) # ---- simplify anyOf/oneOf if they only differ by integer/number ---- for key in ("oneOf", "anyOf"): if key in node and isinstance(node[key], list): # Special case: anyOf or oneOf with "type X" and "null" if len(node[key]) == 2: types = [sub.get("type") for sub in node[key]] if "null" in types: non_null_type = next(t for t in types if t != "null") if isinstance(non_null_type, str): node["type"] = non_null_type node.pop(key, None) continue simplified = [] changed = False for sub in node[key]: sub = walk(sub) # recurse simplified.append(sub) # If all subs are the same after integer→number, collapse try: import json canon = [json.dumps(x, sort_keys=True) for x in simplified] if len(set(canon)) == 1: # copy the single schema up only = simplified[0] node.pop(key, None) for k, v in only.items(): if k not in node: node[k] = v changed = True except Exception: pass if not changed: node[key] = simplified # ---- recurse into known schema containers ---- for child_key in ("properties", "patternProperties", "definitions", "$defs"): if child_key in node and isinstance(node[child_key], dict): for k, v in list(node[child_key].items()): node[child_key][k] = walk(v) # arrays/items if "items" in node: node["items"] = walk(node["items"]) # allOf/if/then/else - pass through with integer→number conversions applied inside for key in ("allOf",): if key in node and isinstance(node[key], list): node[key] = [walk(x) for x in node[key]] if "if" in node: node["if"] = walk(node["if"]) if "then" in node: node["then"] = walk(node["then"]) if "else" in node: node["else"] = walk(node["else"]) return node return walk(s) @staticmethod def make_mcp_tool(tool: Tool, openai_tool_compatible: bool = True) -> MCPTool: """ Create an MCP tool from a Serena Tool instance. :param tool: The Serena Tool instance to convert. :param openai_tool_compatible: whether to process the tool schema to be compatible with OpenAI tools (doesn't accept integer, needs number instead, etc.). This allows using Serena MCP within codex. """ func_name = tool.get_name() func_doc = tool.get_apply_docstring() or "" func_arg_metadata = tool.get_apply_fn_metadata() is_async = False parameters = func_arg_metadata.arg_model.model_json_schema() if openai_tool_compatible: parameters = SerenaMCPFactory._sanitize_for_openai_tools(parameters) docstring = docstring_parser.parse(func_doc) # Mount the tool description as a combination of the docstring description and # the return value description, if it exists. overridden_description = tool.agent.get_context().tool_description_overrides.get(func_name, None) if overridden_description is not None: func_doc = overridden_description elif docstring.description: func_doc = docstring.description else: func_doc = "" func_doc = func_doc.strip().strip(".") if func_doc: func_doc += "." if docstring.returns and (docstring_returns_descr := docstring.returns.description): # Only add a space before "Returns" if func_doc is not empty prefix = " " if func_doc else "" func_doc = f"{func_doc}{prefix}Returns {docstring_returns_descr.strip().strip('.')}." # Parse the parameter descriptions from the docstring and add pass its description # to the parameter schema. docstring_params = {param.arg_name: param for param in docstring.params} parameters_properties: dict[str, dict[str, Any]] = parameters["properties"] for parameter, properties in parameters_properties.items(): if (param_doc := docstring_params.get(parameter)) and param_doc.description: param_desc = f"{param_doc.description.strip().strip('.') + '.'}" properties["description"] = param_desc[0].upper() + param_desc[1:] def execute_fn(**kwargs) -> str: # type: ignore return tool.apply_ex(log_call=True, catch_exceptions=True, **kwargs) # Generate human-readable title from snake_case tool name tool_title = " ".join(word.capitalize() for word in func_name.split("_")) # Create annotations with appropriate hints based on tool capabilities can_edit = tool.can_edit() annotations = ToolAnnotations( title=tool_title, readOnlyHint=not can_edit, destructiveHint=can_edit, ) return MCPTool( fn=execute_fn, name=func_name, description=func_doc, parameters=parameters, fn_metadata=func_arg_metadata, is_async=is_async, # keep the value in sync with the kwarg name in Tool.apply_ex. The mcp sdk uses reflection to infer this # when the tool is constructed via from_function (which is a bit crazy IMO, but well...) context_kwarg="mcp_ctx", annotations=annotations, title=tool_title, ) def _iter_tools(self) -> Iterator[Tool]: assert self.agent is not None yield from self.agent.get_exposed_tool_instances() # noinspection PyProtectedMember def _set_mcp_tools(self, mcp: FastMCP, openai_tool_compatible: bool = False) -> None: """Update the tools in the MCP server""" if mcp is not None: mcp._tool_manager._tools = {} for tool in self._iter_tools(): mcp_tool = self.make_mcp_tool(tool, openai_tool_compatible=openai_tool_compatible) mcp._tool_manager._tools[tool.get_name()] = mcp_tool log.info(f"Starting MCP server with {len(mcp._tool_manager._tools)} tools: {list(mcp._tool_manager._tools.keys())}") def _create_serena_agent(self, serena_config: SerenaConfig, modes: ModeSelectionDefinition | None = None) -> SerenaAgent: return SerenaAgent( project=self.project, serena_config=serena_config, context=self.context, modes=modes, memory_log_handler=self.memory_log_handler ) def _create_default_serena_config(self) -> SerenaConfig: return SerenaConfig.from_config_file() def create_mcp_server( self, host: str = "0.0.0.0", port: int = 8000, modes: Sequence[str] = (), language_backend: LanguageBackend | None = None, enable_web_dashboard: bool | None = None, enable_gui_log_window: bool | None = None, open_web_dashboard: bool | None = None, log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = None, trace_lsp_communication: bool | None = None, tool_timeout: float | None = None, ) -> FastMCP: """ Create an MCP server with process-isolated SerenaAgent to prevent asyncio contamination. :param host: The host to bind to :param port: The port to bind to :param modes: List of mode names or paths to mode files :param language_backend: the language backend to use, overriding the configuration setting. :param enable_web_dashboard: Whether to enable the web dashboard. If not specified, will take the value from the serena configuration. :param enable_gui_log_window: Whether to enable the GUI log window. It currently does not work on macOS, and setting this to True will be ignored then. If not specified, will take the value from the serena configuration. :param open_web_dashboard: Whether to open the web dashboard on launch. If not specified, will take the value from the serena configuration. :param log_level: Log level. If not specified, will take the value from the serena configuration. :param trace_lsp_communication: Whether to trace the communication between Serena and the language servers. This is useful for debugging language server issues. :param tool_timeout: Timeout in seconds for tool execution. If not specified, will take the value from the serena configuration. """ try: config = self._create_default_serena_config() # update configuration with the provided parameters if enable_web_dashboard is not None: config.web_dashboard = enable_web_dashboard if enable_gui_log_window is not None: config.gui_log_window = enable_gui_log_window if open_web_dashboard is not None: config.web_dashboard_open_on_launch = open_web_dashboard if log_level is not None: log_level = cast(Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], log_level.upper()) config.log_level = logging.getLevelNamesMapping()[log_level] if trace_lsp_communication is not None: config.trace_lsp_communication = trace_lsp_communication if tool_timeout is not None: config.tool_timeout = tool_timeout if language_backend is not None: config.language_backend = language_backend mode_selection_def: ModeSelectionDefinition | None = None if modes: mode_selection_def = ModeSelectionDefinition(default_modes=modes) self.agent = self._create_serena_agent(config, mode_selection_def) except Exception as e: show_fatal_exception_safe(e) raise # Override model_config to disable the use of `.env` files for reading settings, because user projects are likely to contain # `.env` files (e.g. containing LOG_LEVEL) that are not supposed to override the MCP settings; # retain only FASTMCP_ prefix for already set environment variables. Settings.model_config = SettingsConfigDict(env_prefix="FASTMCP_") instructions = self._get_initial_instructions() mcp = FastMCP(lifespan=self.server_lifespan, host=host, port=port, instructions=instructions) return mcp @asynccontextmanager async def server_lifespan(self, mcp_server: FastMCP) -> AsyncIterator[None]: """Manage server startup and shutdown lifecycle.""" openai_tool_compatible = self.context.name in ["chatgpt", "codex", "oaicompat-agent"] self._set_mcp_tools(mcp_server, openai_tool_compatible=openai_tool_compatible) log.info("MCP server lifetime setup complete") yield log.info("MCP server shutting down") def _get_initial_instructions(self) -> str: assert self.agent is not None return self.agent.create_system_prompt() ================================================ FILE: src/serena/project.py ================================================ import json import logging import os import re import shutil import threading from collections.abc import Sequence from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, Optional import pathspec from sensai.util.logging import LogTime from sensai.util.string import TextBuilder, ToStringMixin from serena.config.serena_config import ( ProjectConfig, SerenaConfig, SerenaPaths, ) from serena.constants import SERENA_FILE_ENCODING from serena.ls_manager import LanguageServerFactory, LanguageServerManager from serena.util.file_system import GitignoreParser, match_path from serena.util.text_utils import ContentReplacer, MatchedConsecutiveLines, search_files from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import FileUtils if TYPE_CHECKING: from serena.agent import SerenaAgent log = logging.getLogger(__name__) class MemoriesManager: GLOBAL_TOPIC = "global" _global_memory_dir = SerenaPaths().global_memories_path def __init__(self, serena_data_folder: str | Path | None, read_only_memory_patterns: Sequence[str] = ()): """ :param serena_data_folder: the absolute path to the project's .serena data folder :param read_only_memory_patterns: whether to allow writing global memories in tool execution contexts """ self._project_memory_dir: Path | None = None if serena_data_folder is not None: self._project_memory_dir = Path(serena_data_folder) / "memories" self._project_memory_dir.mkdir(parents=True, exist_ok=True) self._encoding = SERENA_FILE_ENCODING self._read_only_memory_patterns = [re.compile(pattern) for pattern in set(read_only_memory_patterns)] def _is_read_only_memory(self, name: str) -> bool: for pattern in self._read_only_memory_patterns: if pattern.fullmatch(name): return True return False def _is_global(self, name: str) -> bool: return name == self.GLOBAL_TOPIC or name.startswith(self.GLOBAL_TOPIC + "/") def get_memory_file_path(self, name: str) -> Path: # Strip .md extension if present name = name.replace(".md", "") if self._is_global(name): if name == self.GLOBAL_TOPIC: raise ValueError( f'Bare "{self.GLOBAL_TOPIC}" is not a valid memory name. ' f'Use "{self.GLOBAL_TOPIC}/" to address a global memory.' ) # Strip "global/" prefix and resolve against global dir sub_name = name[len(self.GLOBAL_TOPIC) + 1 :] parts = sub_name.split("/") filename = f"{parts[-1]}.md" if len(parts) > 1: subdir = self._global_memory_dir / "/".join(parts[:-1]) subdir.mkdir(parents=True, exist_ok=True) return subdir / filename return self._global_memory_dir / filename # Project-local memory assert self._project_memory_dir is not None, "Project dir was not passed at initialization" parts = name.split("/") filename = f"{parts[-1]}.md" if len(parts) > 1: # Create subdirectory path subdir = self._project_memory_dir / "/".join(parts[:-1]) subdir.mkdir(parents=True, exist_ok=True) return subdir / filename return self._project_memory_dir / filename def _check_write_access(self, name: str, is_tool_context: bool) -> None: # in tool context, memories can be read-only if is_tool_context and self._is_read_only_memory(name): raise PermissionError(f"Attempted to write to read_only memory: '{name}')") def load_memory(self, name: str) -> str: memory_file_path = self.get_memory_file_path(name) if not memory_file_path.exists(): return f"Memory file {name} not found, consider creating it with the `write_memory` tool if you need it." with open(memory_file_path, encoding=self._encoding) as f: return f.read() def save_memory(self, name: str, content: str, is_tool_context: bool) -> str: self._check_write_access(name, is_tool_context) memory_file_path = self.get_memory_file_path(name) with open(memory_file_path, "w", encoding=self._encoding) as f: f.write(content) return f"Memory {name} written." class MemoriesList: def __init__(self) -> None: self.memories: list[str] = [] self.read_only_memories: list[str] = [] def __len__(self) -> int: return len(self.memories) + len(self.read_only_memories) def add(self, memory_name: str, is_read_only: bool) -> None: if is_read_only: self.read_only_memories.append(memory_name) else: self.memories.append(memory_name) def extend(self, other: "MemoriesManager.MemoriesList") -> None: self.memories.extend(other.memories) self.read_only_memories.extend(other.read_only_memories) def to_dict(self) -> dict[str, list[str]]: result = {} if self.memories: result["memories"] = sorted(self.memories) if self.read_only_memories: result["read_only_memories"] = sorted(self.read_only_memories) return result def get_full_list(self) -> list[str]: return sorted(self.memories + self.read_only_memories) def _list_memories(self, search_dir: Path, base_dir: Path, prefix: str = "") -> MemoriesList: result = self.MemoriesList() if not search_dir.exists(): return result for md_file in search_dir.rglob("*.md"): rel = str(md_file.relative_to(base_dir).with_suffix("")).replace(os.sep, "/") memory_name = prefix + rel result.add(memory_name, is_read_only=self._is_read_only_memory(memory_name)) return result def list_global_memories(self, subtopic: str = "") -> MemoriesList: dir_path = self._global_memory_dir if subtopic: dir_path = dir_path / subtopic.replace("/", os.sep) return self._list_memories(dir_path, self._global_memory_dir, self.GLOBAL_TOPIC + "/") def list_project_memories(self, topic: str = "") -> MemoriesList: assert self._project_memory_dir is not None, "Project dir was not passed at initialization" dir_path = self._project_memory_dir if topic: dir_path = dir_path / topic.replace("/", os.sep) return self._list_memories(dir_path, self._project_memory_dir) def list_memories(self, topic: str = "") -> MemoriesList: """ Lists all memories, optionally filtered by topic. If the topic is omitted, both global and project-specific memories are returned. """ memories: MemoriesManager.MemoriesList if topic: if self._is_global(topic): topic_parts = topic.split("/") subtopic = "/".join(topic_parts[1:]) memories = self.list_global_memories(subtopic=subtopic) else: memories = self.list_project_memories(topic=topic) else: memories = self.list_project_memories() memories.extend(self.list_global_memories()) return memories def delete_memory(self, name: str, is_tool_context: bool) -> str: self._check_write_access(name, is_tool_context) memory_file_path = self.get_memory_file_path(name) if not memory_file_path.exists(): return f"Memory {name} not found." memory_file_path.unlink() return f"Memory {name} deleted." def move_memory(self, old_name: str, new_name: str, is_tool_context: bool) -> str: """ Rename or move a memory file. Moving between global and project scope (e.g. "global/foo" -> "bar") is supported. """ self._check_write_access(new_name, is_tool_context) old_path = self.get_memory_file_path(old_name) new_path = self.get_memory_file_path(new_name) if not old_path.exists(): raise FileNotFoundError(f"Memory {old_name} not found.") if new_path.exists(): raise FileExistsError(f"Memory {new_name} already exists.") new_path.parent.mkdir(parents=True, exist_ok=True) shutil.move(old_path, new_path) return f"Memory renamed from {old_name} to {new_name}." def edit_memory( self, name: str, needle: str, repl: str, mode: Literal["literal", "regex"], allow_multiple_occurrences: bool, is_tool_context: bool ) -> str: """ Edit a memory by replacing content matching a pattern. :param name: the memory name :param needle: the string or regex to search for :param repl: the replacement string :param mode: "literal" or "regex" :param allow_multiple_occurrences: """ self._check_write_access(name, is_tool_context) memory_file_path = self.get_memory_file_path(name) if not memory_file_path.exists(): raise FileNotFoundError(f"Memory {name} not found.") with open(memory_file_path, encoding=self._encoding) as f: original_content = f.read() replacer = ContentReplacer(mode=mode, allow_multiple_occurrences=allow_multiple_occurrences) updated_content = replacer.replace(original_content, needle, repl) with open(memory_file_path, "w", encoding=self._encoding) as f: f.write(updated_content) return f"Memory {name} edited successfully." class Project(ToStringMixin): def __init__( self, *, project_root: str, project_config: ProjectConfig, serena_config: SerenaConfig, is_newly_created: bool = False, ): assert serena_config is not None self.project_root = project_root self.project_config = project_config self.serena_config = serena_config self._serena_data_folder = serena_config.get_project_serena_folder(self.project_root) log.info("Serena project data folder: %s", self._serena_data_folder) read_only_memory_patterns = serena_config.read_only_memory_patterns + project_config.read_only_memory_patterns self.memories_manager = MemoriesManager(self._serena_data_folder, read_only_memory_patterns=read_only_memory_patterns) # resolve line ending (project -> global) self.line_ending = project_config.line_ending or serena_config.line_ending self.language_server_manager: LanguageServerManager | None = None self._language_server_manager_init_error: Exception | None = None self._is_newly_created = is_newly_created self._agent: Optional["SerenaAgent"] = None # create .gitignore file in the project's Serena data folder if not yet present serena_data_gitignore_path = os.path.join(self._serena_data_folder, ".gitignore") if not os.path.exists(serena_data_gitignore_path): os.makedirs(os.path.dirname(serena_data_gitignore_path), exist_ok=True) log.info(f"Creating .gitignore file in {serena_data_gitignore_path}") with open(serena_data_gitignore_path, "w", encoding="utf-8") as f: f.write(f"/{SolidLanguageServer.CACHE_FOLDER_NAME}\n") f.write(f"/{ProjectConfig.SERENA_LOCAL_PROJECT_FILE}\n") # prepare ignore spec asynchronously, ensuring immediate project activation. self.__ignored_patterns: list[str] self.__ignore_spec: pathspec.PathSpec self._ignore_spec_available = threading.Event() threading.Thread(name=f"gather-ignorespec[{self.project_config.project_name}]", target=self._gather_ignorespec, daemon=True).start() def _gather_ignorespec(self) -> None: with LogTime(f"Gathering ignore spec for project {self.project_config.project_name}", logger=log): # gather ignored paths from the global configuration, project configuration, and gitignore files global_ignored_paths = self.serena_config.ignored_paths ignored_patterns = list(global_ignored_paths) + list(self.project_config.ignored_paths) if len(global_ignored_paths) > 0: log.info(f"Using {len(global_ignored_paths)} ignored paths from the global configuration.") log.debug(f"Global ignored paths: {list(global_ignored_paths)}") if len(self.project_config.ignored_paths) > 0: log.info(f"Using {len(self.project_config.ignored_paths)} ignored paths from the project configuration.") log.debug(f"Project ignored paths: {self.project_config.ignored_paths}") log.debug(f"Combined ignored patterns: {ignored_patterns}") if self.project_config.ignore_all_files_in_gitignore: gitignore_parser = GitignoreParser(self.project_root) for spec in gitignore_parser.get_ignore_specs(): log.debug(f"Adding {len(spec.patterns)} patterns from {spec.file_path} to the ignored paths.") ignored_patterns.extend(spec.patterns) self.__ignored_patterns = ignored_patterns # Set up the pathspec matcher for the ignored paths # for all absolute paths in ignored_paths, convert them to relative paths processed_patterns = [] for pattern in ignored_patterns: # Normalize separators (pathspec expects forward slashes) pattern = pattern.replace(os.path.sep, "/") processed_patterns.append(pattern) log.debug(f"Processing {len(processed_patterns)} ignored paths") self.__ignore_spec = pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, processed_patterns) self._ignore_spec_available.set() def _tostring_includes(self) -> list[str]: return [] def _tostring_additional_entries(self) -> dict[str, Any]: return {"root": self.project_root, "name": self.project_name} def set_agent(self, agent: "SerenaAgent") -> None: self._agent = agent @property def project_name(self) -> str: return self.project_config.project_name @classmethod def load( cls, project_root: str | Path, serena_config: "SerenaConfig", autogenerate: bool = True, ) -> "Project": assert serena_config is not None project_root = Path(project_root).resolve() if not project_root.exists(): raise FileNotFoundError(f"Project root not found: {project_root}") project_config = ProjectConfig.load(project_root, serena_config=serena_config, autogenerate=autogenerate) return Project(project_root=str(project_root), project_config=project_config, serena_config=serena_config) def save_config(self) -> None: """ Saves the current project configuration to disk. """ self.project_config.save(self.path_to_project_yml()) def path_to_serena_data_folder(self) -> str: return self._serena_data_folder def path_to_project_yml(self) -> str: return self.serena_config.get_project_yml_location(self.project_root) def get_activation_message(self) -> str: """ :return: a message providing information about the project upon activation (e.g. programming language, memories, initial prompt) """ if self._is_newly_created: msg = f"Created and activated a new project with name '{self.project_name}' at {self.project_root}. " else: msg = f"The project with name '{self.project_name}' at {self.project_root} is activated." languages_str = ", ".join([lang.value for lang in self.project_config.languages]) msg += f"\nProgramming languages: {languages_str}; file encoding: {self.project_config.encoding}" project_memories = self.memories_manager.list_project_memories() if project_memories: msg += ( f"\nAvailable project memories: {json.dumps(project_memories.to_dict())}\n" + "Use the `read_memory` tool to read these memories later if they are relevant to the task." ) if self.project_config.initial_prompt: msg += f"\nAdditional project-specific instructions:\n {self.project_config.initial_prompt}" return msg def read_file(self, relative_path: str) -> str: """ Reads a file relative to the project root. :param relative_path: the path to the file relative to the project root :return: the content of the file """ abs_path = Path(self.project_root) / relative_path return FileUtils.read_file(str(abs_path), self.project_config.encoding) @property def _ignore_spec(self) -> pathspec.PathSpec: """ :return: the pathspec matcher for the paths that were configured to be ignored, either explicitly or implicitly through .gitignore files. """ if not self._ignore_spec_available.is_set(): log.info("Waiting for ignore spec to become available ...") self._ignore_spec_available.wait() log.info("Ignore spec is now available for project; proceeding") return self.__ignore_spec @property def _ignored_patterns(self) -> list[str]: """ :return: the list of ignored path patterns """ if not self._ignore_spec_available.is_set(): log.info("Waiting for ignored patterns to become available ...") self._ignore_spec_available.wait() log.info("Ignore patterns are now available for project; proceeding") return self.__ignored_patterns def _is_ignored_relative_path(self, relative_path: str | Path, ignore_non_source_files: bool = True) -> bool: """ Determine whether an existing path should be ignored based on file type and ignore patterns. Raises `FileNotFoundError` if the path does not exist. :param relative_path: Relative path to check :param ignore_non_source_files: whether files that are not source files (according to the file masks determined by the project's programming language) shall be ignored :return: whether the path should be ignored """ # special case, never ignore the project root itself # If the user ignores hidden files, "." might match against the corresponding PathSpec pattern. # The empty string also points to the project root and should never be ignored. if str(relative_path) in [".", ""]: return False abs_path = os.path.join(self.project_root, relative_path) if not os.path.exists(abs_path): raise FileNotFoundError(f"File {abs_path} not found, the ignore check cannot be performed") # Check file extension if it's a file is_file = os.path.isfile(abs_path) if is_file and ignore_non_source_files: is_file_in_supported_language = False for language in self.project_config.languages: fn_matcher = language.get_source_fn_matcher() if fn_matcher.is_relevant_filename(abs_path): is_file_in_supported_language = True break if not is_file_in_supported_language: return True # Create normalized path for consistent handling rel_path = Path(relative_path) # always ignore paths inside .git if len(rel_path.parts) > 0 and rel_path.parts[0] == ".git": return True return match_path(str(relative_path), self._ignore_spec, root_path=self.project_root) def is_ignored_path(self, path: str | Path, ignore_non_source_files: bool = False) -> bool: """ Checks whether the given path is ignored :param path: the path to check, can be absolute or relative :param ignore_non_source_files: whether to ignore files that are not source files (according to the file masks determined by the project's programming language) """ path = Path(path) if path.is_absolute(): try: relative_path = path.relative_to(self.project_root) except ValueError: # If the path is not relative to the project root, we consider it as an absolute path outside the project # (which we ignore) log.warning(f"Path {path} is not relative to the project root {self.project_root} and was therefore ignored") return True else: relative_path = path return self._is_ignored_relative_path(str(relative_path), ignore_non_source_files=ignore_non_source_files) def is_path_in_project(self, path: str | Path) -> bool: """ Checks if the given (absolute or relative) path is inside the project directory. Note: This is intended to catch cases where ".." segments would lead outside of the project directory, but we intentionally allow symlinks, as the assumption is that they point to relevant project files. """ if not os.path.isabs(path): path = os.path.join(self.project_root, path) # collapse any ".." or "." segments (purely lexically) path = os.path.normpath(path) try: return os.path.commonpath([self.project_root, path]) == self.project_root except ValueError: # occurs, in particular, if paths are on different drives on Windows return False def relative_path_exists(self, relative_path: str) -> bool: """ Checks if the given relative path exists in the project directory. :param relative_path: the path to check, relative to the project root :return: True if the path exists, False otherwise """ abs_path = Path(self.project_root) / relative_path return abs_path.exists() def validate_relative_path(self, relative_path: str, require_not_ignored: bool = False) -> None: """ Validates that the given relative path to an existing file/dir is safe to read or edit, meaning it's inside the project directory. Passing a path to a non-existing file will lead to a `FileNotFoundError`. :param relative_path: the path to validate, relative to the project root :param require_not_ignored: if True, the path must not be ignored according to the project's ignore settings """ if not self.is_path_in_project(relative_path): raise ValueError(f"{relative_path=} points to path outside of the repository root; cannot access for safety reasons") if require_not_ignored: if self.is_ignored_path(relative_path): raise ValueError(f"Path {relative_path} is ignored; cannot access for safety reasons") def gather_source_files(self, relative_path: str = "") -> list[str]: """Retrieves relative paths of all source files, optionally limited to the given path :param relative_path: if provided, restrict search to this path """ rel_file_paths = [] start_path = os.path.join(self.project_root, relative_path) if not os.path.exists(start_path): raise FileNotFoundError(f"Relative path {start_path} not found.") if os.path.isfile(start_path): return [relative_path] else: for root, dirs, files in os.walk(start_path, followlinks=True): # prevent recursion into ignored directories dirs[:] = [d for d in dirs if not self.is_ignored_path(os.path.join(root, d))] # collect non-ignored files for file in files: abs_file_path = os.path.join(root, file) try: if not self.is_ignored_path(abs_file_path, ignore_non_source_files=True): try: rel_file_path = os.path.relpath(abs_file_path, start=self.project_root) except Exception: log.warning( "Ignoring path '%s' because it appears to be outside of the project root (%s)", abs_file_path, self.project_root, ) continue rel_file_paths.append(rel_file_path) except FileNotFoundError: log.warning( f"File {abs_file_path} not found (possibly due it being a symlink), skipping it in request_parsed_files", ) return rel_file_paths def search_source_files_for_pattern( self, pattern: str, relative_path: str = "", context_lines_before: int = 0, context_lines_after: int = 0, paths_include_glob: str | None = None, paths_exclude_glob: str | None = None, ) -> list[MatchedConsecutiveLines]: """ Search for a pattern across all (non-ignored) source files :param pattern: Regular expression pattern to search for, either as a compiled Pattern or string :param relative_path: :param context_lines_before: Number of lines of context to include before each match :param context_lines_after: Number of lines of context to include after each match :param paths_include_glob: Glob pattern to filter which files to include in the search :param paths_exclude_glob: Glob pattern to filter which files to exclude from the search. Takes precedence over paths_include_glob. :return: List of matched consecutive lines with context """ relative_file_paths = self.gather_source_files(relative_path=relative_path) return search_files( relative_file_paths, pattern, root_path=self.project_root, file_reader=self.read_file, context_lines_before=context_lines_before, context_lines_after=context_lines_after, paths_include_glob=paths_include_glob, paths_exclude_glob=paths_exclude_glob, ) def retrieve_content_around_line( self, relative_file_path: str, line: int, context_lines_before: int = 0, context_lines_after: int = 0 ) -> MatchedConsecutiveLines: """ Retrieve the content of the given file around the given line. :param relative_file_path: The relative path of the file to retrieve the content from :param line: The line number to retrieve the content around :param context_lines_before: The number of lines to retrieve before the given line :param context_lines_after: The number of lines to retrieve after the given line :return MatchedConsecutiveLines: A container with the desired lines. """ file_contents = self.read_file(relative_file_path) return MatchedConsecutiveLines.from_file_contents( file_contents, line=line, context_lines_before=context_lines_before, context_lines_after=context_lines_after, source_file_path=relative_file_path, ) def create_language_server_manager(self) -> LanguageServerManager: """ Creates the language server manager for the project, starting one language server per configured programming language. :return: the language server manager, which is also stored in the project instance """ try: # determine timeout to use for LS calls tool_timeout = self.serena_config.tool_timeout if tool_timeout is None or tool_timeout < 0: ls_timeout = None else: if tool_timeout < 10: raise ValueError(f"Tool timeout must be at least 10 seconds, but is {tool_timeout} seconds") ls_timeout = tool_timeout - 5 # the LS timeout is for a single call, it should be smaller than the tool timeout # if there is an existing instance, stop its language servers first if self.language_server_manager is not None: log.info("Stopping existing language server manager ...") self.language_server_manager.stop_all() self.language_server_manager = None log.info(f"Creating language server manager for {self.project_root}") self._language_server_manager_init_error = None factory = LanguageServerFactory( project_root=self.project_root, project_data_path=self._serena_data_folder, encoding=self.project_config.encoding, ignored_patterns=self._ignored_patterns, ls_timeout=ls_timeout, ls_specific_settings=self.serena_config.ls_specific_settings, trace_lsp_communication=self.serena_config.trace_lsp_communication, ) self.language_server_manager = LanguageServerManager.from_languages(self.project_config.languages, factory) return self.language_server_manager except Exception as e: self._language_server_manager_init_error = e raise def get_language_server_manager_or_raise(self) -> LanguageServerManager: if self.language_server_manager is None: msg = TextBuilder("The language server manager is not initialized, indicating a problem during project initialisation.") if self._language_server_manager_init_error is not None: msg.with_text(str(self._language_server_manager_init_error)) if self._agent is not None: msg.with_text("For details, please check the logs. " + self._agent.get_log_inspection_instructions()) msg.with_text( "IMPORTANT: Stop, do not attempt workarounds. Inform the user and wait for further instructions before you continue!" ) raise Exception(msg.build()) return self.language_server_manager def add_language(self, language: Language) -> None: """ Adds a new programming language to the project configuration, starting the corresponding language server instance if the LS manager is active. The project configuration is saved to disk after adding the language. :param language: the programming language to add """ if language in self.project_config.languages: log.info(f"Language {language.value} is already present in the project configuration.") return # start the language server (if the LS manager is active) if self.language_server_manager is None: log.info("Language server manager is not active; skipping language server startup for the new language.") else: log.info("Adding and starting the language server for new language %s ...", language.value) self.language_server_manager.add_language_server(language) # update the project configuration self.project_config.languages.append(language) self.save_config() def remove_language(self, language: Language) -> None: """ Removes a programming language from the project configuration, stopping the corresponding language server instance if the LS manager is active. The project configuration is saved to disk after removing the language. :param language: the programming language to remove """ if language not in self.project_config.languages: log.info(f"Language {language.value} is not present in the project configuration.") return # update the project configuration self.project_config.languages.remove(language) self.save_config() # stop the language server (if the LS manager is active) if self.language_server_manager is None: log.info("Language server manager is not active; skipping language server shutdown for the removed language.") else: log.info("Removing and stopping the language server for language %s ...", language.value) self.language_server_manager.remove_language_server(language) def shutdown(self, timeout: float = 2.0) -> None: if self.language_server_manager is not None: self.language_server_manager.stop_all(save_cache=True, timeout=timeout) self.language_server_manager = None ================================================ FILE: src/serena/project_server.py ================================================ import json import logging from typing import TYPE_CHECKING import requests as requests_lib from flask import Flask, request from pydantic import BaseModel from sensai.util.logging import LogTime from serena.config.serena_config import LanguageBackend, SerenaConfig from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient if TYPE_CHECKING: from serena.project import Project log = logging.getLogger(__name__) # disable Werkzeug's logging to avoid cluttering the output logging.getLogger("werkzeug").setLevel(logging.WARNING) class QueryProjectRequest(BaseModel): """ Request model for the /query_project endpoint, matching the interface of :class:`~serena.tools.query_project_tools.QueryProjectTool`. """ project_name: str tool_name: str tool_params_json: str class ProjectServer: """ A lightweight Flask server that exposes a SerenaAgent's project querying capabilities via HTTP, using the LSP language server backend for symbolic retrieval. Projects are loaded on demand when a query is made for them, and cached in memory for subsequent queries. The server instantiates a :class:`SerenaAgent` with default options and provides a ``/query_project`` endpoint whose interface matches :class:`~serena.tools.query_project_tools.QueryProjectTool`. """ PORT = JetBrainsPluginClient.BASE_PORT - 1 def __init__(self) -> None: from serena.agent import SerenaAgent serena_config = SerenaConfig.from_config_file() serena_config.gui_log_window = False serena_config.web_dashboard = False serena_config.language_backend = LanguageBackend.LSP self._agent = SerenaAgent(serena_config=serena_config) self._loaded_projects_by_name: dict[str, "Project"] = {} # create the Flask application self._app = Flask(__name__) self._setup_routes() def _setup_routes(self) -> None: @self._app.route("/heartbeat", methods=["GET"]) def heartbeat() -> dict[str, str]: return {"status": "alive"} @self._app.route("/query_project", methods=["POST"]) def query_project() -> str: query_request = QueryProjectRequest.model_validate(request.get_json()) return self._query_project(query_request) def _get_project(self, project_name: str) -> "Project": """Gets the project with the given name, loading it if necessary.""" if project_name in self._loaded_projects_by_name: return self._loaded_projects_by_name[project_name] else: serena_config = self._agent.serena_config registered_project = serena_config.get_registered_project(project_name) if registered_project is None: raise ValueError(f"Project '{project_name}' is not registered with Serena.") with LogTime(f"Loading project '{project_name}'"): project = registered_project.get_project_instance(serena_config) project.create_language_server_manager() self._loaded_projects_by_name[project_name] = project return project def _query_project(self, req: QueryProjectRequest) -> str: """Handle a /query_project request by invoking the agent on the specified project and tool.""" project = self._get_project(req.project_name) with self._agent.active_project_context(project): tool = self._agent.get_tool_by_name(req.tool_name) params = json.loads(req.tool_params_json) return tool.apply_ex(**params) def run(self, host: str = "127.0.0.1", port: int = PORT) -> int: """Run the server on the given host and port. :param host: the host address to listen on. :param port: the port to listen on. :return: the port number the server is running on. """ from flask import cli # suppress the default Flask startup banner cli.show_server_banner = lambda *args, **kwargs: None self._app.run(host=host, port=port, debug=False, use_reloader=False, threaded=True) return port class ProjectServerClient: """Client for interacting with a running :class:`ProjectServer`. Upon instantiation, the client verifies that the server is reachable by sending a heartbeat request. If the server is not running, a :class:`ConnectionError` is raised. """ def __init__(self, host: str = "127.0.0.1", port: int = ProjectServer.PORT, timeout: int = 300) -> None: """ :param host: the host address of the project server. :param port: the port of the project server. :raises ConnectionError: if the project server is not reachable. """ self._base_url = f"http://{host}:{port}" self._timeout = timeout # verify that the server is running try: response = requests_lib.get(f"{self._base_url}/heartbeat", timeout=5) response.raise_for_status() except requests_lib.ConnectionError: raise ConnectionError(f"ProjectServer is not reachable at {self._base_url}. Make sure the server is running.") except requests_lib.RequestException as e: raise ConnectionError(f"ProjectServer health check failed: {e}") def query_project(self, project_name: str, tool_name: str, tool_params_json: str) -> str: """ Query a project by executing a Serena tool in its context. The interface matches :meth:`QueryProjectTool.apply `. :param project_name: the name of the project to query. :param tool_name: the name of the tool to execute. The tool must be read-only. :param tool_params_json: the parameters to pass to the tool, encoded as a JSON string. :return: the tool's result as a string. """ payload = QueryProjectRequest( project_name=project_name, tool_name=tool_name, tool_params_json=tool_params_json, ).model_dump() response = requests_lib.post(f"{self._base_url}/query_project", json=payload, timeout=self._timeout) response.raise_for_status() return response.text ================================================ FILE: src/serena/prompt_factory.py ================================================ import os from serena.config.serena_config import SerenaPaths from serena.constants import PROMPT_TEMPLATES_DIR_INTERNAL from serena.generated.generated_prompt_factory import PromptFactory class SerenaPromptFactory(PromptFactory): """ A class for retrieving and rendering prompt templates and prompt lists. """ def __init__(self) -> None: user_templates_dir = SerenaPaths().user_prompt_templates_dir os.makedirs(user_templates_dir, exist_ok=True) super().__init__(prompts_dir=[user_templates_dir, PROMPT_TEMPLATES_DIR_INTERNAL]) ================================================ FILE: src/serena/resources/config/contexts/agent.yml ================================================ description: Agent context where the system prompt (initial instructions) are provided at startup prompt: | You are running in an agent context. excluded_tools: - initial_instructions tool_description_overrides: {} ================================================ FILE: src/serena/resources/config/contexts/chatgpt.yml ================================================ description: A configuration specific for ChatGPT, which has a limit of 30 tools and requires short descriptions. prompt: | You are running in desktop app context where the tools give you access to the code base as well as some access to the file system, if configured. You interact with the user through a chat interface that is separated from the code base. As a consequence, if you are in interactive mode, your communication with the user should involve high-level thinking and planning as well as some summarization of any code edits that you make. For viewing the code edits the user will view them in a separate code editor window, and the back-and-forth between the chat and the code editor should be minimized as well as facilitated by you. If complex changes have been made, advise the user on how to review them in the code editor. If complex relationships that the user asked for should be visualized or explained, consider creating a diagram in addition to your text-based communication. Note that in the chat interface you have various rendering options for text, html, and mermaid diagrams, as has been explained to you in your initial instructions. excluded_tools: [] included_optional_tools: - switch_modes tool_description_overrides: find_symbol: | Retrieves symbols matching `name_path_pattern` in a file. Use `depth > 0` to include children. `name_path_pattern` can be: "foo": any symbol named "foo"; "foo/bar": "bar" within "foo"; "/foo/bar": only top-level "foo/bar" replace_content: | Replaces content in files. Preferred for smaller edits where symbol-level tools aren't appropriate. Use mode "regex" with wildcards (.*?) to match large sections efficiently: "beginning.*?end" instead of specifying exact content. Essential for multi-line replacements. search_for_pattern: | Flexible pattern search across codebase. Prefer symbolic operations when possible. Uses DOTALL matching. Use non-greedy quantifiers (.*?) to avoid over-matching. Supports file filtering via globs and code-only restriction. ================================================ FILE: src/serena/resources/config/contexts/claude-code.yml ================================================ description: Claude Code (CLI agent where file operations, basic edits, etc. are already covered; single project mode) prompt: | You are running in a CLI coding agent context where file operations, basic (line-based) edits and reads as well as shell commands are handled by your own, internal tools. If Serena's tools can be used to achieve your task, you should prioritize them. In particular, it is important that you avoid reading entire source code files unless it is strictly necessary! Instead, for exploring and reading code in a token-efficient manner, use Serena's overview and symbolic search tools. For non-code files or for reads where you don't know the symbol's name path, you can use the pattern search tool. excluded_tools: - create_text_file - read_file - execute_shell_command - prepare_for_new_conversation - replace_content tool_description_overrides: {} # whether to assume that Serena shall only work on a single project in this context (provided that a project is given # when Serena is started). # If set to true and a project is provided at startup, the set of tools is limited to those required by the project's # concrete configuration, and other tools are excluded completely, allowing the set of tools to be minimal. # Tools explicitly disabled by the project will not be available at all. # The `activate_project` tool is always disabled in this case, as project switching cannot be allowed. single_project: true ================================================ FILE: src/serena/resources/config/contexts/codex.yml ================================================ description: Codex Non-symbolic editing tools and general shell tool are excluded prompt: | You are running in the Codex IDE assistant mode, where file operations, basic (line-based) edits and reads as well as shell commands are handled by your own, internal tools. Don't attempt to use any excluded tools; instead, rely on your own internal tools for basic file or shell operations. If Serena's tools can be used to achieve your task, you should prioritize them. In particular, it is important that you avoid reading entire source code files unless it is strictly necessary! Instead, for exploring and reading code in a token-efficient manner, use Serena's overview and symbolic search tools. For non-code files or for reads where you don't know the symbol's name path, you can use the pattern search tool. excluded_tools: - create_text_file - read_file - execute_shell_command - prepare_for_new_conversation - replace_content tool_description_overrides: {} ================================================ FILE: src/serena/resources/config/contexts/context.template.yml ================================================ # See Serena's documentation for more details on concept of contexts. description: Description of the context, not used in the code. prompt: Prompt that will form part of the system prompt/initial instructions for agents started in this context. excluded_tools: [] # several tools are excluded by default and have to be explicitly included by the user included_optional_tools: [] # mapping of tool names to an override of their descriptions (the default description is the docstring of the Tool's apply method). # Sometimes, tool descriptions are too long (e.g., for ChatGPT), or users may want to override them for another reason. tool_description_overrides: {} # whether to assume that Serena shall only work on a single project in this context (provided that a project is given # when Serena is started). # If set to true and a project is provided at startup, the set of tools is limited to those required by the project's # concrete configuration, and other tools are excluded completely, allowing the set of tools to be minimal. # The `activate_project` tool will, therefore, be disabled in this case, as project switching is not allowed. single_project: false ================================================ FILE: src/serena/resources/config/contexts/desktop-app.yml ================================================ description: Desktop application context (chat application detached from code) where Serena's full toolset is provided prompt: | You are running in a desktop application context. Serena's tools give you access to the code base as well as some access to the file system (if enabled). You interact with the user through a chat interface that is separated from the code base. As a consequence, if you are in interactive mode, your communication with the user should involve high-level thinking and planning as well as some summarization of any code edits that you make. To view the code edits you make, the user will have switch to a separate application. To illustrate complex relationships, consider creating diagrams in addition to your text-based communication (depending on the options for text, html, mermaid diagrams, etc. that you are provided with in your initial instructions). excluded_tools: [] included_optional_tools: - switch_modes tool_description_overrides: {} ================================================ FILE: src/serena/resources/config/contexts/ide.yml ================================================ description: Generic IDE coding agent context (basic file operations and shell operations assumed to be covered; single project mode) prompt: | You are running in an IDE assistant context where file operations, basic (line-based) edits and reads, and shell commands are handled by your own, internal tools. If Serena's tools can be used to achieve your task, you should prioritize them. In particular, it is important that you avoid reading entire source code files unless it is strictly necessary! Instead, for exploring and reading code in a token-efficient manner, use Serena's overview and symbolic search tools. For non-code files or for reads where you don't know the symbol's name path, you can use the pattern search tool. excluded_tools: - create_text_file - read_file - execute_shell_command - prepare_for_new_conversation tool_description_overrides: {} # whether to assume that Serena shall only work on a single project in this context (provided that a project is given # when Serena is started). # If set to true and a project is provided at startup, the set of tools is limited to those required by the project's # concrete configuration, and other tools are excluded completely, allowing the set of tools to be minimal. # Tools explicitly disabled by the project will not be available at all. # The `activate_project` tool is always disabled in this case, as project switching cannot be allowed. single_project: true ================================================ FILE: src/serena/resources/config/contexts/oaicompat-agent.yml ================================================ description: All tools except InitialInstructionsTool for agent context, uses OpenAI compatible tool definitions prompt: | You are running in agent context where the system prompt is provided externally. You should use symbolic tools when possible for code understanding and modification. excluded_tools: - initial_instructions tool_description_overrides: {} ================================================ FILE: src/serena/resources/config/internal_modes/jetbrains.yml ================================================ description: JetBrains tools replace language server-based tools prompt: | You have access to the very powerful JetBrains tools for symbolic operations: * `jet_brains_find_symbol` replaces `find_symbol` * `jet_brains_find_referencing_symbols` replaces `find_referencing_symbols` * `jet_brains_get_symbols_overview` replaces `get_symbols_overview` excluded_tools: - find_symbol - find_referencing_symbols - get_symbols_overview - restart_language_server included_optional_tools: - jet_brains_find_symbol - jet_brains_find_referencing_symbols - jet_brains_get_symbols_overview - jet_brains_type_hierarchy ================================================ FILE: src/serena/resources/config/modes/editing.yml ================================================ description: All tools, with detailed instructions for code editing prompt: | You are operating in editing mode. You can edit files with the provided tools. You adhere to the project's code style and patterns. Use symbolic editing tools whenever possible for precise code modifications. If no explicit editing task has yet been provided, wait for the user to provide one. Do not be overly eager. When writing new code, think about where it belongs best. Don't generate new files if you don't plan on actually properly integrating them into the codebase. You have two main approaches for editing code: (a) editing at the symbol level and (b) file-based editing. The symbol-based approach is appropriate if you need to adjust an entire symbol, e.g. a method, a class, a function, etc. It is not appropriate if you need to adjust just a few lines of code within a larger symbol. **Symbolic editing** Use symbolic retrieval tools to identify the symbols you need to edit. If you need to replace the definition of a symbol, use the `replace_symbol_body` tool. If you want to add some new code at the end of the file, use the `insert_after_symbol` tool with the last top-level symbol in the file. Similarly, you can use `insert_before_symbol` with the first top-level symbol in the file to insert code at the beginning of a file. You can understand relationships between symbols by using the `find_referencing_symbols` tool. If not explicitly requested otherwise by the user, you make sure that when you edit a symbol, the change is either backward-compatible or you find and update all references as needed. The `find_referencing_symbols` tool will give you code snippets around the references as well as symbolic information. You can assume that all symbol editing tools are reliable, so you never need to verify the results if the tools return without error. {% if 'replace_content' in available_tools %} **File-based editing** The `replace_content` tool allows you to perform regex-based replacements within files (as well as simple string replacements). This is your primary tool for editing code whenever replacing or deleting a whole symbol would be a more expensive operation, e.g. if you need to adjust just a few lines of code within a method. You are extremely good at regex, so you never need to check whether the replacement produced the correct result. In particular, you know how to use wildcards effectively in order to avoid specifying the full original text to be replaced! {% endif %} excluded_tools: - replace_lines - insert_at_line - delete_lines ================================================ FILE: src/serena/resources/config/modes/interactive.yml ================================================ description: Interactive mode for clarification and step-by-step work prompt: | You are operating in interactive mode. You should engage with the user throughout the task, asking for clarification whenever anything is unclear, insufficiently specified, or ambiguous. Break down complex tasks into smaller steps and explain your thinking at each stage. When you're uncertain about a decision, present options to the user and ask for guidance rather than making assumptions. Focus on providing informative results for intermediate steps, such that the user can follow along with your progress and provide feedback as needed. excluded_tools: [] ================================================ FILE: src/serena/resources/config/modes/mode.template.yml ================================================ # See Serena's documentation for more details on concept of modes. description: Description of the mode (meta-information only) prompt: | Provide a prompt that will form part of the instructions sent to the model when this mode is activated. # tools that are to be excluded by this mode excluded_tools: [] # several tools are excluded by default and have to be explicitly included by the user included_optional_tools: [] ================================================ FILE: src/serena/resources/config/modes/no-memories.yml ================================================ description: Excludes Serena's memory tools (and onboarding tools, which rely on memory) prompt: | Serena's memory tools are not available and the onboarding workflow is not being applied. excluded_tools: - write_memory - read_memory - delete_memory - edit_memory - rename_memory - list_memories - onboarding - check_onboarding_performed ================================================ FILE: src/serena/resources/config/modes/no-onboarding.yml ================================================ description: The onboarding process is not used (memories may have been created externally) prompt: | The onboarding process is not applied. excluded_tools: - onboarding - check_onboarding_performed ================================================ FILE: src/serena/resources/config/modes/onboarding.yml ================================================ description: Only read-only tools, focused on analysis and planning prompt: | You are operating in onboarding mode. This is the first time you are seeing the project. Your task is to collect relevant information about it and to save memories using the tools provided. Call relevant onboarding tools for more instructions on how to do this. In this mode, you should not be modifying any existing files. If you are also in interactive mode and something about the project is unclear, ask the user for clarification. excluded_tools: - create_text_file - replace_symbol_body - insert_after_symbol - insert_before_symbol - delete_lines - replace_lines - insert_at_line - execute_shell_command ================================================ FILE: src/serena/resources/config/modes/one-shot.yml ================================================ description: Focus on completely finishing a task without interaction prompt: | You are operating in one-shot mode. Your goal is to complete the entire task autonomously without further user interaction. You should assume auto-approval for all tools and continue working until the task is completely finished. If the task is planning, your final result should be a comprehensive plan. If the task is coding, your final result should be working code with all requirements fulfilled. Try to understand what the user asks you to do and to assume as little as possible. Only abort the task if absolutely necessary, such as when critical information is missing that cannot be inferred from the codebase. It may be that you have not received a task yet. In this case, wait for the user to provide a task, this will be the only time you should wait for user interaction. excluded_tools: [] ================================================ FILE: src/serena/resources/config/modes/planning.yml ================================================ description: Only read-only tools, focused on analysis and planning prompt: | You are operating in planning mode. Your task is to analyze code but not write any code. The user may ask you to assist in creating a comprehensive plan, or to learn something about the codebase. excluded_tools: - create_text_file - replace_symbol_body - insert_after_symbol - insert_before_symbol - delete_lines - replace_lines - insert_at_line - execute_shell_command - replace_content ================================================ FILE: src/serena/resources/config/modes/query-projects.yml ================================================ description: Enables tools that allow inactive projects to be queried prompt: | You can use the 'query_project' tool to query Serena projects without activating them. Use this when a project is related to the active project and you need to query it for information. excluded_tools: [] included_optional_tools: - list_queryable_projects - query_project ================================================ FILE: src/serena/resources/config/prompt_templates/simple_tool_outputs.yml ================================================ # Some of Serena's tools are just outputting a fixed text block without doing anything else. # Such tools are meant to encourage the agent to think in a certain way, to stay on track # and so on. The (templates for) outputs of these tools are contained here. prompts: onboarding_prompt: | You are viewing the project for the first time. Your task is to assemble relevant high-level information about the project which will be saved to memory files in the following steps. The information should be sufficient to understand what the project is about, and the most important commands for developing code. The project is being developed on the system: {{ system }}. You need to identify at least the following information: * the project's purpose * the tech stack used * the code style and conventions used (including naming, type hints, docstrings, etc.) * which commands to run when a task is completed (linting, formatting, testing, etc.) * the rough structure of the codebase * the commands for testing, formatting, and linting * the commands for running the entrypoints of the project * the util commands for the system, like `git`, `ls`, `cd`, `grep`, `find`, etc. Keep in mind that the system is {{ system }}, so the commands might be different than on a regular unix system. * whether there are particular guidelines, styles, design patterns, etc. that one should know about This list is not exhaustive, you can add more information if you think it is relevant. For doing that, you will need to acquire information about the project with the corresponding tools. Read only the necessary files and directories to avoid loading too much data into memory. If you cannot find everything you need from the project itself, you should ask the user for more information. After collecting all the information, you will use the `write_memory` tool (in multiple calls) to save it to various memory files. A particularly important memory file will be the `suggested_commands.md` file, which should contain a list of commands that the user should know about to develop code in this project. Moreover, you should create memory files for the style and conventions and a dedicated memory file for what should be done when a task is completed. **Important**: after done with the onboarding task, remember to call the `write_memory` to save the collected information! think_about_collected_information: | Have you collected all the information you need for solving the current task? If not, can the missing information be acquired by using the available tools, in particular the tools related to symbol discovery? Or do you need to ask the user for more information? Think about it step by step and give a summary of the missing information and how it could be acquired. think_about_task_adherence: | Are you deviating from the task at hand? Do you need any additional information to proceed? Have you loaded all relevant memory files to see whether your implementation is fully aligned with the code style, conventions, and guidelines of the project? If not, adjust your implementation accordingly before modifying any code into the codebase. Note that it is better to stop and ask the user for clarification than to perform large changes which might not be aligned with the user's intentions. If you feel like the conversation is deviating too much from the original task, apologize and suggest to the user how to proceed. If the conversation became too long, create a summary of the current progress and suggest to the user to start a new conversation based on that summary. think_about_whether_you_are_done: | Have you already performed all the steps required by the task? Is it appropriate to run tests and linting, and if so, have you done that already? Is it appropriate to adjust non-code files like documentation and config and have you done that already? Should new tests be written to cover the changes? Note that a task that is just about exploring the codebase does not require running tests or linting. Read the corresponding memory files to see what should be done when a task is completed. summarize_changes: | Summarize all the changes you have made to the codebase over the course of the conversation. Explore the diff if needed (e.g. by using `git diff`) to ensure that you have not missed anything. Explain whether and how the changes are covered by tests. Explain how to best use the new code, how to understand it, which existing code it affects and interacts with. Are there any dangers (like potential breaking changes or potential new problems) that the user should be aware of? Should any new documentation be written or existing documentation updated? You can use tools to explore the codebase prior to writing the summary, but don't write any new code in this step until the summary is complete. prepare_for_new_conversation: | You have not yet completed the current task but we are running out of context. {mode_prepare_for_new_conversation} Imagine that you are handing over the task to another person who has access to the same tools and memory files as you do, but has not been part of the conversation so far. Write a summary that can be used in the next conversation to a memory file using the `write_memory` tool. ================================================ FILE: src/serena/resources/config/prompt_templates/system_prompt.yml ================================================ # The system prompt template. Note that many clients will not allow configuration of the actual system prompt, # in which case this prompt will be given as a regular message on the call of a simple tool which the agent # is encouraged (via the tool description) to call at the beginning of the conversation. prompts: system_prompt: | You are a professional coding agent. You have access to semantic coding tools upon which you rely heavily for all your work. You operate in a resource-efficient and intelligent manner, always keeping in mind to not read or generate content that is not needed for the task at hand. Some tasks may require you to understand the architecture of large parts of the codebase, while for others, it may be enough to read a small set of symbols or a single file. You avoid reading entire files unless it is absolutely necessary, instead relying on intelligent step-by-step acquisition of information. {% if 'ToolMarkerSymbolicRead' in available_markers %}Once you have read a full file, it does not make sense to analyse it with the symbolic read tools; you already have the information.{% endif %} You can achieve intelligent reading of code by using the symbolic tools for getting an overview of symbols and the relations between them, and then only reading the bodies of symbols that are necessary to complete the task at hand. You can use the standard tools like list_dir, find_file and search_for_pattern if you need to. Where appropriate, you pass the `relative_path` parameter to restrict the search to a specific file or directory. {% if 'search_for_pattern' in available_tools %} If you are unsure about a symbol's name or location{% if 'find_symbol' in available_tools %} (to the extent that substring_matching for the symbol name is not enough){% endif %}, you can use the `search_for_pattern` tool, which allows fast and flexible search for patterns in the codebase.{% if 'ToolMarkerSymbolicRead' in available_markers %} In this way, you can first find candidates for symbols or files, and then proceed with the symbolic tools.{% endif %} {% endif %} {% if 'ToolMarkerSymbolicRead' in available_markers %} Symbols are identified by their `name_path` and `relative_path` (see the description of the `find_symbol` tool). You can get information about the symbols in a file by using the `get_symbols_overview` tool or use the `find_symbol` to search. You only read the bodies of symbols when you need to (e.g. if you want to fully understand or edit it). For example, if you are working with Python code and already know that you need to read the body of the constructor of the class Foo, you can directly use `find_symbol` with name path pattern `Foo/__init__` and `include_body=True`. If you don't know yet which methods in `Foo` you need to read or edit, you can use `find_symbol` with name path pattern `Foo`, `include_body=False` and `depth=1` to get all (top-level) methods of `Foo` before proceeding to read the desired methods with `include_body=True`. You can understand relationships between symbols by using the `find_referencing_symbols` tool. {% endif %} {% if 'read_memory' in available_tools -%} You generally have access to memories and it may be useful for you to read them. You infer whether memories are relevant based on their names. {% if global_memories_list -%} The following global (not project-specific) memories are available to you: {{ global_memories_list }} {%- endif -%} {%- endif %} The context and modes of operation are described below. These determine how to interact with your user and which kinds of interactions are expected of you. Context description: {{ context_system_prompt }} Modes descriptions: {% for prompt in mode_system_prompts %} {{ prompt }} {% endfor %} You have hereby read the 'Serena Instructions Manual' and do not need to read it again. ================================================ FILE: src/serena/resources/dashboard/dashboard.css ================================================ html { scrollbar-gutter: stable; /* Prevent layout shift when scrollbar appears */ } :root { /* Light theme variables */ --bg-primary: #f5f5f5; --bg-secondary: #ffffff; --text-primary: #000000; --text-secondary: #333333; --text-muted: #666666; --border-color: #ddd; --btn-primary: #eaa45d; --btn-hover: #dca662; --btn-disabled: #6c757d; --shadow: 0 2px 4px rgba(0, 0, 0, 0.1); --tool-highlight: #ffff00; --tool-highlight-text: #000000; --log-debug: #808080; --log-info: #000000; --log-warning: #FF8C00; --log-error: #FF0000; --stats-header: #f8f9fa; --header-height: 150px; --header-padding: 20px; --header-gap-main: 25px; --frame-padding: 25px; --border-radius: 5px; } [data-theme="dark"] { /* Dark theme variables */ --bg-primary: #1a1a1a; --bg-secondary: #2d2d2d; --text-primary: #ffffff; --text-secondary: #e0e0e0; --text-muted: #b0b0b0; --border-color: #444; --btn-primary: #eaa45d; --btn-hover: #dca662; --btn-disabled: #6c757d; --shadow: 0 2px 4px rgba(0, 0, 0, 0.3); --tool-highlight: #ffd700; --tool-highlight-text: #000000; --log-debug: #808080; --log-info: #ffffff; --log-warning: #FF8C00; --log-error: #FF0000; --stats-header: #3a3a3a; } .news-section { background: var(--bg-secondary); padding: 20px; border-radius: var(--border-radius); box-shadow: var(--shadow); margin-bottom: 25px; } .news-section h2 { margin: 0 0 20px 0; font-size: 18px; color: var(--text-primary); } .news-item { padding: 20px; border: 1px solid var(--border-color); border-radius: var(--border-radius); margin-bottom: 15px; background: var(--bg-primary); position: relative; } .news-item:last-child { margin-bottom: 0; } .news-item h3 { margin: 0 0 10px 0; font-size: 16px; color: var(--text-primary); } .news-item .date { color: var(--text-muted); font-size: 13px; margin: 0 0 15px 0; } .news-item p { margin: 10px 0; line-height: 1.6; color: var(--text-secondary); } .news-item ul { margin: 10px 0; padding-left: 25px; color: var(--text-secondary); } .news-item ul li { margin: 8px 0; line-height: 1.5; } .news-item strong { color: var(--text-primary); } .news-mark-read { position: absolute; top: 20px; right: 20px; } .news-mark-read-btn { background-color: var(--btn-primary); color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 13px; transition: background-color 0.3s ease; white-space: nowrap; } .news-mark-read-btn:hover { background-color: var(--btn-hover); } .news-mark-read-btn:disabled { background-color: var(--btn-disabled); cursor: not-allowed; } .news-no-items { color: var(--text-muted); font-style: italic; text-align: center; padding: 20px; } body { font-family: 'Consolas', 'Monaco', 'Courier New', monospace; margin: 0; background-color: var(--bg-primary); color: var(--text-primary); transition: background-color 0.3s ease, color 0.3s ease; } #frame { max-width: 1600px; margin: 0 auto; padding: var(--frame-padding); padding-top: 0; min-width: 1280px; } .main { padding-top: var(--header-gap-main); } .header { top: 0; left: 0; right: 0; height: var(--header-height); background-color: var(--bg-secondary); border-bottom: 1px solid var(--border-color); border-bottom-left-radius: var(--border-radius); border-bottom-right-radius: var(--border-radius); padding: var(--header-padding); display: flex; justify-content: space-between; align-items: center; gap: 20px; z-index: 1000; transition: background-color 0.3s ease, border-color 0.3s ease; min-height: 90px; box-shadow: var(--shadow); max-width: 1600px; margin: 0 auto; } .header-left { display: flex; gap: 30px; } .logo-container { height: var(--header-height); order: 1; flex-shrink: 0; } .logo-container img { height: calc(var(--header-height) - 20px); margin-top: 10px; display: block; } .header-banner { position: relative; top: 0; left: 0; order: 2; height: var(--header-height); max-height: var(--header-height); } .header-nav { position: relative; display: flex; flex-direction: column; align-items: flex-end; justify-content: space-between; height: 100%; flex-shrink: 0; } .header-actions { position: relative; display: flex; align-items: center; gap: 10px; } .header-tabs { display: flex; align-items: flex-end; gap: 0; } .header-tab { display: flex; align-items: center; padding: 8px 20px; color: var(--text-muted); text-decoration: none; font-size: 15px; font-weight: 500; border-bottom: 3px solid transparent; transition: color 0.2s ease, border-color 0.2s ease; white-space: nowrap; } .header-tab:hover { color: var(--text-primary); } .header-tab.active { color: var(--btn-primary); border-bottom-color: var(--btn-primary); } .menu-button { background-color: var(--bg-secondary); color: var(--text-primary); border: 1px solid var(--border-color); padding: 8px 16px; border-radius: 4px; cursor: pointer; font-size: 16px; transition: background-color 0.3s ease, border-color 0.3s ease; display: flex; align-items: center; gap: 8px; } .menu-button:hover { background-color: var(--border-color); } .menu-dropdown { position: absolute; top: 100%; margin-top: 6px; right: 0; background-color: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 4px; box-shadow: var(--shadow); min-width: 200px; z-index: 999; transition: background-color 0.3s ease, border-color 0.3s ease; } .menu-dropdown a { display: block; padding: 12px 20px; color: var(--text-primary); text-decoration: none; transition: background-color 0.3s ease; } .menu-dropdown a:hover { background-color: var(--border-color); } .menu-dropdown a.active { background-color: var(--btn-primary); color: white; } .menu-dropdown hr { border: none; border-top: 1px solid var(--border-color); margin: 5px 0; } .platinum-banner-slide { display: none; pointer-events: none; height: 100%; } .platinum-banner-slide.active { display: block; pointer-events: auto; } .banner-image { object-fit: contain; border-radius: var(--border-radius); } .banner-border { border: 1px solid var(--border-color); } .platinum-banner-slide .banner-image { max-height: 100%; object-fit: contain; } .gold-banners-section { margin: 0 auto; width: 100%; position: relative; align-items: center; justify-content: center; padding: 0; } .gold-banner { position: relative; width: 100%; overflow: hidden; } .gold-banner-slide { display: none; pointer-events: none; } .gold-banner-slide.active { display: block; pointer-events: auto; } .gold-banner-slide .banner-image { max-width: 100%; object-fit: contain; } /* Banner Arrow Navigation */ .banner-arrow { position: absolute; top: 50%; transform: translateY(-50%); z-index: 10; background: rgba(128, 128, 128, 0.15); color: var(--text-muted); border: none; font-size: 18px; line-height: 1; width: 24px; height: 32px; display: flex; align-items: center; justify-content: center; cursor: pointer; border-radius: 4px; opacity: 0.3; transition: opacity 0.2s ease, background-color 0.2s ease, color 0.2s ease; padding: 0; } .banner-arrow:hover { opacity: 0.8; background: rgba(128, 128, 128, 0.4); color: var(--text-primary); } .banner-arrow-left { left: 0; } .banner-arrow-right { right: 0; } .page-view { /*max-width: 1600px;*/ margin: 0 auto; } /* Overview Page Layout */ .overview-container { display: grid; grid-template-columns: 1fr 400px; gap: 20px; } .overview-left { min-width: 0; } .overview-right { min-width: 0; } /* Overview Page Styles */ .config-section, .basic-stats-section, .projects-section { background-color: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: var(--border-radius); padding: 20px; margin-bottom: 20px; transition: background-color 0.3s ease, border-color 0.3s ease; } .config-section h2, .basic-stats-section h2 { margin-top: 0; color: var(--text-secondary); } /* Collapsible Headers */ .collapsible-header { margin-top: 0; margin-bottom: 0; font-size: 18px; color: var(--text-secondary); cursor: pointer; user-select: none; display: flex; justify-content: space-between; align-items: center; } .collapsible-header:hover { color: var(--text-primary); } .toggle-icon { transition: transform 0.3s ease; font-size: 14px; } .toggle-icon.expanded { transform: rotate(-180deg); } .collapsible-content { margin-top: 15px; max-height: 400px; overflow-y: auto; } .config-grid { display: grid; grid-template-columns: 180px 1fr; gap: 12px; margin-bottom: 20px; } .config-label { font-weight: bold; color: var(--text-secondary); } .config-value { color: var(--text-primary); } .config-list { list-style: none; padding: 0; margin: 0; } .config-list li { padding: 4px 0; } .tools-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px; margin-top: 10px; } .tool-item { background-color: var(--bg-primary); padding: 6px 10px; border-radius: 3px; font-size: 13px; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: default; } /* Projects List */ .project-item { padding: 10px 12px; margin: 5px 0; border-radius: 4px; background-color: var(--bg-primary); border: 1px solid var(--border-color); transition: background-color 0.2s ease; } .project-item:hover { background-color: var(--border-color); } .project-item.active { background-color: var(--btn-primary); color: white; border-color: var(--btn-primary); } .project-name { font-weight: bold; margin-bottom: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .project-path { font-size: 11px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .project-item.active .project-path { color: rgba(255, 255, 255, 0.8); } /* Generic Item Styles for Tools/Modes/Contexts */ .info-item { padding: 8px 12px; margin: 5px 0; border-radius: 4px; background-color: var(--bg-primary); border: 1px solid var(--border-color); transition: background-color 0.2s ease; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: default; } .info-item:hover { background-color: var(--border-color); } .info-item.active { background-color: var(--btn-primary); color: white; border-color: var(--btn-primary); font-weight: bold; } /* Basic Stats Styles */ .basic-stats-section { background-color: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 5px; padding: 20px; margin-bottom: 20px; transition: background-color 0.3s ease, border-color 0.3s ease; } .basic-stats-section h2 { margin-top: 0; color: var(--text-secondary); } /* Executions Styles */ .executions-section { background-color: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 5px; padding: 20px; margin-bottom: 20px; transition: background-color 0.3s ease, border-color 0.3s ease; } .executions-section h2 { margin-top: 0; color: var(--text-secondary); } .execution-list { display: flex; flex-direction: column; gap: 8px; } .execution-item { display: flex; align-items: center; gap: 10px; background-color: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 20px; padding: 8px 12px; min-height: 40px; transition: background-color 0.2s ease, border-color 0.2s ease; } .execution-item.running { border-color: var(--btn-primary); background: linear-gradient(to right, rgba(234, 164, 93, 0.1), var(--bg-primary)); } .execution-item.cancelled { border-color: var(--text-muted); background-color: var(--bg-primary); opacity: 0.7; } .execution-item.abandoned { border-color: var(--log-error); background: linear-gradient(to right, rgba(255, 0, 0, 0.1), var(--bg-primary)); } .execution-spinner { width: 16px; height: 16px; border: 2px solid var(--border-color); border-top-color: var(--btn-primary); border-radius: 50%; animation: spin 0.7s linear infinite; flex-shrink: 0; } @keyframes spin { to { transform: rotate(360deg); } } .execution-name { flex: 1; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text-primary); } .execution-meta { font-size: 11px; color: var(--text-muted); flex-shrink: 0; } .execution-cancel-btn { background: none; border: none; color: var(--text-muted); font-size: 16px; cursor: pointer; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; border-radius: 50%; transition: background-color 0.15s ease, color 0.15s ease; flex-shrink: 0; } .execution-cancel-btn:hover { background-color: rgba(255, 0, 0, 0.1); color: var(--log-error); } .execution-icon { width: 16px; height: 16px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 10px; flex-shrink: 0; } .execution-icon.success { background-color: rgba(34, 197, 94, 0.2); border: 1px solid rgba(34, 197, 94, 0.6); color: #22c55e; } .execution-icon.cancelled { background-color: var(--border-color); border: 1px solid var(--text-muted); color: var(--text-muted); } .execution-icon.abandoned { background-color: rgba(255, 0, 0, 0.2); border: 1px solid var(--log-error); color: var(--log-error); } .execution-icon.error { background-color: rgba(255, 0, 0, 0.2); border: 1px solid var(--log-error); color: var(--log-error); } .last-execution-container { display: flex; align-items: center; gap: 12px; background: linear-gradient(to right, rgba(34, 197, 94, 0.08), transparent); border: 1px solid rgba(34, 197, 94, 0.2); border-radius: 8px; padding: 12px; } .last-execution-container.error { background: linear-gradient(to right, rgba(255, 0, 0, 0.08), transparent); border-color: rgba(255, 0, 0, 0.2); } .last-execution-icon-container { width: 28px; height: 28px; background-color: rgba(34, 197, 94, 0.2); border: 1px solid rgba(34, 197, 94, 0.6); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 14px; color: #22c55e; flex-shrink: 0; } .last-execution-container.error .last-execution-icon-container { background-color: rgba(255, 0, 0, 0.2); border-color: var(--log-error); color: var(--log-error); } .last-execution-body { flex: 1; } .last-execution-status { font-size: 11px; color: var(--text-muted); margin-bottom: 2px; } .last-execution-name { font-size: 13px; color: var(--text-primary); } .stat-bar-container { display: flex; align-items: center; margin: 8px 0; gap: 12px; } .stat-tool-name { min-width: 200px; max-width: 200px; font-weight: bold; color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: default; } .bar-wrapper { flex: 1; height: 24px; background-color: var(--border-color); border-radius: 3px; overflow: hidden; position: relative; } .bar { height: 100%; background-color: var(--btn-primary); transition: width 0.5s ease; border-radius: 3px; } .stat-count { min-width: 60px; text-align: right; font-weight: bold; color: var(--text-primary); } .no-stats-message { text-align: center; color: var(--text-muted); font-style: italic; padding: 20px; } /* Log Container Styles */ .log-container { background-color: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 5px; height: calc(100vh - var(--header-height) - 2 * var(--header-padding) - 3 * var(--header-gap-main)); overflow-y: auto; overflow-x: auto; padding: 10px; white-space: pre-wrap; font-size: 14px; line-height: 1.4; color: var(--text-primary); transition: background-color 0.3s ease, border-color 0.3s ease, color 0.3s ease; } .controls { position: sticky; top: 90px; z-index: 100; background-color: var(--bg-primary); padding: 10px 0; margin-bottom: 10px; text-align: center; display: flex; justify-content: center; align-items: center; gap: 10px; flex-wrap: wrap; transition: background-color 0.3s ease; } .btn { background-color: var(--btn-primary); color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; font-size: 14px; transition: background-color 0.3s ease; } .btn:hover { background-color: var(--btn-hover); } .btn:disabled { background-color: var(--btn-disabled); cursor: not-allowed; } .theme-toggle { display: flex; align-items: center; gap: 5px; background-color: var(--bg-secondary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 8px 16px; cursor: pointer; font-size: 16px; transition: background-color 0.3s ease, border-color 0.3s ease; } .theme-toggle:hover { background-color: var(--border-color); } .theme-toggle span { line-height: 1; } .log-debug { color: var(--log-debug); } .log-info { color: var(--log-info); } .log-warning { color: var(--log-warning); } .log-error { color: var(--log-error); } .log-default { color: var(--log-info); } /* Tool name highlighting */ .tool-name { background-color: var(--tool-highlight); color: var(--tool-highlight-text); font-weight: bold; } .loading { text-align: center; color: var(--text-muted); font-style: italic; } .error-message { color: var(--log-error); text-align: center; margin: 10px 0; } /* Advanced Stats Styles */ .charts-container { display: flex; flex-wrap: wrap; gap: 15px; justify-content: space-between; max-width: 1400px; margin: 0 auto; } .chart-group { flex: 1; min-width: 280px; max-width: 320px; text-align: center; } .chart-wide { flex: 0 0 100%; min-width: 100%; margin-top: 10px; } .chart-group h3 { margin: 0 0 10px 0; color: var(--text-secondary); } .stats-summary { margin: 0 auto; border-collapse: collapse; background: var(--bg-secondary); border-radius: 5px; overflow: hidden; box-shadow: var(--shadow); transition: background-color 0.3s ease, box-shadow 0.3s ease; } .stats-summary th, .stats-summary td { padding: 10px 20px; text-align: left; border-bottom: 1px solid var(--border-color); color: var(--text-primary); transition: border-color 0.3s ease, color 0.3s ease; } .stats-summary th { background-color: var(--stats-header); font-weight: bold; transition: background-color 0.3s ease; } .stats-summary tr:last-child td { border-bottom: none; } @media (max-width: 1024px) { .overview-container { grid-template-columns: 1fr; } .overview-right { order: -1; } } @media (max-width: 768px) { body { padding-top: 140px; } .header { flex-direction: column; gap: 10px; padding: 10px 15px; } .logo-container { width: 100%; text-align: center; } .logo-container img { max-width: 200px; } .header-nav { width: 100%; align-items: center; } .header-tabs { justify-content: center; } .header-actions { justify-content: center; } .charts-container { flex-direction: column; } .chart-group, .chart-wide { min-width: auto; max-width: none; } .controls { flex-direction: column; gap: 5px; } .config-grid { grid-template-columns: 1fr; } .tools-grid { grid-template-columns: 1fr; } .stat-bar-container { flex-wrap: wrap; } .stat-tool-name { min-width: 100%; } } /* Modal Styles */ .modal { position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgba(0, 0, 0, 0.5); } .modal-content { background-color: var(--bg-primary); margin: 10% auto; padding: 25px; border: 1px solid var(--border-color); border-radius: 8px; width: 90%; max-width: 500px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); position: relative; } .modal-close { color: var(--text-muted); float: right; font-size: 28px; font-weight: bold; line-height: 20px; cursor: pointer; transition: color 0.2s; } .modal-close:hover, .modal-close:focus { color: var(--text-primary); } .modal h3 { margin-top: 0; margin-bottom: 15px; color: var(--text-primary); } /* Language Badge Styles */ .languages-container { display: flex; flex-wrap: wrap; gap: 8px; } .language-badge { position: relative; display: inline-flex; align-items: center; padding: 6px 12px; background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 6px; color: var(--text-primary); font-size: 13px; font-weight: 500; } .language-badge.removable { padding-right: 28px; } .language-remove { position: absolute; top: 2px; right: 2px; width: 18px; height: 18px; display: flex; align-items: center; justify-content: center; background: rgba(255, 68, 68, 0.1); border-radius: 3px; cursor: pointer; color: #ff4444; font-size: 14px; font-weight: bold; line-height: 1; transition: all 0.2s; } .language-remove:hover { background: rgba(255, 68, 68, 0.2); transform: scale(1.1); } .language-add-btn { padding: 6px 12px; font-size: 13px; font-weight: 500; border-radius: 6px; border: 1px dashed var(--border-color); background: var(--bg-secondary); color: var(--text-primary); cursor: pointer; transition: all 0.2s; } .language-add-btn:hover { background: var(--border-color); border-color: var(--btn-primary); color: var(--btn-primary); } .memory-add-btn { display: inline-flex; align-items: center; padding: 8px 12px; margin: 5px; border-radius: 4px; border: 1px dashed var(--border-color); background: var(--bg-secondary); color: var(--text-primary); cursor: pointer; transition: all 0.2s; font-family: inherit; font-size: inherit; font-weight: inherit; line-height: inherit; } .memory-add-btn:hover { background: var(--border-color); border-color: var(--btn-primary); color: var(--btn-primary); } .language-spinner { display: inline-flex; align-items: center; justify-content: center; padding: 6px 12px; } .spinner { width: 16px; height: 16px; border: 2px solid var(--border-color); border-top-color: var(--btn-primary); border-radius: 50%; animation: spin 0.8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } /* Memory Editor Styles */ .modal-content-large { max-width: 800px; width: 90%; } .memory-editor { font-family: 'Courier New', monospace; font-size: 13px; line-height: 1.5; tab-size: 4; -moz-tab-size: 4; } .memory-editor:focus { outline: 2px solid var(--btn-primary); outline-offset: -1px; } /* Memory Item Styles */ .memory-item { position: relative; display: inline-flex; align-items: center; padding: 8px 12px; margin: 5px; border-radius: 4px; background-color: var(--bg-primary); border: 1px solid var(--border-color); transition: background-color 0.2s ease; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .memory-item:hover { background-color: var(--border-color); text-decoration: underline; } .memory-item.removable { padding-right: 28px; } .memory-remove { position: absolute; top: 2px; right: 2px; width: 18px; height: 18px; display: flex; align-items: center; justify-content: center; background: rgba(255, 68, 68, 0.1); border-radius: 3px; cursor: pointer; color: #ff4444; font-size: 14px; font-weight: bold; line-height: 1; transition: all 0.2s; } .memory-remove:hover { background: rgba(255, 68, 68, 0.2); transform: scale(1.1); text-decoration: none; } .memories-container { display: flex; flex-wrap: wrap; gap: 8px; } /* Memory Rename Styles */ .memory-rename-btn { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 4px; cursor: pointer; color: var(--text-muted); opacity: 0.5; transition: opacity 0.2s ease, background-color 0.2s ease; } .memory-rename-btn:hover { opacity: 1; background-color: var(--border-color); } .memory-rename-input { font-size: inherit; font-weight: inherit; font-family: inherit; color: var(--text-primary); background: transparent; border: none; border-bottom: 1px solid var(--btn-primary); outline: none; padding: 0; flex: 1; min-width: 200px; max-width: 80%; } /* Log Action Buttons (Save, Copy, Clear) */ .log-action-buttons { position: absolute; top: 15px; right: 20px; z-index: 10; display: flex; gap: 8px; align-items: center; } .log-action-btn { display: flex; align-items: center; gap: 6px; padding: 8px 12px; background-color: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 4px; color: var(--text-primary); cursor: pointer; opacity: 0.8; transition: opacity 0.2s ease, background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease; font-size: 13px; font-weight: 500; } .log-action-btn:hover { opacity: 1; background-color: var(--border-color); } .log-action-btn svg { flex-shrink: 0; } .log-action-btn-text { display: none; white-space: nowrap; } .log-action-btn:hover .log-action-btn-text { display: inline; } .log-action-btn:disabled { opacity: 0.35; cursor: not-allowed; } .log-action-btn-danger:hover { background-color: var(--border-color); border-color: rgba(220, 53, 53, 0.9); color: var(--log-error); } ================================================ FILE: src/serena/resources/dashboard/dashboard.js ================================================ class LogMessage { constructor(message, toolNames) { message = this.escapeHtml(message); const logLevel = this.determineLogLevel(message); const highlightedMessage = this.highlightToolNames(message, toolNames); this.$elem = $('
').addClass('log-' + logLevel).html(highlightedMessage + '\n'); } determineLogLevel(message) { if (message.startsWith('DEBUG')) { return 'debug'; } else if (message.startsWith('INFO')) { return 'info'; } else if (message.startsWith('WARNING')) { return 'warning'; } else if (message.startsWith('ERROR')) { return 'error'; } else { return 'default'; } } highlightToolNames(message, toolNames) { let highlightedMessage = message; toolNames.forEach(function (toolName) { const regex = new RegExp('\\b' + toolName + '\\b', 'gi'); highlightedMessage = highlightedMessage.replace(regex, '' + toolName + ''); }); return highlightedMessage; } escapeHtml(convertString) { if (typeof convertString !== 'string') return convertString; const patterns = { '<': '<', '>': '>', '&': '&', '"': '"', '\'': ''', '`': '`' }; return convertString.replace(/[<>&"'`]/g, match => patterns[match]); }; } function updateThemeAwareImage($img, theme=null) { if (!theme) { const isDarkMode = $('html').data("theme") == 'dark'; theme = isDarkMode ? 'dark' : 'light'; } console.log("updating theme-aware image to theme:", theme); const newSrc = $img.data('src-' + theme); if (newSrc) { $img.attr('src', newSrc); } } /** * Manages banner loading, display, and navigation. * * When automaticRotationEnabled is true, banners rotate on a timer and arrow * buttons are hidden. When false (the current default), a random initial * banner is shown and the user navigates manually via arrow buttons. */ class BannerRotation { constructor() { this.automaticRotationEnabled = false; this.platinumIndex = 0; this.goldIndex = 0; this.platinumTimer = null; this.goldTimer = null; this.platinumInterval = 15000; this.goldInterval = 15000; this.init(); } init() { let self = this; this.loadBanners(function() { self.randomizeInitialBanner('platinum'); self.randomizeInitialBanner('gold'); if (self.automaticRotationEnabled) { self.startPlatinumRotation(); self.startGoldRotation(); // Hide arrows entirely when rotation is automatic $('.banner-arrow').hide(); } else { self.hideArrowsIfSingle(); self.bindArrowButtons(); } }); } loadBanners(onSuccess) { $.ajax({ url: 'https://oraios-software.de/serena-banners/manifest.php', type: 'GET', success: function (response) { console.log('Banners loaded:', response); function fillBanners($container, banners, className) { $.each(banners, function (index, banner) { let $img = $(''); if (banner.image_dark) { $img.addClass('theme-aware-img'); $img.attr('data-src-dark', banner.image_dark); $img.attr('data-src-light', banner.image); updateThemeAwareImage($img); } let $anchor = $(''); $anchor.append($img); let $banner = $('
'); $banner.append($anchor); if (index === 0) { $banner.addClass('active'); } if (banner.border) { $img.addClass('banner-border'); } $container.append($banner); }); } fillBanners($('#gold-banners'), response.gold, 'gold-banner'); fillBanners($('#platinum-banners'), response.platinum, 'platinum-banner'); onSuccess(); }, error: function (xhr, status, error) { console.error('Error loading banners:', error); } }); } startPlatinumRotation() { const self = this; this.platinumTimer = setInterval(() => { self.rotatePlatinum('next'); }, this.platinumInterval); } randomizeInitialBanner(type) { const slideClass = type === 'platinum' ? '.platinum-banner-slide' : '.gold-banner-slide'; const $slides = $(slideClass); const total = $slides.length; if (total === 0) return; const randomIndex = Math.floor(Math.random() * total); if (type === 'platinum') { this.platinumIndex = randomIndex; } else { this.goldIndex = randomIndex; } $slides.removeClass('active'); $slides.eq(randomIndex).addClass('active'); } startGoldRotation() { const self = this; this.goldTimer = setInterval(() => { self.rotateGold('next'); }, this.goldInterval); } hideArrowsIfSingle() { if ($('.platinum-banner-slide').length <= 1) { $('#platinum-banners .banner-arrow').hide(); } if ($('.gold-banner-slide').length <= 1) { $('#gold-banners .banner-arrow').hide(); } } bindArrowButtons() { let self = this; $('.banner-arrow').on('click', function(e) { e.preventDefault(); e.stopPropagation(); const target = $(this).data('target'); const direction = $(this).hasClass('banner-arrow-right') ? 'next' : 'prev'; if (target === 'platinum') { self.rotatePlatinum(direction); } else { self.rotateGold(direction); } }); } rotatePlatinum(direction) { const $slides = $('.platinum-banner-slide'); const total = $slides.length; if (total === 0) return; // Remove active class from current slide $slides.eq(this.platinumIndex).removeClass('active'); // Calculate next index if (direction === 'next') { this.platinumIndex = (this.platinumIndex + 1) % total; } else { this.platinumIndex = (this.platinumIndex - 1 + total) % total; } // Add active class to new slide $slides.eq(this.platinumIndex).addClass('active'); // Reset timer when in automatic rotation mode if (this.automaticRotationEnabled) { clearInterval(this.platinumTimer); this.startPlatinumRotation(); } } rotateGold(direction) { const $groups = $('.gold-banner-slide'); const total = $groups.length; if (total === 0) return; // Remove active class from current group $groups.eq(this.goldIndex).removeClass('active'); // Calculate next index if (direction === 'next') { this.goldIndex = (this.goldIndex + 1) % total; } else { this.goldIndex = (this.goldIndex - 1 + total) % total; } // Add active class to new group $groups.eq(this.goldIndex).addClass('active'); // Reset timer when in automatic rotation mode if (this.automaticRotationEnabled) { clearInterval(this.goldTimer); this.startGoldRotation(); } } } class Dashboard { constructor() { let self = this; // Page state this.currentPage = 'overview'; this.configData = null; this.lastConfigDataJson = null; // Cache for comparison this.jetbrainsMode = false; this.activeProjectName = null; this.languageToRemove = null; this.currentMemoryName = null; this.originalMemoryContent = null; this.memoryContentDirty = false; this.memoryToDelete = null; this.isAddingLanguage = false; this.waitingForConfigPollingResult = false; this.waitingForExecutionsPollingResult = false; this.originalSerenaConfigContent = null; this.serenaConfigContentDirty = false; // Execution tracking this.cancelledExecutions = []; this.executionToCancel = null; // Tool names and stats this.toolNames = []; this.currentMaxIdx = -1; this.pollInterval = null; this.configPollInterval = null; this.executionsPollInterval = null; this.heartbeatFailureCount = 0; // jQuery elements this.$logContainer = $('#log-container'); this.$errorContainer = $('#error-container'); this.$saveLogsBtn = $('#save-logs-btn'); this.$copyLogsBtn = $('#copy-logs-btn'); this.$clearLogsBtn = $('#clear-logs-btn'); this.$menuToggle = $('#menu-toggle'); this.$menuDropdown = $('#menu-dropdown'); this.$menuShutdown = $('#menu-shutdown'); this.$themeToggle = $('#theme-toggle'); this.$themeIcon = $('#theme-icon'); this.$themeText = $('#theme-text'); this.$configDisplay = $('#config-display'); this.$basicStatsDisplay = $('#basic-stats-display'); this.$statsSection = $('#stats-section'); this.$refreshStats = $('#refresh-stats'); this.$clearStats = $('#clear-stats'); this.$projectsDisplay = $('#projects-display'); this.$projectsHeader = $('#projects-header'); this.$availableToolsDisplay = $('#available-tools-display'); this.$availableModesDisplay = $('#available-modes-display'); this.$availableContextsDisplay = $('#available-contexts-display'); this.$addLanguageModal = $('#add-language-modal'); this.$modalLanguageSelect = $('#modal-language-select'); this.$modalProjectName = $('#modal-project-name'); this.$modalAddBtn = $('#modal-add-btn'); this.$modalCancelBtn = $('#modal-cancel-btn'); this.$modalClose = $('.modal-close'); this.$removeLanguageModal = $('#remove-language-modal'); this.$removeLanguageName = $('#remove-language-name'); this.$removeModalOkBtn = $('#remove-modal-ok-btn'); this.$removeModalCancelBtn = $('#remove-modal-cancel-btn'); this.$modalCloseRemove = $('.modal-close-remove'); this.$editMemoryModal = $('#edit-memory-modal'); this.$editMemoryName = $('#edit-memory-name'); this.$editMemoryRenameBtn = $('#edit-memory-rename-btn'); this.$editMemoryRenameInput = $('#edit-memory-rename-input'); this.$editMemoryContent = $('#edit-memory-content'); this.$editMemorySaveBtn = $('#edit-memory-save-btn'); this.$editMemoryCancelBtn = $('#edit-memory-cancel-btn'); this.$modalCloseEditMemory = $('.modal-close-edit-memory'); this.$deleteMemoryModal = $('#delete-memory-modal'); this.$deleteMemoryName = $('#delete-memory-name'); this.$deleteMemoryOkBtn = $('#delete-memory-ok-btn'); this.$deleteMemoryCancelBtn = $('#delete-memory-cancel-btn'); this.$modalCloseDeleteMemory = $('.modal-close-delete-memory'); this.$createMemoryModal = $('#create-memory-modal'); this.$createMemoryProjectName = $('#create-memory-project-name'); this.$createMemoryNameInput = $('#create-memory-name-input'); this.$createMemoryCreateBtn = $('#create-memory-create-btn'); this.$createMemoryCancelBtn = $('#create-memory-cancel-btn'); this.$modalCloseCreateMemory = $('.modal-close-create-memory'); this.$activeExecutionQueueDisplay = $('#active-executions-display'); this.$lastExecutionDisplay = $('#last-execution-display'); this.$cancelledExecutionsDisplay = $('#cancelled-executions-display'); this.$cancelExecutionModal = $('#cancel-execution-modal'); this.$cancelExecutionOkBtn = $('#cancel-execution-ok-btn'); this.$cancelExecutionCancelBtn = $('#cancel-execution-cancel-btn'); this.$modalCloseCancelExecution = $('.modal-close-cancel-execution'); this.$editSerenaConfigModal = $('#edit-serena-config-modal'); this.$editSerenaConfigContent = $('#edit-serena-config-content'); this.$editSerenaConfigSaveBtn = $('#edit-serena-config-save-btn'); this.$editSerenaConfigCancelBtn = $('#edit-serena-config-cancel-btn'); this.$modalCloseEditSerenaConfig = $('.modal-close-edit-serena-config'); this.$newsSection = $('#news-section'); this.$newsDisplay = $('#news-display'); // Chart references this.countChart = null; this.tokensChart = null; this.inputChart = null; this.outputChart = null; // Register event handlers this.$saveLogsBtn.click(this.saveLogs.bind(this)); this.$copyLogsBtn.click(this.copyLogs.bind(this)); this.$clearLogsBtn.click(this.clearLogs.bind(this)); this.$menuShutdown.click(function (e) { e.preventDefault(); self.shutdown(); }); this.$menuToggle.click(this.toggleMenu.bind(this)); this.$themeToggle.click(this.toggleTheme.bind(this)); this.$refreshStats.click(this.loadStats.bind(this)); this.$clearStats.click(this.clearStats.bind(this)); this.$modalAddBtn.click(this.addLanguageFromModal.bind(this)); this.$modalCancelBtn.click(this.closeLanguageModal.bind(this)); this.$modalClose.click(this.closeLanguageModal.bind(this)); this.$removeModalOkBtn.click(this.confirmRemoveLanguageOk.bind(this)); this.$removeModalCancelBtn.click(this.closeRemoveLanguageModal.bind(this)); this.$modalCloseRemove.click(this.closeRemoveLanguageModal.bind(this)); this.$editMemorySaveBtn.click(this.saveMemoryFromModal.bind(this)); this.$editMemoryCancelBtn.click(this.closeEditMemoryModal.bind(this)); this.$modalCloseEditMemory.click(this.closeEditMemoryModal.bind(this)); this.$editMemoryContent.on('input', this.trackMemoryChanges.bind(this)); this.$editMemoryRenameBtn.click(this.startMemoryRename.bind(this)); this.$editMemoryRenameInput.keydown(function (e) { if (e.which === 13) { // Enter key e.preventDefault(); self.commitMemoryRename(); } else if (e.which === 27) { // Escape key e.preventDefault(); self.cancelMemoryRename(); } }); this.$editMemoryRenameInput.on('blur', function () { self.cancelMemoryRename(); }); this.$deleteMemoryOkBtn.click(this.confirmDeleteMemoryOk.bind(this)); this.$deleteMemoryCancelBtn.click(this.closeDeleteMemoryModal.bind(this)); this.$modalCloseDeleteMemory.click(this.closeDeleteMemoryModal.bind(this)); this.$createMemoryCreateBtn.click(this.createMemoryFromModal.bind(this)); this.$createMemoryCancelBtn.click(this.closeCreateMemoryModal.bind(this)); this.$modalCloseCreateMemory.click(this.closeCreateMemoryModal.bind(this)); this.$createMemoryNameInput.keypress(function (e) { if (e.which === 13) { // Enter key e.preventDefault(); self.createMemoryFromModal(); } }); this.$cancelExecutionOkBtn.click(this.confirmCancelExecutionOk.bind(this)); this.$cancelExecutionCancelBtn.click(this.closeCancelExecutionModal.bind(this)); this.$modalCloseCancelExecution.click(this.closeCancelExecutionModal.bind(this)); this.$editSerenaConfigSaveBtn.click(this.saveSerenaConfigFromModal.bind(this)); this.$editSerenaConfigCancelBtn.click(this.closeEditSerenaConfigModal.bind(this)); this.$modalCloseEditSerenaConfig.click(this.closeEditSerenaConfigModal.bind(this)); // Page navigation $('[data-page]').click(function (e) { e.preventDefault(); const page = $(this).data('page'); self.navigateToPage(page); }); // Close menu when clicking outside $(document).click(function (e) { if (!$(e.target).closest('.header-nav').length) { self.$menuDropdown.hide(); } }); // Close modals when clicking outside this.$addLanguageModal.click(function (e) { if ($(e.target).hasClass('modal')) { self.closeLanguageModal(); } }); this.$removeLanguageModal.click(function (e) { if ($(e.target).hasClass('modal')) { self.closeRemoveLanguageModal(); } }); this.$editMemoryModal.click(function (e) { if ($(e.target).hasClass('modal')) { self.closeEditMemoryModal(); } }); this.$deleteMemoryModal.click(function (e) { if ($(e.target).hasClass('modal')) { self.closeDeleteMemoryModal(); } }); this.$createMemoryModal.click(function (e) { if ($(e.target).hasClass('modal')) { self.closeCreateMemoryModal(); } }); this.$editSerenaConfigModal.click(function (e) { if ($(e.target).hasClass('modal')) { self.closeEditSerenaConfigModal(); } }); // Collapsible sections $('.collapsible-header').click(function () { const $header = $(this); const $content = $header.next('.collapsible-content'); const $icon = $header.find('.toggle-icon'); $content.slideToggle(300); $icon.toggleClass('expanded'); }); // Initialize theme this.initializeTheme(); // Initialize banner rotation this.bannerRotation = new BannerRotation(); // Add ESC key handler for closing modals $(document).keydown(function (e) { if (e.key === 'Escape' || e.keyCode === 27) { if (self.$addLanguageModal.is(':visible')) { self.closeLanguageModal(); } else if (self.$removeLanguageModal.is(':visible')) { self.closeRemoveLanguageModal(); } else if (self.$editMemoryModal.is(':visible')) { self.closeEditMemoryModal(); } else if (self.$deleteMemoryModal.is(':visible')) { self.closeDeleteMemoryModal(); } else if (self.$createMemoryModal.is(':visible')) { self.closeCreateMemoryModal(); } } }); // Initialize the application this.loadToolNames().then(function () { // Start on overview page self.loadNews(); self.loadConfigOverview(); self.startConfigPolling(); self.startExecutionsPolling(); }); // Initialize heartbeat interval setInterval(this.heartbeat.bind(this), 250); } heartbeat() { let self = this; $.ajax({ url: '/heartbeat', type: 'GET', success: function (response) { self.heartbeatFailureCount = 0; }, error: function (xhr, status, error) { self.heartbeatFailureCount++; console.error('Heartbeat failure; count = ', self.heartbeatFailureCount); if (self.heartbeatFailureCount >= 1) { console.log('Server appears to be down, closing tab'); window.close(); } }, }); } toggleMenu() { this.$menuDropdown.toggle(); } navigateToPage(page) { // Hide menu this.$menuDropdown.hide(); // Hide all pages $('.page-view').hide(); // Show selected page $('#page-' + page).show(); // Update menu active state $('[data-page]').removeClass('active'); $('[data-page="' + page + '"]').addClass('active'); // Update current page this.currentPage = page; // Stop all polling this.stopPolling(); // Start appropriate polling for the page if (page === 'overview') { this.loadNews(); this.loadConfigOverview(); this.startConfigPolling(); this.startExecutionsPolling(); } else if (page === 'logs') { this.loadLogs(); } else if (page === 'stats') { this.loadStats(); } } stopPolling() { if (this.pollInterval) { clearInterval(this.pollInterval); this.pollInterval = null; } if (this.configPollInterval) { clearInterval(this.configPollInterval); this.configPollInterval = null; } if (this.executionsPollInterval) { clearInterval(this.executionsPollInterval); this.executionsPollInterval = null; } } // ===== Config Overview Methods ===== loadConfigOverview() { if (this.waitingForConfigPollingResult) { console.log('Still waiting for previous config poll result, skipping this poll'); return; } this.waitingForConfigPollingResult = true; console.log('Polling for config overview...'); let self = this; $.ajax({ url: '/get_config_overview', type: 'GET', success: function (response) { // Check if the config data has actually changed const currentConfigJson = JSON.stringify(response); const hasChanged = self.lastConfigDataJson !== currentConfigJson; if (hasChanged) { console.log('Config has changed, updating display'); self.lastConfigDataJson = currentConfigJson; self.configData = response; self.jetbrainsMode = response.jetbrains_mode; self.activeProjectName = response.active_project.name; self.displayConfig(response); self.displayBasicStats(response.tool_stats_summary); self.displayProjects(response.registered_projects); self.displayAvailableTools(response.available_tools); self.displayAvailableModes(response.available_modes); self.displayAvailableContexts(response.available_contexts); } else { console.log('Config unchanged, skipping display update'); } }, error: function (xhr, status, error) { console.error('Error loading config overview:', error); self.$configDisplay.html('
Error loading configuration
'); self.$basicStatsDisplay.html('
Error loading stats
'); self.$projectsDisplay.html('
Error loading projects
'); self.$availableToolsDisplay.html('
Error loading tools
'); self.$availableModesDisplay.html('
Error loading modes
'); self.$availableContextsDisplay.html('
Error loading contexts
'); }, complete: function () { self.waitingForConfigPollingResult = false; } }); } startConfigPolling() { this.configPollInterval = setInterval(this.loadConfigOverview.bind(this), 1000); } startExecutionsPolling() { // Poll every 1 second for executions (independent of config polling) // This ensures stuck executions can still be cancelled even if config polling is blocked this.loadExecutions() this.executionsPollInterval = setInterval(() => { this.loadQueuedExecutions(); this.loadLastExecution(); }, 1000); } displayConfig(config) { try { // Check if tools and memories sections are currently expanded const $existingToolsContent = $('#tools-content'); const $existingMemoriesContent = $('#memories-content'); const wasToolsExpanded = $existingToolsContent.is(':visible'); const wasMemoriesExpanded = $existingMemoriesContent.is(':visible'); let html = '
'; // Project info html += '
Active Project:
'; if (config.active_project.name && config.active_project.path) { const configPath = config.active_project.path + '/.serena/project.yml'; html += '
' + config.active_project.name + '
'; } else { html += '
' + (config.active_project.name || 'None') + '
'; } html += '
Languages:
'; if (this.jetbrainsMode) { html += '
Using JetBrains backend
'; } else { html += '
'; if (config.languages && config.languages.length > 0) { html += '
'; config.languages.forEach(function (language, index) { const isRemovable = config.languages.length > 1; html += '
'; html += language; if (isRemovable) { html += '×'; } html += '
'; }); // Add the "Add Language" button inline with language badges (only if active project exists) if (config.active_project && config.active_project.name) { // TODO: address after refactoring, it's not awesome to keep depending on state if (this.isAddingLanguage) { html += '
'; } else { html += ''; html += ''; } html += '
'; } else { html += 'N/A'; } html += '
'; } // Context info html += '
Context:
'; html += '
' + config.context.name + '
'; // Modes info html += '
Active Modes:
'; html += '
'; if (config.modes.length > 0) { const modeSpans = config.modes.map(function (mode) { return '' + mode.name + ''; }); html += modeSpans.join(', '); } else { html += 'None'; } html += '
'; // File Encoding info html += '
File Encoding:
'; html += '
' + (config.encoding || 'N/A') + '
'; // Current Client info html += '
Current Client:
'; html += '
' + (config.current_client || 'None') + '
'; html += '
'; // Active tools - collapsible html += '
'; html += '

'; html += 'Active Tools (' + config.active_tools.length + ')'; html += ''; html += '

'; html += '
'; config.active_tools.forEach(function (tool) { html += '
' + tool + '
'; }); html += '
'; html += '
'; // Available memories - collapsible (show if memories exist or if project exists) if (config.active_project && config.active_project.name) { html += '
'; html += '

'; const memoryCount = (config.available_memories && config.available_memories.length) || 0; html += 'Available Memories (' + memoryCount + ')'; html += ''; html += '

'; html += '
'; if (config.available_memories && config.available_memories.length > 0) { config.available_memories.forEach(function (memory) { html += '
'; html += memory; html += '×'; html += '
'; }); } // Add Create Memory button html += ''; html += '
'; html += '
'; } // Configuration help link and edit config button html += '
'; html += '
'; html += '📖 '; html += 'View Configuration Guide'; html += '
'; html += ''; html += '
'; this.$configDisplay.html(html); // Attach event handlers for the dynamically created add language button $('#add-language-btn').click(this.openLanguageModal.bind(this)); // Attach event handler for edit serena config button $('#edit-serena-config-btn').click(this.openEditSerenaConfigModal.bind(this)); // Attach event handlers for language remove buttons const self = this; $('.language-remove').click(function (e) { e.preventDefault(); e.stopPropagation(); const language = $(this).data('language'); self.confirmRemoveLanguage(language); }); // Attach event handlers for memory items $('.memory-item').click(function (e) { e.preventDefault(); const memoryName = $(this).data('memory'); self.openEditMemoryModal(memoryName); }); // Attach event handlers for memory remove buttons $('.memory-remove').click(function (e) { e.preventDefault(); e.stopPropagation(); const memoryName = $(this).data('memory'); self.confirmDeleteMemory(memoryName); }); // Attach event handler for create memory button $('#create-memory-btn').click(this.openCreateMemoryModal.bind(this)); // Re-attach collapsible handler for the newly created tools header $('#tools-header').click(function () { const $header = $(this); const $content = $('#tools-content'); const $icon = $header.find('.toggle-icon'); $content.slideToggle(300); $icon.toggleClass('expanded'); }); // Re-attach collapsible handler for the newly created memories header $('#memories-header').click(function () { const $header = $(this); const $content = $('#memories-content'); const $icon = $header.find('.toggle-icon'); $content.slideToggle(300); $icon.toggleClass('expanded'); }); } catch (error) { console.error('Error in displayConfig:', error); this.$configDisplay.html('
Error displaying configuration: ' + error.message + '
'); } } displayBasicStats(stats) { if (Object.keys(stats).length === 0) { this.$basicStatsDisplay.html('
No tool usage stats collected yet.
'); return; } // Sort tools by call count (descending) const sortedTools = Object.keys(stats).sort((a, b) => { return stats[b].num_calls - stats[a].num_calls; }); const maxCalls = Math.max(...sortedTools.map(tool => stats[tool].num_calls)); let html = ''; sortedTools.forEach(function (toolName) { const count = stats[toolName].num_calls; const percentage = maxCalls > 0 ? (count / maxCalls * 100) : 0; html += '
'; html += '
' + toolName + '
'; html += '
'; html += '
'; html += '
'; html += '
' + count + '
'; html += '
'; }); this.$basicStatsDisplay.html(html); } displayProjects(projects) { if (!projects || projects.length === 0) { this.$projectsDisplay.html('
No projects registered.
'); return; } let html = ''; projects.forEach(function (project) { const activeClass = project.is_active ? ' active' : ''; html += '
'; html += '
' + project.name + '
'; html += '
' + project.path + '
'; html += '
'; }); this.$projectsDisplay.html(html); } displayAvailableTools(tools) { if (!tools || tools.length === 0) { this.$availableToolsDisplay.html('
All tools are active.
'); return; } let html = ''; tools.forEach(function (tool) { html += '
' + tool.name + '
'; }); this.$availableToolsDisplay.html(html); } displayAvailableModes(modes) { if (!modes || modes.length === 0) { this.$availableModesDisplay.html('
No modes available.
'); return; } let html = ''; modes.forEach(function (mode) { const activeClass = mode.is_active ? ' active' : ''; html += '
' + mode.name + '
'; }); this.$availableModesDisplay.html(html); } displayAvailableContexts(contexts) { if (!contexts || contexts.length === 0) { this.$availableContextsDisplay.html('
No contexts available.
'); return; } let html = ''; contexts.forEach(function (context) { const activeClass = context.is_active ? ' active' : ''; html += '
' + context.name + '
'; }); this.$availableContextsDisplay.html(html); } // ===== Executions Methods ===== loadQueuedExecutions() { let self = this; $.ajax({ url: '/queued_task_executions', type: 'GET', success: function (response) { if (response.status === 'success') { self.displayActiveExecutionsQueue(response.queued_executions || []); } else { console.error('Error loading executions:', response.message); } }, error: function (xhr, status, error) { console.error('Error loading executions:', error); self.$activeExecutionQueueDisplay.html('
Error loading executions
'); } }); } loadLastExecution() { let self = this; $.ajax({ url: '/last_execution', type: 'GET', success: function (response) { if (response.status === 'success') { if (response.last_execution !== null && response.last_execution.logged) { self.displayLastExecution(response.last_execution); } } else { console.error('Error loading last execution:', response.message); } }, error: function (xhr, status, error) { console.error('Error loading last execution:', error); self.$lastExecutionDisplay.html('
Error loading last execution
'); } }); } loadExecutions() { if (this.waitingForExecutionsPollingResult) { console.log('Still waiting for previous executions poll result, skipping this poll'); } else { this.waitingForExecutionsPollingResult = true; console.log('Polling for executions...'); this.loadQueuedExecutions(); this.loadLastExecution(); } } displayActiveExecutionsQueue(executions) { if (!executions || executions.length === 0) { return; } let html = '
'; let self = this; executions.forEach(function (execution) { const isRunning = execution.is_running; const logged = execution.logged; if (!logged) { return; // Skip unlogged executions } let itemClass = 'execution-item'; if (isRunning) { itemClass += ' running'; } // Escape JSON for HTML attribute - replace single quotes and use HTML entities const executionJson = JSON.stringify(execution).replace(/'/g, '''); html += '
'; if (isRunning) { html += '
'; } html += '
' + self.escapeHtml(execution.name) + '
'; if (isRunning) { html += '
#' + execution.task_id + '
'; } else { html += '
queued · #' + execution.task_id + '
'; } html += ''; html += '
'; }); html += '
'; this.$activeExecutionQueueDisplay.html(html); // Attach event handlers for cancel buttons $('.execution-cancel-btn').click(function (e) { e.preventDefault(); console.log('Cancel button clicked'); const $item = $(this).closest('.execution-item'); console.log('Found item:', $item.length); const executionDataStr = $item.attr('data-execution'); console.log('Execution data string:', executionDataStr); if (executionDataStr) { // Unescape HTML entities const unescapedStr = executionDataStr.replace(/'/g, "'"); const executionData = JSON.parse(unescapedStr); console.log('Parsed execution data:', executionData); self.confirmCancelExecution(executionData); } else { console.error('No execution data found on element'); } }); // Update cancelled executions display this.displayCancelledExecutions(executions); } displayLastExecution(execution) { if (!execution) { this.$lastExecutionDisplay.html('
No executions yet.
'); return; } const isSuccess = execution.finished_successfully; let html = '
'; html += '
'; html += isSuccess ? '✓' : '✕'; html += '
'; html += '
'; html += '
' + (isSuccess ? 'Succeeded' : 'Failed') + '
'; html += '
' + this.escapeHtml(execution.name) + '
'; html += '
'; html += '
#' + execution.task_id + '
'; html += '
'; this.$lastExecutionDisplay.html(html); } displayCancelledExecutions() { let self = this; const cancelledExecs = self.cancelledExecutions if (cancelledExecs.length === 0) { // Hide the cancelled executions section $('.executions-section').eq(2).hide(); return; } // Show the cancelled executions section $('.executions-section').eq(2).show(); let html = '
'; cancelledExecs.forEach(function (execution) { const isAbandoned = execution.is_running; html += '
'; html += '
'; html += isAbandoned ? '!' : '✕'; html += '
'; html += '
' + self.escapeHtml(execution.name) + '
'; html += '
' + (isAbandoned ? 'abandoned · ' : '') + '#' + execution.task_id + '
'; html += '
'; }); html += '
'; this.$cancelledExecutionsDisplay.html(html); } confirmCancelExecution(executionData) { console.log('confirmCancelExecution called with:', executionData); this.executionToCancel = executionData; if (executionData.is_running) { // Show modal for running executions console.log('Showing modal for running execution'); this.$cancelExecutionModal.fadeIn(200); } else { // Directly cancel queued executions console.log('Directly cancelling queued execution'); this.cancelExecution(executionData); } } confirmCancelExecutionOk() { if (this.executionToCancel) { this.cancelExecution(this.executionToCancel); } this.closeCancelExecutionModal(); } cancelExecution(executionData) { const self = this; console.log('cancelExecution called with full execution data:', executionData); console.log('Attempting to cancel task:', executionData.task_id); // Call backend API to cancel the task $.ajax({ url: '/cancel_task_execution', type: 'POST', contentType: 'application/json', data: JSON.stringify({ task_id: executionData.task_id }), success: function (response) { console.log('Cancel task response:', response); if (response.status === 'error') { console.error('Backend returned error status:', response.message); alert('Error cancelling task: ' + response.message); return; } if (response.status === 'success') { if (response.was_cancelled) { console.log('Task ' + executionData.task_id + ' was successfully cancelled'); // Add to cancelled list (only managed in JS, not persisted) const alreadyCancelled = self.cancelledExecutions.some(function (exec) { return exec.task_id === executionData.task_id; }); if (!alreadyCancelled) { console.log('Adding execution to cancelled list:', executionData); self.cancelledExecutions.push(executionData); console.log('Cancelled executions array now contains:', self.cancelledExecutions); } else { console.log('Execution already in cancelled list'); } } else { console.log('Task ' + executionData.task_id + ' could not be cancelled (may have already completed). ' + response.message); } // Refresh display regardless self.loadQueuedExecutions(); } else { console.error('Unexpected response status:', response.status); alert('Unexpected response from server'); } }, error: function (xhr, status, error) { console.error('AJAX error cancelling task:'); console.error(' Status:', status); console.error(' Error:', error); console.error(' XHR:', xhr); console.error(' Response:', xhr.responseText); let errorMessage = error; if (xhr.responseJSON && xhr.responseJSON.message) { errorMessage = xhr.responseJSON.message; } else if (xhr.responseText) { errorMessage = xhr.responseText; } alert('Error cancelling task: ' + errorMessage); } }); } closeCancelExecutionModal() { this.$cancelExecutionModal.fadeOut(200); this.executionToCancel = null; } escapeHtml(text) { if (typeof text !== 'string') return text; const patterns = { '<': '<', '>': '>', '&': '&', '"': '"', "'": ''', '`': '`' }; return text.replace(/[<>&"'`]/g, match => patterns[match]); } // ===== Logs Methods ===== displayLogMessage(message) { this.$logContainer.append(new LogMessage(message, this.toolNames).$elem); } loadToolNames() { let self = this; return $.ajax({ url: '/get_tool_names', type: 'GET', success: function (response) { self.toolNames = response.tool_names || []; console.log('Loaded tool names:', self.toolNames); }, error: function (xhr, status, error) { console.error('Error loading tool names:', error); } }); } updateTitle(activeProject) { document.title = activeProject ? `${activeProject} – Serena Dashboard` : 'Serena Dashboard'; } updateLogButtons(hasLogs) { this.$saveLogsBtn.prop('disabled', !hasLogs); this.$copyLogsBtn.prop('disabled', !hasLogs); this.$clearLogsBtn.prop('disabled', !hasLogs); } saveLogs() { const logText = this.$logContainer.text(); const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); const blob = new Blob([logText], {type: 'text/plain'}); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `serena-logs-${timestamp}.txt`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); const originalHtml = this.$saveLogsBtn.html(); const checkmarkSvg = 'save logs'; this.$saveLogsBtn.html(checkmarkSvg); setTimeout(() => { this.$saveLogsBtn.html(originalHtml); }, 1500); } copyLogs() { const logText = this.$logContainer.text(); // Use the Clipboard API to copy text navigator.clipboard.writeText(logText).then(() => { // Visual feedback - temporarily change icon to grey checkmark const originalHtml = this.$copyLogsBtn.html(); const checkmarkSvg = 'copy logs'; this.$copyLogsBtn.html(checkmarkSvg); setTimeout(() => { this.$copyLogsBtn.html(originalHtml); }, 1500); }).catch(err => { console.error('Failed to copy logs:', err); }); } clearLogs() { let self = this; $.ajax({ url: '/clear_logs', type: 'POST', success: function () { self.$logContainer.empty(); self.currentMaxIdx = -1; self.updateLogButtons(false); const originalHtml = self.$clearLogsBtn.html(); const checkmarkSvg = 'clear logs'; self.$clearLogsBtn.html(checkmarkSvg); setTimeout(() => { self.$clearLogsBtn.html(originalHtml); }, 1500); }, error: function (xhr, status, error) { console.error('Failed to clear logs:', error); } }); } loadLogs() { console.log("Loading logs"); let self = this; self.$errorContainer.empty(); // Make API call $.ajax({ url: '/get_log_messages', type: 'POST', contentType: 'application/json', data: JSON.stringify({ start_idx: 0 }), success: function (response) { // Clear existing logs self.$logContainer.empty(); // Update max_idx self.currentMaxIdx = response.max_idx || -1; // Display each log message if (response.messages && response.messages.length > 0) { response.messages.forEach(function (message) { self.displayLogMessage(message); }); // Auto-scroll to bottom const logContainer = $('#log-container')[0]; logContainer.scrollTop = logContainer.scrollHeight; } else { $('#log-container').html('
No log messages found.
'); } self.updateLogButtons(response.messages && response.messages.length > 0); self.updateTitle(response.active_project); // Start periodic polling for new logs self.startPeriodicPolling(); }, error: function (xhr, status, error) { console.error('Error loading logs:', error); self.$errorContainer.html('
Error loading logs: ' + (xhr.responseJSON ? xhr.responseJSON.detail : error) + '
'); } }); } pollForNewLogs() { let self = this; console.log("Polling logs", this.currentMaxIdx); $.ajax({ url: '/get_log_messages', type: 'POST', contentType: 'application/json', data: JSON.stringify({ start_idx: self.currentMaxIdx + 1 }), success: function (response) { // Only append new messages if we have any if (response.messages && response.messages.length > 0) { let wasAtBottom = false; const logContainer = $('#log-container')[0]; // Check if user was at the bottom before adding new logs if (logContainer.scrollHeight > 0) { wasAtBottom = (logContainer.scrollTop + logContainer.clientHeight) >= (logContainer.scrollHeight - 10); } // Append new messages response.messages.forEach(function (message) { self.displayLogMessage(message); }); // Update max_idx self.currentMaxIdx = response.max_idx || self.currentMaxIdx; self.updateLogButtons(true); // Auto-scroll to bottom if user was already at bottom if (wasAtBottom) { logContainer.scrollTop = logContainer.scrollHeight; } } else { // Update max_idx even if no new messages self.currentMaxIdx = response.max_idx || self.currentMaxIdx; } // Update window title with active project self.updateTitle(response.active_project); } }); } startPeriodicPolling() { // Clear any existing interval if (this.pollInterval) { clearInterval(this.pollInterval); } // Start polling every second (1000ms) this.pollInterval = setInterval(this.pollForNewLogs.bind(this), 1000); } // ===== Stats Methods ===== loadStats() { let self = this; $.when($.ajax({url: '/get_tool_stats', type: 'GET'}), $.ajax({ url: '/get_token_count_estimator_name', type: 'GET' })).done(function (statsResp, estimatorResp) { const stats = statsResp[0].stats; const tokenCountEstimatorName = estimatorResp[0].token_count_estimator_name; self.displayStats(stats, tokenCountEstimatorName); }).fail(function () { console.error('Error loading stats or estimator name'); }); } clearStats() { let self = this; $.ajax({ url: '/clear_tool_stats', type: 'POST', success: function () { self.loadStats(); }, error: function (xhr, status, error) { console.error('Error clearing stats:', error); } }); } displayStats(stats, tokenCountEstimatorName) { const names = Object.keys(stats); // If no stats collected if (names.length === 0) { // hide summary, charts, estimator name $('#stats-summary').hide(); $('#estimator-name').hide(); $('.charts-container').hide(); // show no-stats message $('#no-stats-message').show(); return; } else { // Ensure everything is visible $('#estimator-name').show(); $('#stats-summary').show(); $('.charts-container').show(); $('#no-stats-message').hide(); } $('#estimator-name').html(`Token count estimator: ${tokenCountEstimatorName}`); const counts = names.map(n => stats[n].num_times_called); const inputTokens = names.map(n => stats[n].input_tokens); const outputTokens = names.map(n => stats[n].output_tokens); const totalTokens = names.map(n => stats[n].input_tokens + stats[n].output_tokens); // Calculate totals for summary table const totalCalls = counts.reduce((sum, count) => sum + count, 0); const totalInputTokens = inputTokens.reduce((sum, tokens) => sum + tokens, 0); const totalOutputTokens = outputTokens.reduce((sum, tokens) => sum + tokens, 0); // Generate consistent colors for tools const colors = this.generateColors(names.length); const countCtx = document.getElementById('count-chart'); const tokensCtx = document.getElementById('tokens-chart'); const inputCtx = document.getElementById('input-chart'); const outputCtx = document.getElementById('output-chart'); if (this.countChart) this.countChart.destroy(); if (this.tokensChart) this.tokensChart.destroy(); if (this.inputChart) this.inputChart.destroy(); if (this.outputChart) this.outputChart.destroy(); // Update summary table this.updateSummaryTable(totalCalls, totalInputTokens, totalOutputTokens); // Register datalabels plugin Chart.register(ChartDataLabels); // Get theme-aware colors const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; const textColor = isDark ? '#ffffff' : '#000000'; const gridColor = isDark ? '#444' : '#ddd'; // Tool calls pie chart this.countChart = new Chart(countCtx, { type: 'pie', data: { labels: names, datasets: [{ data: counts, backgroundColor: colors }] }, options: { plugins: { legend: { display: true, labels: { color: textColor } }, datalabels: { display: true, color: 'white', font: {weight: 'bold'}, formatter: (value) => value } } } }); // Input tokens pie chart this.inputChart = new Chart(inputCtx, { type: 'pie', data: { labels: names, datasets: [{ data: inputTokens, backgroundColor: colors }] }, options: { plugins: { legend: { display: true, labels: { color: textColor } }, datalabels: { display: true, color: 'white', font: {weight: 'bold'}, formatter: (value) => value } } } }); // Output tokens pie chart this.outputChart = new Chart(outputCtx, { type: 'pie', data: { labels: names, datasets: [{ data: outputTokens, backgroundColor: colors }] }, options: { plugins: { legend: { display: true, labels: { color: textColor } }, datalabels: { display: true, color: 'white', font: {weight: 'bold'}, formatter: (value) => value } } } }); // Combined input/output tokens bar chart this.tokensChart = new Chart(tokensCtx, { type: 'bar', data: { labels: names, datasets: [{ label: 'Input Tokens', data: inputTokens, backgroundColor: colors.map(color => color + '80'), // Semi-transparent borderColor: colors, borderWidth: 2, borderSkipped: false, yAxisID: 'y' }, { label: 'Output Tokens', data: outputTokens, backgroundColor: colors, yAxisID: 'y1' }] }, options: { responsive: true, plugins: { legend: { labels: { color: textColor } } }, scales: { x: { ticks: { color: textColor }, grid: { color: gridColor } }, y: { type: 'linear', display: true, position: 'left', beginAtZero: true, title: { display: true, text: 'Input Tokens', color: textColor }, ticks: { color: textColor }, grid: { color: gridColor } }, y1: { type: 'linear', display: true, position: 'right', beginAtZero: true, title: { display: true, text: 'Output Tokens', color: textColor }, ticks: { color: textColor }, grid: { drawOnChartArea: false, color: gridColor } } } } }); } generateColors(count) { const colors = ['#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF', '#FF9F40', '#FF6384', '#C9CBCF', '#4BC0C0', '#FF6384']; return Array.from({length: count}, (_, i) => colors[i % colors.length]); } updateSummaryTable(totalCalls, totalInputTokens, totalOutputTokens) { const tableHtml = `
MetricTotal
Tool Calls${totalCalls}
Input Tokens${totalInputTokens}
Output Tokens${totalOutputTokens}
Total Tokens${totalInputTokens + totalOutputTokens}
`; $('#stats-summary').html(tableHtml); } // ===== Theme Methods ===== initializeTheme() { // Check if user has manually set a theme preference const savedTheme = localStorage.getItem('serena-theme'); if (savedTheme) { // User has manually set a preference, use it this.setTheme(savedTheme); } else { // No manual preference, detect system color scheme this.detectSystemTheme(); } // Listen for system theme changes this.setupSystemThemeListener(); } detectSystemTheme() { // Check if system prefers dark mode const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; const theme = prefersDark ? 'dark' : 'light'; this.setTheme(theme); } setupSystemThemeListener() { // Listen for changes in system color scheme const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); const handleSystemThemeChange = (e) => { // Only auto-switch if user hasn't manually set a preference const savedTheme = localStorage.getItem('serena-theme'); if (!savedTheme) { const newTheme = e.matches ? 'dark' : 'light'; this.setTheme(newTheme); } }; // Add listener for system theme changes if (mediaQuery.addEventListener) { mediaQuery.addEventListener('change', handleSystemThemeChange); } else { // Fallback for older browsers mediaQuery.addListener(handleSystemThemeChange); } } toggleTheme() { const currentTheme = document.documentElement.getAttribute('data-theme') || 'light'; const newTheme = currentTheme === 'light' ? 'dark' : 'light'; // When user manually toggles, save their preference localStorage.setItem('serena-theme', newTheme); this.setTheme(newTheme); } /** * @param theme {'light' | 'dark'} */ setTheme(theme) { // Set the theme on the document element document.documentElement.setAttribute('data-theme', theme); // Update the theme toggle button if (theme === 'dark') { this.$themeIcon.text('☀️'); this.$themeText.text('Light'); } else { this.$themeIcon.text('🌙'); this.$themeText.text('Dark'); } // Update theme-aware images $(".theme-aware-img").each(function() { const $img = $(this); updateThemeAwareImage($img, theme); }); // Save to localStorage localStorage.setItem('serena-theme', theme); // Update charts if they exist this.updateChartsTheme(); } updateChartsTheme() { const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; const textColor = isDark ? '#ffffff' : '#000000'; const gridColor = isDark ? '#444' : '#ddd'; // Update existing charts if they exist and have the scales property if (this.countChart && this.countChart.options.plugins) { if (this.countChart.options.plugins.legend) { this.countChart.options.plugins.legend.labels.color = textColor; } this.countChart.update(); } if (this.inputChart && this.inputChart.options.plugins) { if (this.inputChart.options.plugins.legend) { this.inputChart.options.plugins.legend.labels.color = textColor; } this.inputChart.update(); } if (this.outputChart && this.outputChart.options.plugins) { if (this.outputChart.options.plugins.legend) { this.outputChart.options.plugins.legend.labels.color = textColor; } this.outputChart.update(); } if (this.tokensChart && this.tokensChart.options.scales) { this.tokensChart.options.scales.x.ticks.color = textColor; this.tokensChart.options.scales.y.ticks.color = textColor; this.tokensChart.options.scales.y1.ticks.color = textColor; this.tokensChart.options.scales.x.grid.color = gridColor; this.tokensChart.options.scales.y.grid.color = gridColor; this.tokensChart.options.scales.y1.grid.color = gridColor; this.tokensChart.options.scales.y.title.color = textColor; this.tokensChart.options.scales.y1.title.color = textColor; if (this.tokensChart.options.plugins && this.tokensChart.options.plugins.legend) { this.tokensChart.options.plugins.legend.labels.color = textColor; } this.tokensChart.update(); } } // ===== Language Management Methods ===== confirmRemoveLanguage(language) { // Store the language to remove this.languageToRemove = language; // Set language name in modal this.$removeLanguageName.text(language); // Show modal this.$removeLanguageModal.fadeIn(200); } closeRemoveLanguageModal() { this.$removeLanguageModal.fadeOut(200); this.languageToRemove = null; } confirmRemoveLanguageOk() { if (this.languageToRemove) { this.removeLanguage(this.languageToRemove); this.closeRemoveLanguageModal(); } } removeLanguage(language) { const self = this; $.ajax({ url: '/remove_language', type: 'POST', contentType: 'application/json', data: JSON.stringify({ language: language }), success: function (response) { if (response.status === 'success') { // Reload config to show updated language list self.loadConfigOverview(); } else { alert('Error removing language ' + language + ": " + response.message); } }, error: function (xhr, status, error) { console.error('Error removing language:', error); alert('Error removing language: ' + (xhr.responseJSON ? xhr.responseJSON.message : error)); } }); } openLanguageModal() { // Set project name in modal this.$modalProjectName.text(this.activeProjectName || 'Unknown'); // Load available languages into modal dropdown this.loadAvailableLanguages(); // Show modal this.$addLanguageModal.fadeIn(200); } closeLanguageModal() { this.$addLanguageModal.fadeOut(200); this.$modalLanguageSelect.empty(); this.$modalAddBtn.prop('disabled', false).text('Add Language'); } loadAvailableLanguages() { let self = this; $.ajax({ url: '/get_available_languages', type: 'GET', success: function (response) { const languages = response.languages || []; // Clear all existing options self.$modalLanguageSelect.empty(); if (languages.length === 0) { // Show message if no languages available self.$modalLanguageSelect.append($('
================================================ FILE: src/serena/resources/dashboard/news/20260111.html ================================================

Extended Symbol Information, Type Hierarchy and Compact Overviews

January 11, 2026

Recent commits and a new plugin release provide major new features!

  • Extended symbol information: The `find_symbol` and `find_referencing_symbols` tools (and their JetBrains variants) now can return more information about the symbol (specifically, docstrings and signatures).
  • Type hierarchy tool: A new tool – exclusive to JetBrains mode – that can fetch a symbol's hierarchy, i.e. superclasses and subclasses, which is very useful in many situations.
  • Compact overviews: The `get_symbols_overview` tools now return a much more compact representation, saving many tokens. The JetBrains variant of the tool can now return a file's docstring (LSP varian't can't do that yet).

For more detailed information, see our changelog.

================================================ FILE: src/serena/resources/dashboard/news/20260303.html ================================================

Nested and Global Memories

March 03, 2026

Serena's memory system has been significantly extended in functionality. It keeps the simplicity that made it popular but now supports structuring memories into topics and sharing them across projects.

For more detailed information, see the documentation.

================================================ FILE: src/serena/resources/project.local.template.yml ================================================ # This file allows you to locally override settings in project.yml for development purposes. # # Use the same keys as in project.yml here. Any setting you specify will override the corresponding # setting in project.yml, allowing you to customise the configuration for your local development environment # without affecting the project configuration in project.yml (which is intended to be versioned). ================================================ FILE: src/serena/resources/project.template.yml ================================================ # the name by which the project can be referenced within Serena project_name: "project_name" # list of languages for which language servers are started; choose from: # al bash clojure cpp csharp # csharp_omnisharp dart elixir elm erlang # fortran fsharp go groovy haskell # java julia kotlin lua markdown # matlab nix pascal perl php # php_phpactor powershell python python_jedi r # rego ruby ruby_solargraph rust scala # swift terraform toml typescript typescript_vts # vue yaml zig # (This list may be outdated. For the current list, see values of Language enum here: # https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py # For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) # Note: # - For C, use cpp # - For JavaScript, use typescript # - For Free Pascal/Lazarus, use pascal # Special requirements: # Some languages require additional setup/installations. # See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers # When using multiple languages, the first language server that supports a given file will be used for that file. # The first language is the default language and the respective language server will be used as a fallback. # Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. languages: ["python"] # the encoding used by text files in the project # For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings encoding: "utf-8" # line ending convention to use when writing source files. # Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) # This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. line_ending: # The language backend to use for this project. # If not set, the global setting from serena_config.yml is used. # Valid values: LSP, JetBrains # Note: the backend is fixed at startup. If a project with a different backend # is activated post-init, an error will be returned. language_backend: # whether to use project's .gitignore files to ignore files ignore_all_files_in_gitignore: true # list of additional paths to ignore in this project. # Same syntax as gitignore, so you can use * and **. # Note: global ignored_paths from serena_config.yml are also applied additively. ignored_paths: [] # whether the project is in read-only mode # If set to true, all editing tools will be disabled and attempts to use them will result in an error # Added on 2025-04-18 read_only: false # list of tool names to exclude. # This extends the existing exclusions (e.g. from the global configuration) # # Below is the complete list of tools for convenience. # To make sure you have the latest list of tools, and to view their descriptions, # execute `uv run scripts/print_tool_overview.py`. # # * `activate_project`: Activates a project by name. # * `check_onboarding_performed`: Checks whether project onboarding was already performed. # * `create_text_file`: Creates/overwrites a file in the project directory. # * `delete_lines`: Deletes a range of lines within a file. # * `delete_memory`: Deletes a memory from Serena's project-specific memory store. # * `execute_shell_command`: Executes a shell command. # * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. # * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). # * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). # * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. # * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. # * `initial_instructions`: Gets the initial instructions for the current project. # Should only be used in settings where the system prompt cannot be set, # e.g. in clients you have no control over, like Claude Desktop. # * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. # * `insert_at_line`: Inserts content at a given line in a file. # * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. # * `list_dir`: Lists files and directories in the given directory (optionally with recursion). # * `list_memories`: Lists memories in Serena's project-specific memory store. # * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). # * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). # * `read_file`: Reads a file within the project directory. # * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. # * `remove_project`: Removes a project from the Serena configuration. # * `replace_lines`: Replaces a range of lines within a file with new content. # * `replace_symbol_body`: Replaces the full definition of a symbol. # * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. # * `search_for_pattern`: Performs a search for a pattern in the project. # * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. # * `switch_modes`: Activates modes by providing a list of their names # * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. # * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. # * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. # * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. excluded_tools: [] # list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). # This extends the existing inclusions (e.g. from the global configuration). included_optional_tools: [] # fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. # This cannot be combined with non-empty excluded_tools or included_optional_tools. fixed_tools: [] # list of mode names to that are always to be included in the set of active modes # The full set of modes to be activated is base_modes + default_modes. # If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply. # Otherwise, this setting overrides the global configuration. # Set this to [] to disable base modes for this project. # Set this to a list of mode names to always include the respective modes for this project. base_modes: # list of mode names that are to be activated by default. # The full set of modes to be activated is base_modes + default_modes. # If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply. # Otherwise, this overrides the setting from the global configuration (serena_config.yml). # This setting can, in turn, be overridden by CLI parameters (--mode). default_modes: # initial prompt for the project. It will always be given to the LLM upon activating the project # (contrary to the memories, which are loaded on demand). initial_prompt: "" # time budget (seconds) per tool call for the retrieval of additional symbol information # such as docstrings or parameter information. # This overrides the corresponding setting in the global configuration; see the documentation there. # If null or missing, use the setting from the global configuration. symbol_info_budget: # list of regex patterns which, when matched, mark a memory entry as read‑only. # Extends the list from the global configuration, merging the two lists. read_only_memory_patterns: [] ================================================ FILE: src/serena/resources/serena_config.template.yml ================================================ # the language backend to use for code understanding and manipulation. # Possible values are: # * LSP: Use the language server protocol (LSP), spawning freely available language servers # via the SolidLSP library that is part of Serena. # * JetBrains: Use the Serena plugin in your JetBrains IDE. # (requires the plugin to be installed and the project being worked on to be open # in your IDE). language_backend: LSP # line ending convention to use when writing source files. # Possible values: "lf" (Unix), "crlf" (Windows), "native" (platform default). # Note that Serena's own files (e.g. memories and configuration files) always use native line endings. # This setting can be overridden on a per-project basis in project.yml files. line_ending: native # whether to open a graphical window with Serena's logs. # This is mainly supported on Windows and (partly) on Linux; not available on macOS. # If you prefer a browser-based tool, use the `web_dashboard` option instead. # Further information: https://oraios.github.io/serena/02-usage/060_dashboard.html # # Being able to inspect logs is useful both for troubleshooting and for monitoring the tool calls, # especially when using the agno playground, since the tool calls are not always shown, # and the input params are never shown in the agno UI. # When used as MCP server for Claude Desktop, the logs are primarily for troubleshooting. # Note: unfortunately, the various entities starting the Serena server or agent do so in # mysterious ways, often starting multiple instances of the process without shutting down # previous instances. This can lead to multiple log windows being opened, and only the last # window being updated. Since we can't control how agno or Claude Desktop start Serena, # we have to live with this limitation for now. gui_log_window: False # whether to open the Serena web dashboard (which will be accessible through your web browser) that # provides access to Serena's configuration and state as well as the current session logs. # The web dashboard is supported on all platforms. # We strongly recommend to always enable this option, since the dashboard provides important information # about the current state of Serena, including the configuration, the logs and tool usage statistics. # If you don't want the browser window to pop up automatically, set the `web_dashboard_open_on_launch` to False # (either here or through the corresponding flag in the `start-mcp-server` CLI command). # You can then open the dashboard by asking your agent to do so (e.g., by saying "open the dashboard"), Serena provides # a tool for this. # Further information: https://oraios.github.io/serena/02-usage/060_dashboard.html web_dashboard: True # the address the web dashboard will listen on (bind address). web_dashboard_listen_address: 127.0.0.1 # whether to open a browser window with the web dashboard when Serena starts (provided that web_dashboard # is enabled). See also the web_dashboard option. # If set to false, you can still open the dashboard manually by # a) telling the LLM to "open the dashboard" (provided that the open_dashboard tool is enabled) or by # b) manually navigating to http://localhost:24282/dashboard/ in your web browser (actual port # may be higher if you have multiple instances running; try ports 24283, 24284, etc.) # See also: https://oraios.github.io/serena/02-usage/060_dashboard.html web_dashboard_open_on_launch: True # address where JetBrains plugin servers are running (only relevant when using the JetBrains language backend) jetbrains_plugin_server_address: 127.0.0.1 # the minimum log level for the GUI log window and the dashboard (10 = debug, 20 = info, 30 = warning, 40 = error) log_level: 20 # whether to trace the communication between Serena and the language servers. # This is useful for debugging language server issues. trace_lsp_communication: False # advanced configuration option allowing to configure language server-specific options. # Maps the language key to the options. # Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. # No documentation on options means no options are available. ls_specific_settings: {} # list of paths to ignore across all projects. # Same syntax as gitignore, so you can use * and **. # These patterns are merged additively with each project's own ignored_paths. ignored_paths: [] # list of regex patterns which, when matched, mark a memory entry as read‑only. # For example, "global/.*" will mark all global memories as read-only. # You can extend the list on a per-project basis in the project.yml configuration file. read_only_memory_patterns: [] # timeout, in seconds, after which tool executions are terminated tool_timeout: 240 # list of tools to be globally excluded excluded_tools: [] # list of optional tools (which are disabled by default) to be included included_optional_tools: [] # fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. # This cannot be combined with non-empty excluded_tools or included_optional_tools. fixed_tools: [] # list of mode names to that are always to be included in the set of active modes # The full set of modes to be activated is base_modes + default_modes. # If this is undefined, no base modes are included. # The project configuration (project.yml) may override this setting. base_modes: # list of mode names that are to be activated by default. # The full set of modes to be activated is base_modes + default_modes. # These modes can be overridden by the project configuration (project.yml) or through the CLI (--mode). default_modes: - interactive - editing # Used as default for tools where the apply method has a default maximal answer length. # Even though the value of the max_answer_chars can be changed when calling the tool, it may make sense to adjust this default # through the global configuration. default_max_tool_answer_chars: 150000 # the name of the token count estimator to use for tool usage statistics. # See the `RegisteredTokenCountEstimator` enum for available options. # # By default, a very naive character count estimator is used, which simply counts the number of characters. # You can configure this to TIKTOKEN_GPT4 to use a local tiktoken-based estimator for GPT-4 (will download tiktoken # data files on first run), or ANTHROPIC_CLAUDE_SONNET_4 which will use the (free of cost) Anthropic API to # estimate the token count using the Claude Sonnet 4 tokenizer. token_count_estimator: CHAR_COUNT # time budget (seconds) per tool call for the retrieval of additional symbol information # such as docstrings or parameter information. # (currently only used by LSP-based tools). # If the budget is exceeded, Serena stops issuing further retrieval requests # and returns partial info results. # 0 disables the budget (no early stopping). Negative values are invalid. # This is an advanced setting that can help alleviate problems with LSP servers # that have a slow implementation of request_hover (clangd is one of those) # or with tool calls that find very many symbols. # Can be overridden in project.yml. symbol_info_budget: 10 # template for the location of the per-project .serena data folder (memories, caches, etc.). # Supports the following placeholders: # $projectDir - the absolute path to the project root directory # $projectFolderName - the name of the project directory # Default: "$projectDir/.serena" (data stored inside the project directory) # Example for a central location: "/projects-metadata/$projectFolderName/.serena" project_serena_folder_location: "$projectDir/.serena" # the list of registered project paths (updated automatically). projects: [] ================================================ FILE: src/serena/symbol.py ================================================ import json import logging import os from abc import ABC, abstractmethod from collections.abc import Callable, Iterable, Iterator, Sequence from dataclasses import asdict, dataclass from time import perf_counter from typing import Any, Generic, Literal, NotRequired, Self, TypedDict, TypeVar from sensai.util.string import ToStringMixin import serena.jetbrains.jetbrains_types as jb from solidlsp import SolidLanguageServer from solidlsp.ls import LSPFileBuffer from solidlsp.ls import ReferenceInSymbol as LSPReferenceInSymbol from solidlsp.ls_types import Position, SymbolKind, UnifiedSymbolInformation from .ls_manager import LanguageServerManager from .project import Project log = logging.getLogger(__name__) NAME_PATH_SEP = "/" @dataclass class LanguageServerSymbolLocation: """ Represents the (start) location of a symbol identifier, which, within Serena, uniquely identifies the symbol. """ relative_path: str | None """ the relative path of the file containing the symbol; if None, the symbol is defined outside of the project's scope """ line: int | None """ the line number in which the symbol identifier is defined (if the symbol is a function, class, etc.); may be None for some types of symbols (e.g. SymbolKind.File) """ column: int | None """ the column number in which the symbol identifier is defined (if the symbol is a function, class, etc.); may be None for some types of symbols (e.g. SymbolKind.File) """ def __post_init__(self) -> None: if self.relative_path is not None: self.relative_path = self.relative_path.replace("/", os.path.sep) def to_dict(self, include_relative_path: bool = True) -> dict[str, Any]: result = asdict(self) if not include_relative_path: result.pop("relative_path", None) return result def has_position_in_file(self) -> bool: return self.relative_path is not None and self.line is not None and self.column is not None @dataclass class PositionInFile: """ Represents a character position within a file """ line: int """ the 0-based line number in the file """ col: int """ the 0-based column """ def to_lsp_position(self) -> Position: """ Convert to LSP Position. """ return Position(line=self.line, character=self.col) class Symbol(ToStringMixin, ABC): @abstractmethod def get_body_start_position(self) -> PositionInFile | None: pass @abstractmethod def get_body_end_position(self) -> PositionInFile | None: pass def get_body_start_position_or_raise(self) -> PositionInFile: """ Get the start position of the symbol body, raising an error if it is not defined. """ pos = self.get_body_start_position() if pos is None: raise ValueError(f"Body start position is not defined for {self}") return pos def get_body_end_position_or_raise(self) -> PositionInFile: """ Get the end position of the symbol body, raising an error if it is not defined. """ pos = self.get_body_end_position() if pos is None: raise ValueError(f"Body end position is not defined for {self}") return pos @abstractmethod def is_neighbouring_definition_separated_by_empty_line(self) -> bool: """ :return: whether a symbol definition of this symbol's kind is usually separated from the previous/next definition by at least one empty line. """ class NamePathComponent: def __init__(self, name: str, overload_idx: int | None = None) -> None: self.name = name self.overload_idx = overload_idx def __repr__(self) -> str: if self.overload_idx is not None: return f"{self.name}[{self.overload_idx}]" else: return self.name class NamePathMatcher(ToStringMixin): """ Matches name paths of symbols against search patterns. A name path is a path in the symbol tree *within a source file*. For example, the method `my_method` defined in class `MyClass` would have the name path `MyClass/my_method`. If a symbol is overloaded (e.g., in Java), a 0-based index is appended (e.g. "MyClass/my_method[0]") to uniquely identify it. A matching pattern can be: * a simple name (e.g. "method"), which will match any symbol with that name * a relative path like "class/method", which will match any symbol with that name path suffix * an absolute name path "/class/method" (absolute name path), which requires an exact match of the full name path within the source file. Append an index `[i]` to match a specific overload only, e.g. "MyClass/my_method[1]". """ class PatternComponent(NamePathComponent): @classmethod def from_string(cls, component_str: str) -> Self: overload_idx = None if component_str.endswith("]") and "[" in component_str: bracket_idx = component_str.rfind("[") index_part = component_str[bracket_idx + 1 : -1] if index_part.isdigit(): component_str = component_str[:bracket_idx] overload_idx = int(index_part) return cls(name=component_str, overload_idx=overload_idx) def matches(self, name_path_component: NamePathComponent, substring_matching: bool) -> bool: if substring_matching: if self.name not in name_path_component.name: return False else: if self.name != name_path_component.name: return False if self.overload_idx is not None and self.overload_idx != name_path_component.overload_idx: return False return True def __init__(self, name_path_pattern: str, substring_matching: bool) -> None: """ :param name_path_pattern: the name path expression to match against :param substring_matching: whether to use substring matching for the last segment """ assert name_path_pattern, "name_path must not be empty" self._expr = name_path_pattern self._substring_matching = substring_matching self._is_absolute_pattern = name_path_pattern.startswith(NAME_PATH_SEP) self._components = [ self.PatternComponent.from_string(x) for x in name_path_pattern.lstrip(NAME_PATH_SEP).rstrip(NAME_PATH_SEP).split(NAME_PATH_SEP) ] def _tostring_includes(self) -> list[str]: return ["_expr"] def matches_ls_symbol(self, symbol: "LanguageServerSymbol") -> bool: return self.matches_reversed_components(symbol.iter_name_path_components_reversed()) def matches_reversed_components(self, components_reversed: Iterator[NamePathComponent]) -> bool: for i, pattern_component in enumerate(reversed(self._components)): try: symbol_component = next(components_reversed) except StopIteration: return False use_substring_matching = self._substring_matching and (i == 0) if not pattern_component.matches(symbol_component, use_substring_matching): return False if self._is_absolute_pattern: # ensure that there are no more components in the symbol try: next(components_reversed) return False except StopIteration: pass return True class LanguageServerSymbol(Symbol, ToStringMixin): def __init__(self, symbol_root_from_ls: UnifiedSymbolInformation) -> None: self.symbol_root = symbol_root_from_ls def _tostring_includes(self) -> list[str]: return [] def _tostring_additional_entries(self) -> dict[str, Any]: return dict(name=self.name, kind=self.symbol_kind_name, num_children=len(self.symbol_root["children"])) @property def name(self) -> str: return self.symbol_root["name"] @property def symbol_kind_name(self) -> str: """ :return: string representation of the symbol kind (name attribute of the `SymbolKind` enum item) """ return SymbolKind(self.symbol_kind).name @property def symbol_kind(self) -> SymbolKind: return self.symbol_root["kind"] def is_low_level(self) -> bool: """ :return: whether the symbol is a low-level symbol (variable, constant, etc.), which typically represents data rather than structure and therefore is not relevant in a high-level overview of the code. """ return self.symbol_kind >= SymbolKind.Variable.value @property def overload_idx(self) -> int | None: return self.symbol_root.get("overload_idx") def is_neighbouring_definition_separated_by_empty_line(self) -> bool: return self.symbol_kind in (SymbolKind.Function, SymbolKind.Method, SymbolKind.Class, SymbolKind.Interface, SymbolKind.Struct) @property def relative_path(self) -> str | None: location = self.symbol_root.get("location") if location: return location.get("relativePath") return None @property def location(self) -> LanguageServerSymbolLocation: """ :return: the start location of the actual symbol identifier """ return LanguageServerSymbolLocation(relative_path=self.relative_path, line=self.line, column=self.column) @property def body_start_position(self) -> Position | None: location = self.symbol_root.get("location") if location: range_info = location.get("range") if range_info: start_pos = range_info.get("start") if start_pos: return start_pos return None @property def body_end_position(self) -> Position | None: location = self.symbol_root.get("location") if location: range_info = location.get("range") if range_info: end_pos = range_info.get("end") if end_pos: return end_pos return None def get_body_start_position(self) -> PositionInFile | None: start_pos = self.body_start_position if start_pos is None: return None return PositionInFile(line=start_pos["line"], col=start_pos["character"]) def get_body_end_position(self) -> PositionInFile | None: end_pos = self.body_end_position if end_pos is None: return None return PositionInFile(line=end_pos["line"], col=end_pos["character"]) def get_body_line_numbers(self) -> tuple[int | None, int | None]: start_pos = self.body_start_position end_pos = self.body_end_position start_line = start_pos["line"] if start_pos else None end_line = end_pos["line"] if end_pos else None return start_line, end_line @property def line(self) -> int | None: """ :return: the line in which the symbol identifier is defined. """ if "selectionRange" in self.symbol_root: return self.symbol_root["selectionRange"]["start"]["line"] else: # line is expected to be undefined for some types of symbols (e.g. SymbolKind.File) return None @property def column(self) -> int | None: if "selectionRange" in self.symbol_root: return self.symbol_root["selectionRange"]["start"]["character"] else: # precise location is expected to be undefined for some types of symbols (e.g. SymbolKind.File) return None @property def body(self) -> str | None: body = self.symbol_root.get("body") if body is None: return None else: return body.get_text() def get_name_path(self) -> str: """ Get the name path of the symbol, e.g. "class/method/inner_function" or "class/method[1]" (overloaded method with identifying index). """ name_path = NAME_PATH_SEP.join(reversed([str(x) for x in self.iter_name_path_components_reversed()])) return name_path def iter_name_path_components_reversed(self) -> Iterator[NamePathComponent]: yield NamePathComponent(self.name, self.overload_idx) for ancestor in self.iter_ancestors(up_to_symbol_kind=SymbolKind.File): yield NamePathComponent(ancestor.name, ancestor.overload_idx) def iter_children(self) -> Iterator[Self]: for c in self.symbol_root["children"]: yield self.__class__(c) def iter_ancestors(self, up_to_symbol_kind: SymbolKind | None = None) -> Iterator[Self]: """ Iterate over all ancestors of the symbol, starting with the parent and going up to the root or the given symbol kind. :param up_to_symbol_kind: if provided, iteration will stop *before* the first ancestor of the given kind. A typical use case is to pass `SymbolKind.File` or `SymbolKind.Package`. """ parent = self.get_parent() if parent is not None: if up_to_symbol_kind is None or parent.symbol_kind != up_to_symbol_kind: yield parent yield from parent.iter_ancestors(up_to_symbol_kind=up_to_symbol_kind) def get_parent(self) -> Self | None: parent_root = self.symbol_root.get("parent") if parent_root is None: return None return self.__class__(parent_root) def find( self, name_path_pattern: str, substring_matching: bool = False, include_kinds: Sequence[SymbolKind] | None = None, exclude_kinds: Sequence[SymbolKind] | None = None, ) -> list[Self]: """ Find all symbols within the symbol's subtree that match the given name path pattern. :param name_path_pattern: the name path pattern to match against (see class :class:`NamePathMatcher` for details) :param substring_matching: whether to use substring matching (as opposed to exact matching) of the last segment of `name_path` against the symbol name. :param include_kinds: an optional sequence of ints representing the LSP symbol kind. If provided, only symbols of the given kinds will be included in the result. :param exclude_kinds: If provided, symbols of the given kinds will be excluded from the result. """ result = [] name_path_matcher = NamePathMatcher(name_path_pattern, substring_matching) def should_include(s: "LanguageServerSymbol") -> bool: if include_kinds is not None and s.symbol_kind not in include_kinds: return False if exclude_kinds is not None and s.symbol_kind in exclude_kinds: return False return name_path_matcher.matches_ls_symbol(s) def traverse(s: "LanguageServerSymbol") -> None: if should_include(s): result.append(s) for c in s.iter_children(): traverse(c) traverse(self) return result class OutputDict(TypedDict): name_path: NotRequired[str] name: NotRequired[str] location: NotRequired[dict[str, Any]] relative_path: NotRequired[str | None] body_location: NotRequired[dict[str, Any]] body: NotRequired[str | None] kind: NotRequired[str] """ string representation of the symbol kind (name attribute of the `SymbolKind` enum item) """ children: NotRequired[list["LanguageServerSymbol.OutputDict"]] OutputDictKey = Literal["name", "name_path", "relative_path", "location", "body_location", "body", "kind", "children"] def to_dict( self, *, name_path: bool = True, name: bool = False, kind: bool = False, location: bool = False, depth: int = 0, body: bool = False, body_location: bool = False, children_body: bool = False, relative_path: bool = False, child_inclusion_predicate: Callable[[Self], bool] | None = None, ) -> OutputDict: """ Converts the symbol to a dictionary. :param name_path: whether to include the name path of the symbol :param name: whether to include the name of the symbol :param kind: whether to include the kind of the symbol :param location: whether to include the location of the symbol :param depth: the depth up to which to include child symbols (0 = do not include children) :param body: whether to include the body of the top-level symbol. :param children_body: whether to also include the body of the children. Note that the body of the children is part of the body of the parent symbol, so there is usually no need to set this to True unless you want process the output and pass the children without passing the parent body to the LM. :param relative_path: whether to include the relative path of the symbol. If `location` is True, this defines whether to include the path in the location entry. If `location` is False, this defines whether to include the relative path as a top-level entry. Relative paths of the symbol's children are always excluded. :param child_inclusion_predicate: an optional predicate that decides whether a child symbol should be included. :return: a dictionary representation of the symbol """ result: LanguageServerSymbol.OutputDict = {} if name_path: result["name_path"] = self.get_name_path() if name: result["name"] = self.name if kind: result["kind"] = self.symbol_kind_name if location: result["location"] = self.location.to_dict(include_relative_path=relative_path) elif relative_path: result["relative_path"] = self.relative_path if body_location: body_start_line, body_end_line = self.get_body_line_numbers() result["body_location"] = {"start_line": body_start_line, "end_line": body_end_line} if body: result["body"] = self.body if child_inclusion_predicate is None: child_inclusion_predicate = lambda s: True def included_children(s: Self) -> list[LanguageServerSymbol.OutputDict]: children = [] for c in s.iter_children(): if not child_inclusion_predicate(c): continue children.append( c.to_dict( name_path=name_path, name=name, kind=kind, location=location, body_location=body_location, depth=depth - 1, child_inclusion_predicate=child_inclusion_predicate, body=children_body, children_body=children_body, # all children have the same relative path as the parent relative_path=False, ) ) return children if depth > 0: children = included_children(self) if len(children) > 0: result["children"] = children return result @dataclass class ReferenceInLanguageServerSymbol(ToStringMixin): """ Represents the location of a reference to another symbol within a symbol/file. The contained symbol is the symbol within which the reference is located, not the symbol that is referenced. """ symbol: LanguageServerSymbol """ the symbol within which the reference is located """ line: int """ the line number in which the reference is located (0-based) """ character: int """ the column number in which the reference is located (0-based) """ @classmethod def from_lsp_reference(cls, reference: LSPReferenceInSymbol) -> Self: return cls(symbol=LanguageServerSymbol(reference.symbol), line=reference.line, character=reference.character) def get_relative_path(self) -> str | None: return self.symbol.location.relative_path class LanguageServerSymbolRetriever: def __init__(self, project: Project) -> None: """ :param project: the project instance """ self._ls_manager: LanguageServerManager = project.get_language_server_manager_or_raise() self.project = project def _request_info(self, relative_file_path: str, line: int, column: int, file_buffer: LSPFileBuffer | None = None) -> str | None: """Retrieves information (in a sanitized format) about the symbol at the desired location, typically containing the docstring and signature. Returns None if no information is available. """ lang_server = self.get_language_server(relative_file_path) hover_info = lang_server.request_hover(relative_file_path=relative_file_path, line=line, column=column, file_buffer=file_buffer) if hover_info is None: return None contents = hover_info["contents"] # Handle various response formats if isinstance(contents, list): # Array format: extract all parts and join them stripped_parts = [] for part in contents: if isinstance(part, str) and (stripped_part := part.strip()): stripped_parts.append(stripped_part) else: # should be a dict with "value" key stripped_parts.append(part["value"].strip()) # type: ignore return "\n".join(stripped_parts) if stripped_parts else None if isinstance(contents, dict) and (stripped_contents := contents.get("value", "").strip()): return stripped_contents if isinstance(contents, str) and (stripped_contents := contents.strip()): return stripped_contents return None def request_info_for_symbol(self, symbol: LanguageServerSymbol) -> str | None: if None in [symbol.relative_path, symbol.line, symbol.column]: return None return self._request_info(relative_file_path=symbol.relative_path, line=symbol.line, column=symbol.column) # type: ignore[arg-type] def _get_symbol_info_budget(self) -> float: symbol_info_budget = self.project.serena_config.symbol_info_budget project_symbol_info_budget = self.project.project_config.symbol_info_budget if project_symbol_info_budget is not None: symbol_info_budget = project_symbol_info_budget return symbol_info_budget def request_info_for_symbol_batch( self, symbols: list[LanguageServerSymbol], ) -> dict[LanguageServerSymbol, str | None]: """Retrieves information for multiple symbols while staying within a time budget. The request_hover operation used here is potentially expensive, we optimize by grouping by file and stop executing it (returning the info as None) after the symbol_info_budget is exceeded. The hover budget is 5s by default Groups symbols by file path to minimize file switching overhead and uses a per-file cache keyed by (line, col) to avoid duplicate hover lookups. The hover budget (symbol_info_budget) limits total time spent on hover requests. If exceeded, remaining symbols get info=None (partial results). :param symbols: list of symbols to get info for :return: a dict mapping each processable symbol to its info (or None if unavailable). Symbols with missing location attributes (relative_path/line/column is None) are skipped and omitted from the result. """ if not symbols: return {} debug_enabled = log.isEnabledFor(logging.DEBUG) t0_total = perf_counter() if debug_enabled else 0.0 info_by_symbol: dict[LanguageServerSymbol, str | None] = {} skipped_symbols = 0 # Group symbols by file path, filtering invalid symbols. symbols_by_file: dict[str, list[LanguageServerSymbol]] = {} for sym in symbols: file_path = sym.relative_path line = sym.line column = sym.column if file_path is None or line is None or column is None: skipped_symbols += 1 continue symbols_by_file.setdefault(file_path, []).append(sym) hover_spent_seconds = 0.0 symbol_info_budget_seconds = self._get_symbol_info_budget() # the vars below are only for debug logging per_file_stats: list[tuple[str, int, float]] = [] total_hover_lookups = 0 hover_cache_hits = 0 skipped_due_to_budget = 0 for file_path, file_symbols in symbols_by_file.items(): t0_file = perf_counter() if debug_enabled else 0.0 file_hover_lookups = 0 ls = self.get_language_server(file_path) with ls.open_file(file_path) as file_buffer: for sym in file_symbols: # Check budget before starting a new hover request # symbol_info_budget_seconds=0 disables the budget mechanism (the first inequality) if 0 < symbol_info_budget_seconds <= hover_spent_seconds: skipped_due_to_budget += 1 info = None # log once when budget exceeded if skipped_due_to_budget == 1: log.debug("Skipping further hover operations due to budget exceeded") else: line = sym.line column = sym.column assert line is not None and column is not None # for mypy, we filtered invalid symbols above t0_hover = perf_counter() info = self._request_info(file_path, line, column, file_buffer=file_buffer) hover_spent_seconds += perf_counter() - t0_hover file_hover_lookups += 1 total_hover_lookups += 1 info_by_symbol[sym] = info if debug_enabled: file_elapsed_ms = (perf_counter() - t0_file) * 1000 per_file_stats.append((file_path, file_hover_lookups, file_elapsed_ms)) if debug_enabled: total_elapsed_ms = (perf_counter() - t0_total) * 1000 total_symbols = len(symbols) unique_files = len(symbols_by_file) budget_exceeded = skipped_due_to_budget > 0 log.debug( f"perf: request_info_for_symbols {total_elapsed_ms=:.2f} {total_symbols=} {skipped_symbols=} " f"{total_hover_lookups=} {hover_cache_hits=} {unique_files=} " f"{symbol_info_budget_seconds=:.1f} {hover_spent_seconds=:.2f} {budget_exceeded=} {skipped_due_to_budget=}" ) for file_path, lookup_count, elapsed_ms in per_file_stats: log.debug(f"perf: {file_path=} {lookup_count=} {elapsed_ms=:.2f}") return info_by_symbol def can_analyze_file(self, relative_file_path: str) -> bool: return self._ls_manager.has_suitable_ls_for_file(relative_file_path) def get_language_server(self, relative_path: str) -> SolidLanguageServer: """:param relative_path: relative path to a file""" return self._ls_manager.get_language_server(relative_path) def find( self, name_path_pattern: str, include_kinds: Sequence[SymbolKind] | None = None, exclude_kinds: Sequence[SymbolKind] | None = None, substring_matching: bool = False, within_relative_path: str | None = None, ) -> list[LanguageServerSymbol]: """ Finds all symbols that match the given name path pattern (see class :class:`NamePathMatcher` for details), optionally limited to a specific file and filtered by kind. """ symbols: list[LanguageServerSymbol] = [] if within_relative_path and os.path.isfile(os.path.join(self.project.project_root, within_relative_path)): """ For a specific file, use get_language_server to select the best LS for the file type (consistent with get_symbol_overview). This ensures e.g. PHP files are served by the PHP language server rather than being rejected by all LSes via is_ignored_path. """ lang_servers: Iterable[SolidLanguageServer] = [self._ls_manager.get_language_server(within_relative_path)] else: lang_servers = self._ls_manager.iter_language_servers() for lang_server in lang_servers: symbol_roots = lang_server.request_full_symbol_tree(within_relative_path=within_relative_path) for root in symbol_roots: symbols.extend( LanguageServerSymbol(root).find( name_path_pattern, include_kinds=include_kinds, exclude_kinds=exclude_kinds, substring_matching=substring_matching ) ) return symbols def find_unique( self, name_path_pattern: str, include_kinds: Sequence[SymbolKind] | None = None, exclude_kinds: Sequence[SymbolKind] | None = None, substring_matching: bool = False, within_relative_path: str | None = None, ) -> LanguageServerSymbol: symbol_candidates = self.find( name_path_pattern, include_kinds=include_kinds, exclude_kinds=exclude_kinds, substring_matching=substring_matching, within_relative_path=within_relative_path, ) if len(symbol_candidates) == 1: return symbol_candidates[0] elif len(symbol_candidates) == 0: raise ValueError(f"No symbol matching '{name_path_pattern}' found") else: # There are multiple candidates. # If only one of the candidates has the given pattern as its exact name path, return that one exact_matches = [s for s in symbol_candidates if s.get_name_path() == name_path_pattern] if len(exact_matches) == 1: return exact_matches[0] # otherwise, raise an error include_rel_path = within_relative_path is not None raise ValueError( f"Found multiple {len(symbol_candidates)} symbols matching '{name_path_pattern}'. " "They are: \n" + json.dumps([s.to_dict(kind=True, relative_path=include_rel_path) for s in symbol_candidates], indent=2) ) def find_by_location(self, location: LanguageServerSymbolLocation) -> LanguageServerSymbol | None: if location.relative_path is None: return None lang_server = self.get_language_server(location.relative_path) document_symbols = lang_server.request_document_symbols(location.relative_path) for symbol_dict in document_symbols.iter_symbols(): symbol = LanguageServerSymbol(symbol_dict) if symbol.location == location: return symbol return None def find_referencing_symbols( self, name_path: str, relative_file_path: str, include_body: bool = False, include_kinds: Sequence[SymbolKind] | None = None, exclude_kinds: Sequence[SymbolKind] | None = None, ) -> list[ReferenceInLanguageServerSymbol]: """ Find all symbols that reference the specified symbol, which is assumed to be unique. :param name_path: the name path of the symbol to find. (While this can be a matching pattern, it should usually be the full path to ensure uniqueness.) :param relative_file_path: the relative path of the file in which the referenced symbol is defined. :param include_body: whether to include the body of all symbols in the result. Not recommended, as the referencing symbols will often be files, and thus the bodies will be very long. :param include_kinds: which kinds of symbols to include in the result. :param exclude_kinds: which kinds of symbols to exclude from the result. """ symbol = self.find_unique(name_path, substring_matching=False, within_relative_path=relative_file_path) return self.find_referencing_symbols_by_location( symbol.location, include_body=include_body, include_kinds=include_kinds, exclude_kinds=exclude_kinds ) def find_referencing_symbols_by_location( self, symbol_location: LanguageServerSymbolLocation, include_body: bool = False, include_kinds: Sequence[SymbolKind] | None = None, exclude_kinds: Sequence[SymbolKind] | None = None, ) -> list[ReferenceInLanguageServerSymbol]: """ Find all symbols that reference the symbol at the given location. :param symbol_location: the location of the symbol for which to find references. Does not need to include an end_line, as it is unused in the search. :param include_body: whether to include the body of all symbols in the result. Not recommended, as the referencing symbols will often be files, and thus the bodies will be very long. Note: you can filter out the bodies of the children if you set include_children_body=False in the to_dict method. :param include_kinds: an optional sequence of ints representing the LSP symbol kind. If provided, only symbols of the given kinds will be included in the result. :param exclude_kinds: If provided, symbols of the given kinds will be excluded from the result. Takes precedence over include_kinds. :return: a list of symbols that reference the given symbol """ if not symbol_location.has_position_in_file(): raise ValueError("Symbol location does not contain a valid position in a file") assert symbol_location.relative_path is not None assert symbol_location.line is not None assert symbol_location.column is not None lang_server = self.get_language_server(symbol_location.relative_path) references = lang_server.request_referencing_symbols( relative_file_path=symbol_location.relative_path, line=symbol_location.line, column=symbol_location.column, include_imports=False, include_self=False, include_body=include_body, include_file_symbols=True, ) if include_kinds is not None: references = [s for s in references if s.symbol["kind"] in include_kinds] if exclude_kinds is not None: references = [s for s in references if s.symbol["kind"] not in exclude_kinds] return [ReferenceInLanguageServerSymbol.from_lsp_reference(r) for r in references] def get_symbol_overview(self, relative_path: str) -> dict[str, list[LanguageServerSymbol]]: """ :param relative_path: the path of the file for which to get the symbol overview :return: a mapping from file paths to lists of symbols. For the case where a file is passed, the mapping will contain a single entry. """ lang_server = self.get_language_server(relative_path) path_to_unified_symbols = lang_server.request_overview(relative_path) return {k: [LanguageServerSymbol(us) for us in v] for k, v in path_to_unified_symbols.items()} class JetBrainsSymbol(Symbol): def __init__(self, symbol_dict: jb.SymbolDTO, project: Project) -> None: """ :param symbol_dict: dictionary as returned by the JetBrains plugin client. """ self._project = project self._dict = symbol_dict self._cached_file_content: str | None = None self._cached_body_start_position: PositionInFile | None = None self._cached_body_end_position: PositionInFile | None = None def _tostring_includes(self) -> list[str]: return [] def _tostring_additional_entries(self) -> dict[str, Any]: return dict(name_path=self.get_name_path(), relative_path=self.get_relative_path(), type=self._dict["type"]) def get_name_path(self) -> str: return self._dict["name_path"] def get_relative_path(self) -> str: return self._dict["relative_path"] def get_file_content(self) -> str: if self._cached_file_content is None: path = os.path.join(self._project.project_root, self.get_relative_path()) with open(path, encoding=self._project.project_config.encoding) as f: self._cached_file_content = f.read() return self._cached_file_content def is_position_in_file_available(self) -> bool: return "text_range" in self._dict def get_body_start_position(self) -> PositionInFile | None: if not self.is_position_in_file_available(): return None if self._cached_body_start_position is None: pos = self._dict["text_range"]["start_pos"] line, col = pos["line"], pos["col"] self._cached_body_start_position = PositionInFile(line=line, col=col) return self._cached_body_start_position def get_body_end_position(self) -> PositionInFile | None: if not self.is_position_in_file_available(): return None if self._cached_body_end_position is None: pos = self._dict["text_range"]["end_pos"] line, col = pos["line"], pos["col"] self._cached_body_end_position = PositionInFile(line=line, col=col) return self._cached_body_end_position def is_neighbouring_definition_separated_by_empty_line(self) -> bool: # NOTE: Symbol types cannot really be differentiated, because types are not handled in a language-agnostic way. return False TSymbolDict = TypeVar("TSymbolDict") GroupedSymbolDict = dict[str, list[dict] | dict[str, dict]] class SymbolDictGrouper(Generic[TSymbolDict], ABC): """ A utility class for grouping a list of symbol dictionaries by one or more specified keys. If an instance is statically initialised (upon module import), then this establishes a guarantee that the specified keys are defined in the symbol dictionary type, ensuring at least basic type safety. The respective ValueError will immediately be apparent. """ def __init__( self, symbol_dict_type: type[TSymbolDict], children_key: Any, group_keys: list[Any], group_children_keys: list[Any], collapse_singleton: bool, ) -> None: """ :param symbol_dict_type: the TypedDict type that represents the type of the symbol dictionaries to be grouped :param children_key: the key in the symbol dictionaries that contains the list of child symbols (for recursive grouping). :param group_keys: keys by which to group the symbol dictionaries. Must be a subset of the keys of `symbol_dict_type`. :param group_children_keys: keys by which to group the child symbol dictionaries. Must be a subset of the keys of `symbol_dict_type`. :param collapse_singleton: whether to collapse dictionaries containing a single entry after regrouping to just the entry's value """ # check whether the type contains all the keys specified in `keys` and raise an error if not. if not hasattr(symbol_dict_type, "__annotations__"): raise ValueError(f"symbol_dict_type must be a TypedDict type, got {symbol_dict_type}") symbol_dict_keys = set(symbol_dict_type.__annotations__.keys()) for key in group_keys + [children_key] + group_children_keys: if key not in symbol_dict_keys: raise ValueError(f"symbol_dict_type {symbol_dict_type} does not contain key '{key}'") self._children_key = children_key self._group_keys = group_keys self._group_children_keys = group_children_keys self._collapse_singleton = collapse_singleton def _group_by(self, l: list[dict], keys: list[str], children_keys: list[str]) -> dict[str, Any]: assert len(keys) > 0, "keys must not be empty" # group by the first key grouped: dict[str, Any] = {} for item in l: key_value = item.pop(keys[0], "unknown") if key_value not in grouped: grouped[key_value] = [] grouped[key_value].append(item) if len(keys) > 1: # continue grouping by the remaining keys for k, group in grouped.items(): grouped[k] = self._group_by(group, keys[1:], children_keys) else: # grouping is complete; now group the children if necessary if children_keys: for k, group in grouped.items(): for item in group: if self._children_key in item: children = item[self._children_key] item[self._children_key] = self._group_by(children, children_keys, children_keys) # post-process final group items grouped = {k: [self._transform_item(i) for i in v] for k, v in grouped.items()} return grouped def _transform_item(self, item: dict) -> dict: """ Post-processes a final group item (which has been regrouped, i.e. some keys may have been removed), collapsing singleton items (and items containing only a single non-children key) """ if self._collapse_singleton: if len(item) == 1: # {"name": "foo"} -> "foo" # if there is only a single entry, collapse the dictionary to just the value of that entry return next(iter(item.values())) elif len(item) == 2 and self._children_key in item: # {"name": "foo", "children": {...}} -> {"foo": {...}} # if there are exactly two entries and one of them is the children key, # convert to {other_value: children} other_key = next(k for k in item.keys() if k != self._children_key) new_item = {item[other_key]: item[self._children_key]} return new_item return item def group(self, symbols: list[TSymbolDict]) -> GroupedSymbolDict: """ :param symbols: the symbols to group :return: dictionary with the symbols grouped as defined at construction """ return self._group_by(symbols, self._group_keys, self._group_children_keys) # type: ignore class LanguageServerSymbolDictGrouper(SymbolDictGrouper[LanguageServerSymbol.OutputDict]): def __init__( self, group_keys: list[LanguageServerSymbol.OutputDictKey], group_children_keys: list[LanguageServerSymbol.OutputDictKey], collapse_singleton: bool = False, ) -> None: super().__init__(LanguageServerSymbol.OutputDict, "children", group_keys, group_children_keys, collapse_singleton) class JetBrainsSymbolDictGrouper(SymbolDictGrouper[jb.SymbolDTO]): def __init__( self, group_keys: list[jb.SymbolDTOKey], group_children_keys: list[jb.SymbolDTOKey], collapse_singleton: bool = False, map_name_path_to_name: bool = False, ) -> None: super().__init__(jb.SymbolDTO, "children", group_keys, group_children_keys, collapse_singleton) self._map_name_path_to_name = map_name_path_to_name def _transform_item(self, item: dict) -> dict: if self._map_name_path_to_name: # {"name_path: "Class/myMethod"} -> {"name: "myMethod"} new_item = dict(item) if "name_path" in item: name_path = new_item.pop("name_path") new_item["name"] = name_path.split("/")[-1] return super()._transform_item(new_item) else: return super()._transform_item(item) ================================================ FILE: src/serena/task_executor.py ================================================ import concurrent.futures import threading import time from collections.abc import Callable from concurrent.futures import Future from dataclasses import dataclass from threading import Thread from typing import Generic, TypeVar from sensai.util import logging from sensai.util.logging import LogTime from sensai.util.string import ToStringMixin log = logging.getLogger(__name__) T = TypeVar("T") class TaskExecutor: def __init__(self, name: str): self._task_executor_lock = threading.Lock() self._task_executor_queue: list[TaskExecutor.Task] = [] self._task_executor_thread = Thread(target=self._process_task_queue, name=name, daemon=True) self._task_executor_thread.start() self._task_executor_task_index = 1 self._task_executor_current_task: TaskExecutor.Task | None = None self._task_executor_last_executed_task_info: TaskExecutor.TaskInfo | None = None class Task(ToStringMixin, Generic[T]): def __init__(self, function: Callable[[], T], name: str, logged: bool = True, timeout: float | None = None): """ :param function: the function representing the task to execute :param name: the name of the task :param logged: whether to log management of the task; if False, only errors will be logged :param timeout: the maximum time to wait for task completion in seconds, or None to wait indefinitely """ self.name = name self.future: concurrent.futures.Future = concurrent.futures.Future() self.logged = logged self.timeout = timeout self._function = function def _tostring_includes(self) -> list[str]: return ["name"] def start(self) -> None: """ Executes the task in a separate thread, setting the result or exception on the future. """ def run_task() -> None: try: if self.future.done(): if self.logged: log.info(f"Task {self.name} was already completed/cancelled; skipping execution") return with LogTime(self.name, logger=log, enabled=self.logged): result = self._function() if not self.future.done(): self.future.set_result(result) except Exception as e: if not self.future.done(): log.error(f"Error during execution of {self.name}: {e}", exc_info=e) self.future.set_exception(e) thread = Thread(target=run_task, name=self.name) thread.start() def is_done(self) -> bool: """ :return: whether the task has completed (either successfully, with failure, or via cancellation) """ return self.future.done() def result(self, timeout: float | None = None) -> T: """ Blocks until the task is done or the timeout is reached, and returns the result. If an exception occurred during task execution, it is raised here. If the timeout is reached, a TimeoutError is raised (but the task is not cancelled). If the task is cancelled, a CancelledError is raised. :param timeout: the maximum time to wait in seconds; if None, use the task's own timeout (which may be None to wait indefinitely) :return: True if the task is done, False if the timeout was reached """ return self.future.result(timeout=timeout) def cancel(self) -> None: """ Cancels the task. If it has not yet started, it will not be executed. If it has already started, its future will be marked as cancelled and will raise a CancelledError when its result is requested. """ self.future.cancel() def wait_until_done(self, timeout: float | None = None) -> None: """ Waits until the task is done or the timeout is reached. The task is done if it either completed successfully, failed with an exception, or was cancelled. :param timeout: the maximum time to wait in seconds; if None, use the task's own timeout (which may be None to wait indefinitely) """ try: self.future.result(timeout=timeout) except: pass def _process_task_queue(self) -> None: while True: # obtain task from the queue task: TaskExecutor.Task | None = None with self._task_executor_lock: if len(self._task_executor_queue) > 0: task = self._task_executor_queue.pop(0) if task is None: time.sleep(0.1) continue # start task execution asynchronously with self._task_executor_lock: self._task_executor_current_task = task if task.logged: log.info("Starting execution of %s", task.name) task.start() # wait for task completion task.wait_until_done(timeout=task.timeout) with self._task_executor_lock: self._task_executor_current_task = None if task.logged: self._task_executor_last_executed_task_info = self.TaskInfo.from_task(task, is_running=False) @dataclass class TaskInfo: name: str is_running: bool future: Future """ future for accessing the task's result """ task_id: int """ unique identifier of the task """ logged: bool def finished_successfully(self) -> bool: return self.future.done() and not self.future.cancelled() and self.future.exception() is None @staticmethod def from_task(task: "TaskExecutor.Task", is_running: bool) -> "TaskExecutor.TaskInfo": return TaskExecutor.TaskInfo(name=task.name, is_running=is_running, future=task.future, task_id=id(task), logged=task.logged) def cancel(self) -> None: self.future.cancel() def get_current_tasks(self) -> list[TaskInfo]: """ Gets the list of tasks currently running or queued for execution. The function returns a list of thread-safe TaskInfo objects (specifically created for the caller). :return: the list of tasks in the execution order (running task first) """ tasks = [] with self._task_executor_lock: if self._task_executor_current_task is not None: tasks.append(self.TaskInfo.from_task(self._task_executor_current_task, True)) for task in self._task_executor_queue: if not task.is_done(): tasks.append(self.TaskInfo.from_task(task, False)) return tasks def issue_task(self, task: Callable[[], T], name: str | None = None, logged: bool = True, timeout: float | None = None) -> Task[T]: """ Issue a task to the executor for asynchronous execution. It is ensured that tasks are executed in the order they are issued, one after another. :param task: the task to execute :param name: the name of the task for logging purposes; if None, use the task function's name :param logged: whether to log management of the task; if False, only errors will be logged :param timeout: the maximum time to wait for task completion in seconds, or None to wait indefinitely :return: the task object, through which the task's future result can be accessed """ with self._task_executor_lock: if logged: task_prefix_name = f"Task-{self._task_executor_task_index}" self._task_executor_task_index += 1 else: task_prefix_name = "BackgroundTask" task_name = f"{task_prefix_name}:{name or task.__name__}" if logged: log.info(f"Scheduling {task_name}") task_obj = self.Task(function=task, name=task_name, logged=logged, timeout=timeout) self._task_executor_queue.append(task_obj) return task_obj def execute_task(self, task: Callable[[], T], name: str | None = None, logged: bool = True, timeout: float | None = None) -> T: """ Executes the given task synchronously via the agent's task executor. This is useful for tasks that need to be executed immediately and whose results are needed right away. :param task: the task to execute :param name: the name of the task for logging purposes; if None, use the task function's name :param logged: whether to log management of the task; if False, only errors will be logged :param timeout: the maximum time to wait for task completion in seconds, or None to wait indefinitely :return: the result of the task execution """ task_obj = self.issue_task(task, name=name, logged=logged, timeout=timeout) return task_obj.result() def get_last_executed_task(self) -> TaskInfo | None: """ Gets information about the last executed task. :return: TaskInfo of the last executed task, or None if no task has been executed yet. """ with self._task_executor_lock: return self._task_executor_last_executed_task_info ================================================ FILE: src/serena/tools/__init__.py ================================================ # ruff: noqa from .tools_base import * from .file_tools import * from .symbol_tools import * from .memory_tools import * from .cmd_tools import * from .config_tools import * from .workflow_tools import * from .jetbrains_tools import * from .query_project_tools import * ================================================ FILE: src/serena/tools/cmd_tools.py ================================================ """ Tools supporting the execution of (external) commands """ import os.path from serena.tools import Tool, ToolMarkerCanEdit from serena.util.shell import execute_shell_command class ExecuteShellCommandTool(Tool, ToolMarkerCanEdit): """ Executes a shell command. """ def apply( self, command: str, cwd: str | None = None, capture_stderr: bool = True, max_answer_chars: int = -1, ) -> str: """ Execute a shell command and return its output. If there is a memory about suggested commands, read that first. Never execute unsafe shell commands! IMPORTANT: Do not use this tool to start * long-running processes (e.g. servers) that are not intended to terminate quickly, * processes that require user interaction. :param command: the shell command to execute :param cwd: the working directory to execute the command in. If None, the project root will be used. :param capture_stderr: whether to capture and return stderr output :param max_answer_chars: if the output is longer than this number of characters, no content will be returned. -1 means using the default value, don't adjust unless there is no other way to get the content required for the task. :return: a JSON object containing the command's stdout and optionally stderr output """ if cwd is None: _cwd = self.get_project_root() else: if os.path.isabs(cwd): _cwd = cwd else: _cwd = os.path.join(self.get_project_root(), cwd) if not os.path.isdir(_cwd): raise FileNotFoundError( f"Specified a relative working directory ({cwd}), but the resulting path is not a directory: {_cwd}" ) result = execute_shell_command(command, cwd=_cwd, capture_stderr=capture_stderr) result = result.json() return self._limit_length(result, max_answer_chars) ================================================ FILE: src/serena/tools/config_tools.py ================================================ from serena.tools import Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional class OpenDashboardTool(Tool, ToolMarkerOptional, ToolMarkerDoesNotRequireActiveProject): """ Opens the Serena web dashboard in the default web browser. The dashboard provides logs, session information, and tool usage statistics. """ def apply(self) -> str: """ Opens the Serena web dashboard in the default web browser. """ if self.agent.open_dashboard(): return f"Serena web dashboard has been opened in the user's default web browser: {self.agent.get_dashboard_url()}" else: return f"Serena web dashboard could not be opened automatically; tell the user to open it via {self.agent.get_dashboard_url()}" class ActivateProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject): """ Activates a project based on the project name or path. """ def apply(self, project: str) -> str: """ Activates the project with the given name or path. :param project: the name of a registered project to activate or a path to a project directory """ active_project = self.agent.activate_project_from_path_or_name(project) result = active_project.get_activation_message() result += "\nIMPORTANT: If you have not yet read the 'Serena Instructions Manual', do it now before continuing!" return result class RemoveProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional): """ Removes a project from the Serena configuration. """ def apply(self, project_name: str) -> str: """ Removes a project from the Serena configuration. :param project_name: Name of the project to remove """ self.agent.serena_config.remove_project(project_name) return f"Successfully removed project '{project_name}' from configuration." class SwitchModesTool(Tool, ToolMarkerOptional): """ Activates modes by providing a list of their names """ def apply(self, modes: list[str]) -> str: """ Activates the desired modes, like ["editing", "interactive"] or ["planning", "one-shot"] :param modes: the names of the modes to activate """ self.agent.set_modes(modes) # Inform the Agent about the activated modes and the currently active tools mode_instances = self.agent.get_active_modes() result_str = f"Active modes: {', '.join([mode.name for mode in mode_instances])}" + "\n" result_str += "\n".join([mode_instance.prompt for mode_instance in mode_instances]) + "\n" result_str += f"Currently active tools: {', '.join(self.agent.get_active_tool_names())}" return result_str class GetCurrentConfigTool(Tool): """ Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. """ def apply(self) -> str: """ Print the current configuration of the agent, including the active and available projects, tools, contexts, and modes. """ return self.agent.get_current_config_overview() ================================================ FILE: src/serena/tools/file_tools.py ================================================ """ File and file system-related tools, specifically for * listing directory contents * reading files * creating files * editing at the file level """ import os from collections import defaultdict from fnmatch import fnmatch from pathlib import Path from typing import Literal from serena.tools import SUCCESS_RESULT, EditedFileContext, Tool, ToolMarkerCanEdit, ToolMarkerOptional from serena.util.file_system import scan_directory from serena.util.text_utils import ContentReplacer, search_files class ReadFileTool(Tool): """ Reads a file within the project directory. """ def apply(self, relative_path: str, start_line: int = 0, end_line: int | None = None, max_answer_chars: int = -1) -> str: """ Reads the given file or a chunk of it. Generally, symbolic operations like find_symbol or find_referencing_symbols should be preferred if you know which symbols you are looking for. :param relative_path: the relative path to the file to read :param start_line: the 0-based index of the first line to be retrieved. :param end_line: the 0-based index of the last line to be retrieved (inclusive). If None, read until the end of the file. :param max_answer_chars: if the file (chunk) is longer than this number of characters, no content will be returned. Don't adjust unless there is really no other way to get the content required for the task. :return: the full text of the file at the given relative path """ self.project.validate_relative_path(relative_path, require_not_ignored=True) result = self.project.read_file(relative_path) result_lines = result.splitlines() if end_line is None: result_lines = result_lines[start_line:] else: result_lines = result_lines[start_line : end_line + 1] result = "\n".join(result_lines) return self._limit_length(result, max_answer_chars) class CreateTextFileTool(Tool, ToolMarkerCanEdit): """ Creates/overwrites a file in the project directory. """ def apply(self, relative_path: str, content: str) -> str: """ Write a new file or overwrite an existing file. :param relative_path: the relative path to the file to create :param content: the (appropriately encoded) content to write to the file :return: a message indicating success or failure """ project_root = self.get_project_root() abs_path = (Path(project_root) / relative_path).resolve() will_overwrite_existing = abs_path.exists() if will_overwrite_existing: self.project.validate_relative_path(relative_path, require_not_ignored=True) else: assert abs_path.is_relative_to( self.get_project_root() ), f"Cannot create file outside of the project directory, got {relative_path=}" abs_path.parent.mkdir(parents=True, exist_ok=True) abs_path.write_text(content, encoding=self.project.project_config.encoding, newline=self.project.line_ending.newline_str) answer = f"File created: {relative_path}." if will_overwrite_existing: answer += " Overwrote existing file." return answer class ListDirTool(Tool): """ Lists files and directories in the given directory (optionally with recursion). """ def apply(self, relative_path: str, recursive: bool, skip_ignored_files: bool = False, max_answer_chars: int = -1) -> str: """ Lists files and directories in the given directory (optionally with recursion). :param relative_path: the relative path to the directory to list; pass "." to scan the project root :param recursive: whether to scan subdirectories recursively :param skip_ignored_files: whether to skip files and directories that are ignored :param max_answer_chars: if the output is longer than this number of characters, no content will be returned. -1 means the default value from the config will be used. Don't adjust unless there is really no other way to get the content required for the task. :return: a JSON object with the names of directories and files within the given directory """ # Check if the directory exists before validation if not self.project.relative_path_exists(relative_path): error_info = { "error": f"Directory not found: {relative_path}", "project_root": self.get_project_root(), "hint": "Check if the path is correct relative to the project root", } return self._to_json(error_info) self.project.validate_relative_path(relative_path, require_not_ignored=skip_ignored_files) dirs, files = scan_directory( os.path.join(self.get_project_root(), relative_path), relative_to=self.get_project_root(), recursive=recursive, is_ignored_dir=self.project.is_ignored_path if skip_ignored_files else None, is_ignored_file=self.project.is_ignored_path if skip_ignored_files else None, ) result = self._to_json({"dirs": dirs, "files": files}) return self._limit_length(result, max_answer_chars) class FindFileTool(Tool): """ Finds files in the given relative paths """ def apply(self, file_mask: str, relative_path: str) -> str: """ Finds non-gitignored files matching the given file mask within the given relative path :param file_mask: the filename or file mask (using the wildcards * or ?) to search for :param relative_path: the relative path to the directory to search in; pass "." to scan the project root :return: a JSON object with the list of matching files """ self.project.validate_relative_path(relative_path, require_not_ignored=True) dir_to_scan = os.path.join(self.get_project_root(), relative_path) # find the files by ignoring everything that doesn't match def is_ignored_file(abs_path: str) -> bool: if self.project.is_ignored_path(abs_path): return True filename = os.path.basename(abs_path) return not fnmatch(filename, file_mask) _dirs, files = scan_directory( path=dir_to_scan, recursive=True, is_ignored_dir=self.project.is_ignored_path, is_ignored_file=is_ignored_file, relative_to=self.get_project_root(), ) result = self._to_json({"files": files}) return result class ReplaceContentTool(Tool, ToolMarkerCanEdit): """ Replaces content in a file (optionally using regular expressions). """ def apply( self, relative_path: str, needle: str, repl: str, mode: Literal["literal", "regex"], allow_multiple_occurrences: bool = False, ) -> str: r""" Replaces one or more occurrences of a given pattern in a file with new content. This is the preferred way to replace content in a file whenever the symbol-level tools are not appropriate. VERY IMPORTANT: The "regex" mode allows very large sections of code to be replaced without fully quoting them! Use a regex of the form "beginning.*?end-of-text-to-be-replaced" to be faster and more economical! ALWAYS try to use wildcards to avoid specifying the exact content to be replaced, especially if it spans several lines. Note that you cannot make mistakes, because if the regex should match multiple occurrences while you disabled `allow_multiple_occurrences`, an error will be returned, and you can retry with a revised regex. Therefore, using regex mode with suitable wildcards is usually the best choice! :param relative_path: the relative path to the file :param needle: the string or regex pattern to search for. If `mode` is "literal", this string will be matched exactly. If `mode` is "regex", this string will be treated as a regular expression (syntax of Python's `re` module, with flags DOTALL and MULTILINE enabled). :param repl: the replacement string (verbatim). If mode is "regex", the string can contain backreferences to matched groups in the needle regex, specified using the syntax $!1, $!2, etc. for groups 1, 2, etc. :param mode: either "literal" or "regex", specifying how the `needle` parameter is to be interpreted. :param allow_multiple_occurrences: whether to allow matching and replacing multiple occurrences. If false and multiple occurrences are found, an error will be returned """ return self.replace_content( relative_path, needle, repl, mode=mode, allow_multiple_occurrences=allow_multiple_occurrences, require_not_ignored=True ) def replace_content( self, relative_path: str, needle: str, repl: str, mode: Literal["literal", "regex"], allow_multiple_occurrences: bool = False, require_not_ignored: bool = True, ) -> str: """ Performs the replacement, with additional options not exposed in the tool. This function can be used internally by other tools. """ self.project.validate_relative_path(relative_path, require_not_ignored=require_not_ignored) with EditedFileContext(relative_path, self.create_code_editor()) as context: original_content = context.get_original_content() replacer = ContentReplacer(mode=mode, allow_multiple_occurrences=allow_multiple_occurrences) updated_content = replacer.replace(original_content, needle, repl) context.set_updated_content(updated_content) return SUCCESS_RESULT class DeleteLinesTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional): """ Deletes a range of lines within a file. """ def apply( self, relative_path: str, start_line: int, end_line: int, ) -> str: """ Deletes the given lines in the file. Requires that the same range of lines was previously read using the `read_file` tool to verify correctness of the operation. :param relative_path: the relative path to the file :param start_line: the 0-based index of the first line to be deleted :param end_line: the 0-based index of the last line to be deleted """ code_editor = self.create_code_editor() code_editor.delete_lines(relative_path, start_line, end_line) return SUCCESS_RESULT class ReplaceLinesTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional): """ Replaces a range of lines within a file with new content. """ def apply( self, relative_path: str, start_line: int, end_line: int, content: str, ) -> str: """ Replaces the given range of lines in the given file. Requires that the same range of lines was previously read using the `read_file` tool to verify correctness of the operation. :param relative_path: the relative path to the file :param start_line: the 0-based index of the first line to be deleted :param end_line: the 0-based index of the last line to be deleted :param content: the content to insert """ if not content.endswith("\n"): content += "\n" result = self.agent.get_tool(DeleteLinesTool).apply(relative_path, start_line, end_line) if result != SUCCESS_RESULT: return result self.agent.get_tool(InsertAtLineTool).apply(relative_path, start_line, content) return SUCCESS_RESULT class InsertAtLineTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional): """ Inserts content at a given line in a file. """ def apply( self, relative_path: str, line: int, content: str, ) -> str: """ Inserts the given content at the given line in the file, pushing existing content of the line down. In general, symbolic insert operations like insert_after_symbol or insert_before_symbol should be preferred if you know which symbol you are looking for. However, this can also be useful for small targeted edits of the body of a longer symbol (without replacing the entire body). :param relative_path: the relative path to the file :param line: the 0-based index of the line to insert content at :param content: the content to be inserted """ if not content.endswith("\n"): content += "\n" code_editor = self.create_code_editor() code_editor.insert_at_line(relative_path, line, content) return SUCCESS_RESULT class SearchForPatternTool(Tool): """ Performs a search for a pattern in the project. """ def apply( self, substring_pattern: str, context_lines_before: int = 0, context_lines_after: int = 0, paths_include_glob: str = "", paths_exclude_glob: str = "", relative_path: str = "", restrict_search_to_code_files: bool = False, max_answer_chars: int = -1, ) -> str: """ Offers a flexible search for arbitrary patterns in the codebase, including the possibility to search in non-code files. Generally, symbolic operations like find_symbol or find_referencing_symbols should be preferred if you know which symbols you are looking for. Pattern Matching Logic: For each match, the returned result will contain the full lines where the substring pattern is found, as well as optionally some lines before and after it. The pattern will be compiled with DOTALL, meaning that the dot will match all characters including newlines. This also means that it never makes sense to have .* at the beginning or end of the pattern, but it may make sense to have it in the middle for complex patterns. If a pattern matches multiple lines, all those lines will be part of the match. Be careful to not use greedy quantifiers unnecessarily, it is usually better to use non-greedy quantifiers like .*? to avoid matching too much content. File Selection Logic: The files in which the search is performed can be restricted very flexibly. Using `restrict_search_to_code_files` is useful if you are only interested in code symbols (i.e., those symbols that can be manipulated with symbolic tools like find_symbol). You can also restrict the search to a specific file or directory, and provide glob patterns to include or exclude certain files on top of that. The globs are matched against relative file paths from the project root (not to the `relative_path` parameter that is used to further restrict the search). Smartly combining the various restrictions allows you to perform very targeted searches. :param substring_pattern: Regular expression for a substring pattern to search for :param context_lines_before: Number of lines of context to include before each match :param context_lines_after: Number of lines of context to include after each match :param paths_include_glob: optional glob pattern specifying files to include in the search. Matches against relative file paths from the project root (e.g., "*.py", "src/**/*.ts"). Supports standard glob patterns (*, ?, [seq], **, etc.) and brace expansion {a,b,c}. Only matches files, not directories. If left empty, all non-ignored files will be included. :param paths_exclude_glob: optional glob pattern specifying files to exclude from the search. Matches against relative file paths from the project root (e.g., "*test*", "**/*_generated.py"). Supports standard glob patterns (*, ?, [seq], **, etc.) and brace expansion {a,b,c}. Takes precedence over paths_include_glob. Only matches files, not directories. If left empty, no files are excluded. :param relative_path: only subpaths of this path (relative to the repo root) will be analyzed. If a path to a single file is passed, only that will be searched. The path must exist, otherwise a `FileNotFoundError` is raised. :param max_answer_chars: if the output is longer than this number of characters, no content will be returned. -1 means the default value from the config will be used. Don't adjust unless there is really no other way to get the content required for the task. Instead, if the output is too long, you should make a stricter query. :param restrict_search_to_code_files: whether to restrict the search to only those files where analyzed code symbols can be found. Otherwise, will search all non-ignored files. Set this to True if your search is only meant to discover code that can be manipulated with symbolic tools. For example, for finding classes or methods from a name pattern. Setting to False is a better choice if you also want to search in non-code files, like in html or yaml files, which is why it is the default. :return: A mapping of file paths to lists of matched consecutive lines. """ abs_path = os.path.join(self.get_project_root(), relative_path) if not os.path.exists(abs_path): raise FileNotFoundError(f"Relative path {relative_path} does not exist.") if restrict_search_to_code_files: matches = self.project.search_source_files_for_pattern( pattern=substring_pattern, relative_path=relative_path, context_lines_before=context_lines_before, context_lines_after=context_lines_after, paths_include_glob=paths_include_glob.strip(), paths_exclude_glob=paths_exclude_glob.strip(), ) else: if os.path.isfile(abs_path): rel_paths_to_search = [relative_path] else: _dirs, rel_paths_to_search = scan_directory( path=abs_path, recursive=True, is_ignored_dir=self.project.is_ignored_path, is_ignored_file=self.project.is_ignored_path, relative_to=self.get_project_root(), ) # TODO (maybe): not super efficient to walk through the files again and filter if glob patterns are provided # but it probably never matters and this version required no further refactoring matches = search_files( rel_paths_to_search, substring_pattern, file_reader=self.project.read_file, root_path=self.get_project_root(), paths_include_glob=paths_include_glob, paths_exclude_glob=paths_exclude_glob, ) # group matches by file file_to_matches: dict[str, list[str]] = defaultdict(list) for match in matches: assert match.source_file_path is not None file_to_matches[match.source_file_path].append(match.to_display_string()) result = self._to_json(file_to_matches) return self._limit_length(result, max_answer_chars) ================================================ FILE: src/serena/tools/jetbrains_tools.py ================================================ import logging from typing import Any, Literal import serena.jetbrains.jetbrains_types as jb from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient from serena.symbol import JetBrainsSymbolDictGrouper from serena.tools import Tool, ToolMarkerOptional, ToolMarkerSymbolicRead log = logging.getLogger(__name__) class JetBrainsFindSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): """ Performs a global (or local) search for symbols using the JetBrains backend """ def apply( self, name_path_pattern: str, depth: int = 0, relative_path: str | None = None, include_body: bool = False, include_info: bool = False, search_deps: bool = False, max_answer_chars: int = -1, ) -> str: """ Retrieves information on all symbols/code entities (classes, methods, etc.) based on the given name path pattern. The returned symbol information can be used for edits or further queries. Specify `depth > 0` to retrieve children (e.g., methods of a class). Important: through `search_deps=True` dependencies can be searched, which should be preferred to web search or other less sophisticated approaches to analyzing dependencies. A name path is a path in the symbol tree *within a source file*. For example, the method `my_method` defined in class `MyClass` would have the name path `MyClass/my_method`. If a symbol is overloaded (e.g., in Java), a 0-based index is appended (e.g. "MyClass/my_method[0]") to uniquely identify it. To search for a symbol, you provide a name path pattern that is used to match against name paths. It can be * a simple name (e.g. "method"), which will match any symbol with that name * a relative path like "class/method", which will match any symbol with that name path suffix * an absolute name path "/class/method" (absolute name path), which requires an exact match of the full name path within the source file. Append an index `[i]` to match a specific overload only, e.g. "MyClass/my_method[1]". :param name_path_pattern: the name path matching pattern (see above) :param depth: depth up to which descendants shall be retrieved (e.g. use 1 to also retrieve immediate children; for the case where the symbol is a class, this will return its methods). Default 0. :param relative_path: Optional. Restrict search to this file or directory. If None, searches entire codebase. If a directory is passed, the search will be restricted to the files in that directory. If a file is passed, the search will be restricted to that file. If you have some knowledge about the codebase, you should use this parameter, as it will significantly speed up the search as well as reduce the number of results. :param include_body: If True, include the symbol's source code. Use judiciously. :param include_info: whether to include additional info (hover-like, typically including docstring and signature), about the symbol (ignored if include_body is True). Default False; info is never included for child symbols and is not included when body is requested. :param search_deps: If True, also search in project dependencies (e.g., libraries). :param max_answer_chars: max characters for the JSON result. If exceeded, no content is returned. -1 means the default value from the config will be used. :return: JSON string: a list of symbols (with locations) matching the name. """ if relative_path == ".": relative_path = None with JetBrainsPluginClient.from_project(self.project) as client: if include_body: include_quick_info = False include_documentation = False else: if include_info: include_documentation = True include_quick_info = False else: # If no additional information is requested, we still include the quick info (type signature) include_documentation = False include_quick_info = True response_dict = client.find_symbol( name_path=name_path_pattern, relative_path=relative_path, depth=depth, include_body=include_body, include_documentation=include_documentation, include_quick_info=include_quick_info, search_deps=search_deps, ) result = self._to_json(response_dict) return self._limit_length(result, max_answer_chars) class JetBrainsFindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): """ Finds symbols that reference the given symbol using the JetBrains backend """ symbol_dict_grouper = JetBrainsSymbolDictGrouper(["relative_path", "type"], ["type"], collapse_singleton=True) # TODO: (maybe) - add content snippets showing the references like in LS based version? def apply( self, name_path: str, relative_path: str, max_answer_chars: int = -1, ) -> str: """ Finds symbols that reference the symbol at the given `name_path`. The result will contain metadata about the referencing symbols. :param name_path: name path of the symbol for which to find references; matching logic as described in find symbol tool. :param relative_path: the relative path to the file containing the symbol for which to find references. Note that here you can't pass a directory but must pass a file. :param max_answer_chars: max characters for the JSON result. If exceeded, no content is returned. -1 means the default value from the config will be used. :return: a list of JSON objects with the symbols referencing the requested symbol """ with JetBrainsPluginClient.from_project(self.project) as client: response_dict = client.find_references( name_path=name_path, relative_path=relative_path, include_quick_info=False, ) symbol_dicts = response_dict["symbols"] result = self.symbol_dict_grouper.group(symbol_dicts) result_json = self._to_json(result) return self._limit_length(result_json, max_answer_chars) class JetBrainsGetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): """ Retrieves an overview of the top-level symbols within a specified file using the JetBrains backend """ USE_COMPACT_FORMAT = True symbol_dict_grouper = JetBrainsSymbolDictGrouper(["type"], ["type"], collapse_singleton=True, map_name_path_to_name=True) def apply( self, relative_path: str, depth: int = 0, max_answer_chars: int = -1, include_file_documentation: bool = False, ) -> str: """ Gets an overview of the top-level symbols in the given file. Calling this is often a good idea before more targeted reading, searching or editing operations on the code symbols. Before requesting a symbol overview, it is usually a good idea to narrow down the scope of the overview by first understanding the basic directory structure of the repository that you can get from memories or by using the `list_dir` and `find_file` tools (or similar). :param relative_path: the relative path to the file to get the overview of :param depth: depth up to which descendants shall be retrieved (e.g., use 1 to also retrieve immediate children). :param max_answer_chars: max characters for the JSON result. If exceeded, no content is returned. -1 means the default value from the config will be used. :param include_file_documentation: whether to include the file's docstring. Default False. :return: a JSON object containing the symbols grouped by kind in a compact format. """ with JetBrainsPluginClient.from_project(self.project) as client: symbol_overview = client.get_symbols_overview( relative_path=relative_path, depth=depth, include_file_documentation=include_file_documentation ) if self.USE_COMPACT_FORMAT: symbols = symbol_overview["symbols"] result: dict[str, Any] = {"symbols": self.symbol_dict_grouper.group(symbols)} documentation = symbol_overview.pop("documentation", None) if documentation: result["docstring"] = documentation json_result = self._to_json(result) else: json_result = self._to_json(symbol_overview) return self._limit_length(json_result, max_answer_chars) class JetBrainsTypeHierarchyTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): """ Retrieves the type hierarchy (supertypes and/or subtypes) of a symbol using the JetBrains backend """ @staticmethod def _transform_hierarchy_nodes(nodes: list[jb.TypeHierarchyNodeDTO] | None) -> dict[str, list]: """ Transform a list of TypeHierarchyNode into a file-grouped compact format. Returns a dict where keys are relative_paths and values are lists of either: - "SymbolNamePath" (leaf node) - {"SymbolNamePath": {nested_file_grouped_children}} (node with children) """ if not nodes: return {} result: dict[str, list] = {} for node in nodes: symbol = node["symbol"] name_path = symbol["name_path"] rel_path = symbol["relative_path"] children = node.get("children", []) if rel_path not in result: result[rel_path] = [] if children: # Node with children - recurse nested = JetBrainsTypeHierarchyTool._transform_hierarchy_nodes(children) result[rel_path].append({name_path: nested}) else: # Leaf node result[rel_path].append(name_path) return result def apply( self, name_path: str, relative_path: str, hierarchy_type: Literal["super", "sub", "both"] = "both", depth: int | None = 1, max_answer_chars: int = -1, ) -> str: """ Gets the type hierarchy of a symbol (supertypes, subtypes, or both). :param name_path: name path of the symbol for which to get the type hierarchy. :param relative_path: the relative path to the file containing the symbol. :param hierarchy_type: which hierarchy to retrieve: "super" for parent classes/interfaces, "sub" for subclasses/implementations, or "both" for both directions. Default is "sub". :param depth: depth limit for hierarchy traversal (None or 0 for unlimited). Default is 1. :param max_answer_chars: max characters for the JSON result. If exceeded, no content is returned. -1 means the default value from the config will be used. :return: Compact JSON with file-grouped hierarchy. Error string if not applicable. """ with JetBrainsPluginClient.from_project(self.project) as client: subtypes = None supertypes = None levels_not_included = {} if hierarchy_type in ("super", "both"): supertypes_response = client.get_supertypes( name_path=name_path, relative_path=relative_path, depth=depth, ) if "num_levels_not_included" in supertypes_response: levels_not_included["supertypes"] = supertypes_response["num_levels_not_included"] supertypes = self._transform_hierarchy_nodes(supertypes_response.get("hierarchy")) if hierarchy_type in ("sub", "both"): subtypes_response = client.get_subtypes( name_path=name_path, relative_path=relative_path, depth=depth, ) if "num_levels_not_included" in subtypes_response: levels_not_included["subtypes"] = subtypes_response["num_levels_not_included"] subtypes = self._transform_hierarchy_nodes(subtypes_response.get("hierarchy")) result_dict: dict[str, dict | list] = {} if supertypes is not None: result_dict["supertypes"] = supertypes if subtypes is not None: result_dict["subtypes"] = subtypes if levels_not_included: result_dict["levels_not_included"] = levels_not_included result = self._to_json(result_dict) return self._limit_length(result, max_answer_chars) ================================================ FILE: src/serena/tools/memory_tools.py ================================================ from typing import Literal from serena.tools import Tool, ToolMarkerCanEdit class WriteMemoryTool(Tool, ToolMarkerCanEdit): """ Write some information (utf-8-encoded) about this project that can be useful for future tasks to a memory in md format. The memory name should be meaningful. """ def apply(self, memory_name: str, content: str, max_chars: int = -1) -> str: """ Write information (utf-8-encoded) about this project that can be useful for future tasks to a memory in md format. The memory name should be meaningful and can include "/" to organize into topics (e.g., "auth/login/logic"). If explicitly instructed, use the "global/" prefix for writing a memory that is shared across projects (e.g., "global/java/style_guide") :param max_chars: the maximum number of characters to write. By default, determined by the config, change only if instructed to do so. """ # NOTE: utf-8 encoding is configured in the MemoriesManager if max_chars == -1: max_chars = self.agent.serena_config.default_max_tool_answer_chars if len(content) > max_chars: raise ValueError( f"Content for {memory_name} is too long. Max length is {max_chars} characters. " + "Please make the content shorter." ) return self.memories_manager.save_memory(memory_name, content, is_tool_context=True) class ReadMemoryTool(Tool): """ Read the content of a memory file. This tool should only be used if the information is relevant to the current task. You can infer whether the information is relevant from the memory file name. You should not read the same memory file multiple times in the same conversation. """ def apply(self, memory_name: str) -> str: """ Reads the contents of a memory. Should only be used if the information is likely to be relevant to the current task, inferring relevance from the memory name. """ return self.memories_manager.load_memory(memory_name) class ListMemoriesTool(Tool): """ List available memories. Any memory can be read using the `read_memory` tool. """ def apply(self, topic: str = "") -> str: """ Lists available memories, optionally filtered by topic. """ return self._to_json(self.memories_manager.list_memories(topic).to_dict()) class DeleteMemoryTool(Tool, ToolMarkerCanEdit): """ Delete a memory file. Should only happen if a user asks for it explicitly, for example by saying that the information retrieved from a memory file is no longer correct or no longer relevant for the project. """ def apply(self, memory_name: str) -> str: """ Delete a memory, only call if instructed explicitly or permission was granted by the user. """ return self.memories_manager.delete_memory(memory_name, is_tool_context=True) class RenameMemoryTool(Tool, ToolMarkerCanEdit): """ Renames or moves a memory. Moving between project and global scope is supported (e.g., renaming "global/foo" to "bar" moves it from global to project scope). """ def apply(self, old_name: str, new_name: str) -> str: """ Rename or move a memory, use "/" in the name to organize into topics. The "global" topic should only be used if explicitly instructed. """ return self.memories_manager.move_memory(old_name, new_name, is_tool_context=True) class EditMemoryTool(Tool, ToolMarkerCanEdit): """ Replaces content matching a regular expression in a memory. """ def apply( self, memory_name: str, needle: str, repl: str, mode: Literal["literal", "regex"], allow_multiple_occurrences: bool = False, ) -> str: r""" Replaces content matching a regular expression in a memory. :param memory_name: the name of the memory :param needle: the string or regex pattern to search for. If `mode` is "literal", this string will be matched exactly. If `mode` is "regex", this string will be treated as a regular expression (syntax of Python's `re` module, with flags DOTALL and MULTILINE enabled). :param repl: the replacement string (verbatim). :param mode: either "literal" or "regex", specifying how the `needle` parameter is to be interpreted. :param allow_multiple_occurrences: whether to allow matching and replacing multiple occurrences. If false and multiple occurrences are found, an error will be returned. """ return self.memories_manager.edit_memory(memory_name, needle, repl, mode, allow_multiple_occurrences, is_tool_context=True) ================================================ FILE: src/serena/tools/query_project_tools.py ================================================ import json from serena.config.serena_config import LanguageBackend from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClientManager from serena.project_server import ProjectServerClient from serena.tools import Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional class ListQueryableProjectsTool(Tool, ToolMarkerOptional, ToolMarkerDoesNotRequireActiveProject): """ Tool for listing all projects that can be queried by the QueryProjectTool. """ def apply(self, symbol_access: bool = True) -> str: """ Lists available projects that can be queried with `query_project_tool`. :param symbol_access: whether to return only projects for which symbol access is available. Default: true """ # determine relevant projects registered_projects = self.agent.serena_config.projects if symbol_access: backend = self.agent.get_language_backend() if backend.is_jetbrains(): # projects with open IDE instances can be queried matched_clients = JetBrainsPluginClientManager().match_clients(registered_projects) relevant_projects = [mc.registered_project for mc in matched_clients] else: # all projects can be queried via ProjectServer (which instantiates projects dynamically) relevant_projects = registered_projects else: relevant_projects = registered_projects # return project names, excluding the active project (if any) project_names = [p.project_name for p in relevant_projects] active_project = self.agent.get_active_project() if active_project is not None: project_names = [n for n in project_names if n != active_project.project_name] return self._to_json(project_names) class QueryProjectTool(Tool, ToolMarkerOptional, ToolMarkerDoesNotRequireActiveProject): """ Tool for querying external project information (i.e. information from projects other than the current one), by executing a read-only tool. """ def apply(self, project_name: str, tool_name: str, tool_params_json: str) -> str: """ Queries a project by executing a read-only Serena tool. The tool will be executed in the context of the project. Use this to query information from projects other than the activated project. :param project_name: the name of the project to query :param tool_name: the name of the tool to execute in the other project. The tool must be read-only. :param tool_params_json: the parameters to pass to the tool, encoded as a JSON string """ tool = self.agent.get_tool_by_name(tool_name) assert tool.is_active(), f"Tool {tool_name} is not active." assert tool.is_readonly(), f"Tool {tool_name} is not read-only and cannot be executed in another project." if self._is_project_server_required(tool): client = ProjectServerClient() return client.query_project(project_name, tool_name, tool_params_json) else: registered_project = self.agent.serena_config.get_registered_project(project_name) assert registered_project is not None, f"Project {project_name} is not registered and cannot be queried." project = registered_project.get_project_instance(self.agent.serena_config) with tool.agent.active_project_context(project): return tool.apply(**json.loads(tool_params_json)) # type: ignore def _is_project_server_required(self, tool: Tool) -> bool: match self.agent.get_language_backend(): case LanguageBackend.JETBRAINS: return False case LanguageBackend.LSP: # Note: As long as only read-only tools are considered, only symbolic tools require the project server. # But if we were to allow non-read-only tools, then tools using a CodeEditor also indirectly require language servers. assert tool.is_readonly() return tool.is_symbolic() case _: raise NotImplementedError ================================================ FILE: src/serena/tools/symbol_tools.py ================================================ """ Language server-related tools """ import os from collections.abc import Sequence from serena.symbol import LanguageServerSymbol, LanguageServerSymbolDictGrouper from serena.tools import ( SUCCESS_RESULT, Tool, ToolMarkerSymbolicEdit, ToolMarkerSymbolicRead, ) from serena.tools.tools_base import ToolMarkerOptional from solidlsp.ls_types import SymbolKind class RestartLanguageServerTool(Tool, ToolMarkerOptional): """Restarts the language server, may be necessary when edits not through Serena happen.""" def apply(self) -> str: """Use this tool only on explicit user request or after confirmation. It may be necessary to restart the language server if it hangs. """ self.agent.reset_language_server_manager() return SUCCESS_RESULT class GetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead): """ Gets an overview of the top-level symbols defined in a given file. """ symbol_dict_grouper = LanguageServerSymbolDictGrouper(["kind"], ["kind"], collapse_singleton=True) def apply(self, relative_path: str, depth: int = 0, max_answer_chars: int = -1) -> str: """ Use this tool to get a high-level understanding of the code symbols in a file. This should be the first tool to call when you want to understand a new file, unless you already know what you are looking for. :param relative_path: the relative path to the file to get the overview of :param depth: depth up to which descendants of top-level symbols shall be retrieved (e.g. 1 retrieves immediate children). Default 0. :param max_answer_chars: if the overview is longer than this number of characters, no content will be returned. -1 means the default value from the config will be used. Don't adjust unless there is really no other way to get the content required for the task. :return: a JSON object containing symbols grouped by kind in a compact format. """ result = self.get_symbol_overview(relative_path, depth=depth) compact_result = self.symbol_dict_grouper.group(result) result_json_str = self._to_json(compact_result) return self._limit_length(result_json_str, max_answer_chars) def get_symbol_overview(self, relative_path: str, depth: int = 0) -> list[LanguageServerSymbol.OutputDict]: """ :param relative_path: relative path to a source file :param depth: the depth up to which descendants shall be retrieved :return: a list of symbol dictionaries representing the symbol overview of the file """ symbol_retriever = self.create_language_server_symbol_retriever() # The symbol overview is capable of working with both files and directories, # but we want to ensure that the user provides a file path. file_path = os.path.join(self.project.project_root, relative_path) if not os.path.exists(file_path): raise FileNotFoundError(f"File or directory {relative_path} does not exist in the project.") if os.path.isdir(file_path): raise ValueError(f"Expected a file path, but got a directory path: {relative_path}. ") if not symbol_retriever.can_analyze_file(relative_path): raise ValueError( f"Cannot extract symbols from file {relative_path}. Active languages: {[l.value for l in self.agent.get_active_lsp_languages()]}" ) symbols = symbol_retriever.get_symbol_overview(relative_path)[relative_path] def child_inclusion_predicate(s: LanguageServerSymbol) -> bool: return not s.is_low_level() symbol_dicts = [] for symbol in symbols: symbol_dicts.append( symbol.to_dict( name_path=False, name=True, depth=depth, kind=True, relative_path=False, location=False, child_inclusion_predicate=child_inclusion_predicate, ) ) return symbol_dicts class FindSymbolTool(Tool, ToolMarkerSymbolicRead): """ Performs a global (or local) search using the language server backend. """ # noinspection PyDefaultArgument def apply( self, name_path_pattern: str, depth: int = 0, relative_path: str = "", include_body: bool = False, include_info: bool = False, include_kinds: list[int] = [], # noqa: B006 exclude_kinds: list[int] = [], # noqa: B006 substring_matching: bool = False, max_answer_chars: int = -1, ) -> str: """ Retrieves information on all symbols/code entities (classes, methods, etc.) based on the given name path pattern. The returned symbol information can be used for edits or further queries. Specify `depth > 0` to also retrieve children/descendants (e.g., methods of a class). A name path is a path in the symbol tree *within a source file*. For example, the method `my_method` defined in class `MyClass` would have the name path `MyClass/my_method`. If a symbol is overloaded (e.g., in Java), a 0-based index is appended (e.g. "MyClass/my_method[0]") to uniquely identify it. To search for a symbol, you provide a name path pattern that is used to match against name paths. It can be * a simple name (e.g. "method"), which will match any symbol with that name * a relative path like "class/method", which will match any symbol with that name path suffix * an absolute name path "/class/method" (absolute name path), which requires an exact match of the full name path within the source file. Append an index `[i]` to match a specific overload only, e.g. "MyClass/my_method[1]". :param name_path_pattern: the name path matching pattern (see above) :param depth: depth up to which descendants shall be retrieved (e.g. use 1 to also retrieve immediate children; for the case where the symbol is a class, this will return its methods). Default 0. :param relative_path: Optional. Restrict search to this file or directory. If None, searches entire codebase. If a directory is passed, the search will be restricted to the files in that directory. If a file is passed, the search will be restricted to that file. If you have some knowledge about the codebase, you should use this parameter, as it will significantly speed up the search as well as reduce the number of results. :param include_body: whether to include the symbol's source code. Use judiciously. :param include_info: whether to include additional info (hover-like, typically including docstring and signature), about the symbol (ignored if include_body is True). Info is never included for child symbols. Note: Depending on the language, this can be slow (e.g., C/C++). :param include_kinds: List of LSP symbol kind integers to include. If not provided, all kinds are included. :param exclude_kinds: Optional. List of LSP symbol kind integers to exclude. Takes precedence over `include_kinds`. If not provided, no kinds are excluded. :param substring_matching: If True, use substring matching for the last element of the pattern, such that "Foo/get" would match "Foo/getValue" and "Foo/getData". :param max_answer_chars: Max characters for the JSON result. If exceeded, no content is returned. -1 means the default value from the config will be used. :return: a list of symbols (with locations) matching the name. """ parsed_include_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in include_kinds] if include_kinds else None parsed_exclude_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in exclude_kinds] if exclude_kinds else None symbol_retriever = self.create_language_server_symbol_retriever() symbols = symbol_retriever.find( name_path_pattern, include_kinds=parsed_include_kinds, exclude_kinds=parsed_exclude_kinds, substring_matching=substring_matching, within_relative_path=relative_path, ) symbol_dicts = [dict(s.to_dict(kind=True, relative_path=True, body_location=True, depth=depth, body=include_body)) for s in symbols] if not include_body and include_info: info_by_symbol = symbol_retriever.request_info_for_symbol_batch(symbols) for s, s_dict in zip(symbols, symbol_dicts, strict=True): if symbol_info := info_by_symbol.get(s): s_dict["info"] = symbol_info s_dict.pop("name", None) # name is included in the info result = self._to_json(symbol_dicts) return self._limit_length(result, max_answer_chars) class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead): """ Finds symbols that reference the given symbol using the language server backend """ symbol_dict_grouper = LanguageServerSymbolDictGrouper(["relative_path", "kind"], ["kind"], collapse_singleton=True) # noinspection PyDefaultArgument def apply( self, name_path: str, relative_path: str, include_kinds: list[int] = [], # noqa: B006 exclude_kinds: list[int] = [], # noqa: B006 max_answer_chars: int = -1, ) -> str: """ Finds references to the symbol at the given `name_path`. The result will contain metadata about the referencing symbols as well as a short code snippet around the reference. :param name_path: for finding the symbol to find references for, same logic as in the `find_symbol` tool. :param relative_path: the relative path to the file containing the symbol for which to find references. Note that here you can't pass a directory but must pass a file. :param include_kinds: same as in the `find_symbol` tool. :param exclude_kinds: same as in the `find_symbol` tool. :param max_answer_chars: same as in the `find_symbol` tool. :return: a list of JSON objects with the symbols referencing the requested symbol """ include_body = False # It is probably never a good idea to include the body of the referencing symbols parsed_include_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in include_kinds] if include_kinds else None parsed_exclude_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in exclude_kinds] if exclude_kinds else None symbol_retriever = self.create_language_server_symbol_retriever() references_in_symbols = symbol_retriever.find_referencing_symbols( name_path, relative_file_path=relative_path, include_body=include_body, include_kinds=parsed_include_kinds, exclude_kinds=parsed_exclude_kinds, ) reference_dicts = [] for ref in references_in_symbols: ref_dict_orig = ref.symbol.to_dict(kind=True, relative_path=True, depth=0, body=include_body, body_location=True) ref_dict = dict(ref_dict_orig) if not include_body: ref_relative_path = ref.symbol.location.relative_path assert ref_relative_path is not None, f"Referencing symbol {ref.symbol.name} has no relative path, this is likely a bug." content_around_ref = self.project.retrieve_content_around_line( relative_file_path=ref_relative_path, line=ref.line, context_lines_before=1, context_lines_after=1 ) ref_dict["content_around_reference"] = content_around_ref.to_display_string() reference_dicts.append(ref_dict) result = self.symbol_dict_grouper.group(reference_dicts) # type: ignore result_json = self._to_json(result) return self._limit_length(result_json, max_answer_chars) class ReplaceSymbolBodyTool(Tool, ToolMarkerSymbolicEdit): """ Replaces the full definition of a symbol using the language server backend. """ def apply( self, name_path: str, relative_path: str, body: str, ) -> str: r""" Replaces the body of the symbol with the given `name_path`. The tool shall be used to replace symbol bodies that have been previously retrieved (e.g. via `find_symbol`). IMPORTANT: Do not use this tool if you do not know what exactly constitutes the body of the symbol. :param name_path: for finding the symbol to replace, same logic as in the `find_symbol` tool. :param relative_path: the relative path to the file containing the symbol :param body: the new symbol body. The symbol body is the definition of a symbol in the programming language, including e.g. the signature line for functions. IMPORTANT: The body does NOT include any preceding docstrings/comments or imports, in particular. """ code_editor = self.create_code_editor() code_editor.replace_body( name_path, relative_file_path=relative_path, body=body, ) return SUCCESS_RESULT class InsertAfterSymbolTool(Tool, ToolMarkerSymbolicEdit): """ Inserts content after the end of the definition of a given symbol. """ def apply( self, name_path: str, relative_path: str, body: str, ) -> str: """ Inserts the given body/content after the end of the definition of the given symbol (via the symbol's location). A typical use case is to insert a new class, function, method, field or variable assignment. :param name_path: name path of the symbol after which to insert content (definitions in the `find_symbol` tool apply) :param relative_path: the relative path to the file containing the symbol :param body: the body/content to be inserted. The inserted code shall begin with the next line after the symbol. """ code_editor = self.create_code_editor() code_editor.insert_after_symbol(name_path, relative_file_path=relative_path, body=body) return SUCCESS_RESULT class InsertBeforeSymbolTool(Tool, ToolMarkerSymbolicEdit): """ Inserts content before the beginning of the definition of a given symbol. """ def apply( self, name_path: str, relative_path: str, body: str, ) -> str: """ Inserts the given content before the beginning of the definition of the given symbol (via the symbol's location). A typical use case is to insert a new class, function, method, field or variable assignment; or a new import statement before the first symbol in the file. :param name_path: name path of the symbol before which to insert content (definitions in the `find_symbol` tool apply) :param relative_path: the relative path to the file containing the symbol :param body: the body/content to be inserted before the line in which the referenced symbol is defined """ code_editor = self.create_code_editor() code_editor.insert_before_symbol(name_path, relative_file_path=relative_path, body=body) return SUCCESS_RESULT class RenameSymbolTool(Tool, ToolMarkerSymbolicEdit): """ Renames a symbol throughout the codebase using language server refactoring capabilities. """ def apply( self, name_path: str, relative_path: str, new_name: str, ) -> str: """ Renames the symbol with the given `name_path` to `new_name` throughout the entire codebase. Note: for languages with method overloading, like Java, name_path may have to include a method's signature to uniquely identify a method. :param name_path: name path of the symbol to rename (definitions in the `find_symbol` tool apply) :param relative_path: the relative path to the file containing the symbol to rename :param new_name: the new name for the symbol :return: result summary indicating success or failure """ code_editor = self.create_code_editor() status_message = code_editor.rename_symbol(name_path, relative_file_path=relative_path, new_name=new_name) return status_message ================================================ FILE: src/serena/tools/tools_base.py ================================================ import inspect import json from abc import ABC from collections.abc import Iterable from dataclasses import dataclass from types import TracebackType from typing import TYPE_CHECKING, Any, Protocol, Self, TypeVar, cast from mcp import Implementation from mcp.server.fastmcp import Context from mcp.server.fastmcp.utilities.func_metadata import FuncMetadata, func_metadata from sensai.util import logging from sensai.util.string import dict_string from serena.config.serena_config import LanguageBackend from serena.project import MemoriesManager, Project from serena.prompt_factory import PromptFactory from serena.util.class_decorators import singleton from serena.util.inspection import iter_subclasses from solidlsp.ls_exceptions import SolidLSPException if TYPE_CHECKING: from serena.agent import SerenaAgent from serena.code_editor import CodeEditor from serena.symbol import LanguageServerSymbolRetriever log = logging.getLogger(__name__) T = TypeVar("T") SUCCESS_RESULT = "OK" class Component(ABC): def __init__(self, agent: "SerenaAgent"): self.agent = agent def get_project_root(self) -> str: """ :return: the root directory of the active project, raises a ValueError if no active project configuration is set """ return self.project.project_root @property def prompt_factory(self) -> PromptFactory: return self.agent.prompt_factory @property def memories_manager(self) -> "MemoriesManager": return self.project.memories_manager def create_language_server_symbol_retriever(self) -> "LanguageServerSymbolRetriever": from serena.symbol import LanguageServerSymbolRetriever assert self.agent.get_language_backend().is_lsp(), "Language server symbol retriever can only be created for LSP language backend" return LanguageServerSymbolRetriever(self.project) @property def project(self) -> Project: return self.agent.get_active_project_or_raise() def create_code_editor(self) -> "CodeEditor": from ..code_editor import JetBrainsCodeEditor, LanguageServerCodeEditor match self.agent.get_language_backend(): case LanguageBackend.LSP: return LanguageServerCodeEditor(self.create_language_server_symbol_retriever()) case LanguageBackend.JETBRAINS: return JetBrainsCodeEditor(project=self.project) case _: raise ValueError class ToolMarker: """ Base class for tool markers. """ class ToolMarkerCanEdit(ToolMarker): """ Marker class for all tools that can perform editing operations on files. """ class ToolMarkerDoesNotRequireActiveProject(ToolMarker): pass class ToolMarkerOptional(ToolMarker): """ Marker class for optional tools that are disabled by default. """ class ToolMarkerSymbolicRead(ToolMarker): """ Marker class for tools that perform symbol read operations. """ class ToolMarkerSymbolicEdit(ToolMarkerCanEdit): """ Marker class for tools that perform symbolic edit operations. """ class ApplyMethodProtocol(Protocol): """Callable protocol for the apply method of a tool.""" def __call__(self, *args: Any, **kwargs: Any) -> str: pass class Tool(Component): # NOTE: each tool should implement the apply method, which is then used in # the central method of the Tool class `apply_ex`. # Failure to do so will result in a RuntimeError at tool execution time. # The apply method is not declared as part of the base Tool interface since we cannot # know the signature of the (input parameters of the) method in advance. # # The docstring and types of the apply method are used to generate the tool description # (which is use by the LLM, so a good description is important) # and to validate the tool call arguments. _last_tool_call_client_str: str | None = None """We can only get the client info from within a tool call. Each tool call will update this variable.""" @classmethod def set_last_tool_call_client_str(cls, client_str: str | None) -> None: cls._last_tool_call_client_str = client_str @classmethod def get_last_tool_call_client_str(cls) -> str | None: return cls._last_tool_call_client_str @classmethod def get_name_from_cls(cls) -> str: name = cls.__name__ if name.endswith("Tool"): name = name[:-4] # convert to snake_case name = "".join(["_" + c.lower() if c.isupper() else c for c in name]).lstrip("_") return name def get_name(self) -> str: return self.get_name_from_cls() def get_apply_fn(self) -> ApplyMethodProtocol: apply_fn = getattr(self, "apply") if apply_fn is None: raise RuntimeError(f"apply not defined in {self}. Did you forget to implement it?") return apply_fn @classmethod def can_edit(cls) -> bool: """ Returns whether this tool can perform editing operations on code. :return: True if the tool can edit code, False otherwise """ return issubclass(cls, ToolMarkerCanEdit) @classmethod def get_tool_description(cls) -> str: docstring = cls.__doc__ if docstring is None: return "" return docstring.strip() @classmethod def get_apply_docstring_from_cls(cls) -> str: """Get the docstring for the apply method from the class (static metadata). Needed for creating MCP tools in a separate process without running into serialization issues. """ # First try to get from __dict__ to handle dynamic docstring changes if "apply" in cls.__dict__: apply_fn = cls.__dict__["apply"] else: # Fall back to getattr for inherited methods apply_fn = getattr(cls, "apply", None) if apply_fn is None: raise AttributeError(f"apply method not defined in {cls}. Did you forget to implement it?") docstring = apply_fn.__doc__ if not docstring: raise AttributeError(f"apply method has no (or empty) docstring in {cls}. Did you forget to implement it?") return docstring.strip() def get_apply_docstring(self) -> str: """Gets the docstring for the tool application, used by the MCP server.""" return self.get_apply_docstring_from_cls() def get_apply_fn_metadata(self) -> FuncMetadata: """Gets the metadata for the tool application function, used by the MCP server.""" return self.get_apply_fn_metadata_from_cls() @classmethod def get_apply_fn_metadata_from_cls(cls) -> FuncMetadata: """Get the metadata for the apply method from the class (static metadata). Needed for creating MCP tools in a separate process without running into serialization issues. """ # First try to get from __dict__ to handle dynamic docstring changes if "apply" in cls.__dict__: apply_fn = cls.__dict__["apply"] else: # Fall back to getattr for inherited methods apply_fn = getattr(cls, "apply", None) if apply_fn is None: raise AttributeError(f"apply method not defined in {cls}. Did you forget to implement it?") return func_metadata(apply_fn, skip_names=["self", "cls"]) def _log_tool_application(self, frame: Any) -> None: params = {} ignored_params = {"self", "log_call", "catch_exceptions", "args", "apply_fn"} for param, value in frame.f_locals.items(): if param in ignored_params: continue if param == "kwargs": params.update(value) else: params[param] = value log.info(f"{self.get_name_from_cls()}: {dict_string(params)}") def _limit_length(self, result: str, max_answer_chars: int) -> str: if max_answer_chars == -1: max_answer_chars = self.agent.serena_config.default_max_tool_answer_chars if max_answer_chars <= 0: raise ValueError(f"Must be positive or the default (-1), got: {max_answer_chars=}") if (n_chars := len(result)) > max_answer_chars: result = ( f"The answer is too long ({n_chars} characters). " + "Please try a more specific tool query or raise the max_answer_chars parameter." ) return result def is_active(self) -> bool: return self.agent.tool_is_active(self.get_name()) def is_readonly(self) -> bool: return not self.can_edit() def is_symbolic(self) -> bool: return issubclass(self.__class__, ToolMarkerSymbolicRead) or issubclass(self.__class__, ToolMarkerSymbolicEdit) def apply_ex(self, log_call: bool = True, catch_exceptions: bool = True, mcp_ctx: Context | None = None, **kwargs) -> str: # type: ignore """ Applies the tool with logging and exception handling, using the given keyword arguments """ if mcp_ctx is not None: try: client_params = mcp_ctx.session.client_params if client_params is not None: client_info = cast(Implementation, client_params.clientInfo) client_str = client_info.title if client_info.title else client_info.name + " " + client_info.version if client_str != self.get_last_tool_call_client_str(): log.debug(f"Updating client info: {client_info}") self.set_last_tool_call_client_str(client_str) except BaseException as e: log.info(f"Failed to get client info: {e}.") def task() -> str: apply_fn = self.get_apply_fn() try: if not self.is_active(): return f"Error: Tool '{self.get_name_from_cls()}' is not active. Active tools: {self.agent.get_active_tool_names()}" except Exception as e: return f"RuntimeError while checking if tool {self.get_name_from_cls()} is active: {e}" if log_call: self._log_tool_application(inspect.currentframe()) try: # check whether the tool requires an active project and language server if not isinstance(self, ToolMarkerDoesNotRequireActiveProject): if self.agent.get_active_project() is None: return ( "Error: No active project. Ask the user to provide the project path or to select a project from this list of known projects: " + f"{self.agent.serena_config.project_names}" ) # apply the actual tool try: result = apply_fn(**kwargs) except SolidLSPException as e: if e.is_language_server_terminated(): affected_language = e.get_affected_language() if affected_language is not None: log.error( f"Language server terminated while executing tool ({e}). Restarting the language server and retrying ..." ) self.agent.get_language_server_manager_or_raise().restart_language_server(affected_language) result = apply_fn(**kwargs) else: log.error( f"Language server terminated while executing tool ({e}), but affected language is unknown. Not retrying." ) raise else: raise # record tool usage self.agent.record_tool_usage(kwargs, result, self) except Exception as e: if not catch_exceptions: raise msg = f"Error executing tool: {e.__class__.__name__} - {e}" log.error(f"Error executing tool: {e}", exc_info=e) result = msg if log_call: log.info(f"Result: {result}") try: ls_manager = self.agent.get_language_server_manager() if ls_manager is not None: ls_manager.save_all_caches() except Exception as e: log.error(f"Error saving language server cache: {e}") return result # execute the tool in the agent's task executor, with timeout try: task_exec = self.agent.issue_task(task, name=self.__class__.__name__) return task_exec.result(timeout=self.agent.serena_config.tool_timeout) except Exception as e: # typically TimeoutError (other exceptions caught in task) msg = f"Error: {e.__class__.__name__} - {e}" log.error(msg) return msg @staticmethod def _to_json(x: Any) -> str: return json.dumps(x, ensure_ascii=False) class EditedFileContext: """ Context manager for file editing. Create the context, then use `set_updated_content` to set the new content, the original content being provided in `original_content`. When exiting the context without an exception, the updated content will be written back to the file. """ def __init__(self, relative_path: str, code_editor: "CodeEditor"): self._relative_path = relative_path self._code_editor = code_editor self._edited_file: CodeEditor.EditedFile | None = None self._edited_file_context: Any = None def __enter__(self) -> Self: self._edited_file_context = self._code_editor.edited_file_context(self._relative_path) self._edited_file = self._edited_file_context.__enter__() return self def get_original_content(self) -> str: """ :return: the original content of the file before any modifications. """ assert self._edited_file is not None return self._edited_file.get_contents() def set_updated_content(self, content: str) -> None: """ Sets the updated content of the file, which will be written back to the file when the context is exited without an exception. :param content: the updated content of the file """ assert self._edited_file is not None self._edited_file.set_contents(content) def __exit__(self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None) -> None: assert self._edited_file_context is not None self._edited_file_context.__exit__(exc_type, exc_value, traceback) @dataclass(kw_only=True) class RegisteredTool: tool_class: type[Tool] is_optional: bool tool_name: str @property def class_docstring(self) -> str: """ :return: the tool description (high-level class docstring) """ return self.tool_class.get_tool_description() tool_packages = ["serena.tools"] @singleton class ToolRegistry: def __init__(self) -> None: self._tool_dict: dict[str, RegisteredTool] = {} inclusion_predicate = lambda c: "apply" in c.__dict__ # include only concrete tool classes that implement apply for cls in iter_subclasses(Tool, inclusion_predicate=inclusion_predicate): if not any(cls.__module__.startswith(pkg) for pkg in tool_packages): continue is_optional = issubclass(cls, ToolMarkerOptional) name = cls.get_name_from_cls() if name in self._tool_dict: raise ValueError(f"Duplicate tool name found: {name}. Tool classes must have unique names.") self._tool_dict[name] = RegisteredTool(tool_class=cls, is_optional=is_optional, tool_name=name) def get_registered_tools_by_module(self) -> dict[str, list[RegisteredTool]]: """ :return: the registered tools grouped by their module (ordered alphabetically by module and tool name) """ module_dict: dict[str, list[RegisteredTool]] = {} for tool in self._tool_dict.values(): module = tool.tool_class.__module__ if module not in module_dict: module_dict[module] = [] module_dict[module].append(tool) sorted_module_dict = {} for module in sorted(module_dict.keys()): sorted_module_dict[module] = sorted(module_dict[module], key=lambda t: t.tool_name) return sorted_module_dict def get_tool_class_by_name(self, tool_name: str) -> type[Tool]: if tool_name not in self._tool_dict: raise ValueError(f"Tool named '{tool_name}' not found.") return self._tool_dict[tool_name].tool_class def get_all_tool_classes(self) -> list[type[Tool]]: return list(t.tool_class for t in self._tool_dict.values()) def get_tool_classes_default_enabled(self) -> list[type[Tool]]: """ :return: the list of tool classes that are enabled by default (i.e. non-optional tools). """ return [t.tool_class for t in self._tool_dict.values() if not t.is_optional] def get_tool_classes_optional(self) -> list[type[Tool]]: """ :return: the list of tool classes that are optional (i.e. disabled by default). """ return [t.tool_class for t in self._tool_dict.values() if t.is_optional] def get_tool_names_default_enabled(self) -> list[str]: """ :return: the list of tool names that are enabled by default (i.e. non-optional tools). """ return [t.tool_name for t in self._tool_dict.values() if not t.is_optional] def get_tool_names_optional(self) -> list[str]: """ :return: the list of tool names that are optional (i.e. disabled by default). """ return [t.tool_name for t in self._tool_dict.values() if t.is_optional] def get_tool_names(self) -> list[str]: """ :return: the list of all tool names. """ return list(self._tool_dict.keys()) def print_tool_overview( self, tools: Iterable[type[Tool] | Tool] | None = None, include_optional: bool = False, only_optional: bool = False ) -> None: """ Print a summary of the tools. If no tools are passed, a summary of the selection of tools (all, default or only optional) is printed. """ if tools is None: if only_optional: tools = self.get_tool_classes_optional() elif include_optional: tools = self.get_all_tool_classes() else: tools = self.get_tool_classes_default_enabled() tool_dict: dict[str, type[Tool] | Tool] = {} for tool_class in tools: tool_dict[tool_class.get_name_from_cls()] = tool_class for tool_name in sorted(tool_dict.keys()): tool_class = tool_dict[tool_name] print(f" * `{tool_name}`: {tool_class.get_tool_description().strip()}") def is_valid_tool_name(self, tool_name: str) -> bool: return tool_name in self._tool_dict ================================================ FILE: src/serena/tools/workflow_tools.py ================================================ """ Tools supporting the general workflow of the agent """ import platform from serena.tools import Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional class CheckOnboardingPerformedTool(Tool): """ Checks whether project onboarding was already performed. """ def apply(self) -> str: """ Checks whether project onboarding was already performed. You should always call this tool before beginning to actually work on the project/after activating a project. """ project_memories = self.memories_manager.list_project_memories() if len(project_memories) == 0: msg = ( "Onboarding not performed yet (no memories available). " "You should perform onboarding by calling the `onboarding` tool before proceeding with the task. " ) else: # Not reporting the list of memories here, as they were already reported at project activation # (with the system prompt if the project was activated at startup) msg = ( f"Onboarding was already performed: {len(project_memories)} project memories are available. " "Consider reading memories if they appear relevant to the task at hand." ) msg += " If you have not read the 'Serena Instructions Manual', do so now." return msg class OnboardingTool(Tool): """ Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). """ def apply(self) -> str: """ Call this tool if onboarding was not performed yet. You will call this tool at most once per conversation. :return: instructions on how to create the onboarding information """ system = platform.system() return self.prompt_factory.create_onboarding_prompt(system=system) class ThinkAboutCollectedInformationTool(Tool, ToolMarkerOptional): """ Thinking tool for pondering the completeness of collected information. """ def apply(self) -> str: """ Think about the collected information and whether it is sufficient and relevant. This tool should ALWAYS be called after you have completed a non-trivial sequence of searching steps like find_symbol, find_referencing_symbols, search_files_for_pattern, read_file, etc. """ return self.prompt_factory.create_think_about_collected_information() class ThinkAboutTaskAdherenceTool(Tool, ToolMarkerOptional): """ Thinking tool for determining whether the agent is still on track with the current task. """ def apply(self) -> str: """ Think about the task at hand and whether you are still on track. Especially important if the conversation has been going on for a while and there has been a lot of back and forth. This tool should ALWAYS be called before you insert, replace, or delete code. """ return self.prompt_factory.create_think_about_task_adherence() class ThinkAboutWhetherYouAreDoneTool(Tool, ToolMarkerOptional): """ Thinking tool for determining whether the task is truly completed. """ def apply(self) -> str: """ Whenever you feel that you are done with what the user has asked for, it is important to call this tool. """ return self.prompt_factory.create_think_about_whether_you_are_done() class SummarizeChangesTool(Tool, ToolMarkerOptional): """ Provides instructions for summarizing the changes made to the codebase. """ def apply(self) -> str: """ Summarize the changes you have made to the codebase. This tool should always be called after you have fully completed any non-trivial coding task, but only after the think_about_whether_you_are_done call. """ return self.prompt_factory.create_summarize_changes() class PrepareForNewConversationTool(Tool): """ Provides instructions for preparing for a new conversation (in order to continue with the necessary context). """ def apply(self) -> str: """ Instructions for preparing for a new conversation. This tool should only be called on explicit user request. """ return self.prompt_factory.create_prepare_for_new_conversation() class InitialInstructionsTool(Tool, ToolMarkerDoesNotRequireActiveProject): """ Provides instructions on how to use the Serena toolbox. Should only be used in settings where the system prompt is not read automatically by the client. NOTE: Some MCP clients (including Claude Desktop) do not read the system prompt automatically! """ def apply(self) -> str: """ Provides the 'Serena Instructions Manual', which contains essential information on how to use the Serena toolbox. IMPORTANT: If you have not yet read the manual, call this tool immediately after you are given your task by the user, as it will critically inform you! """ return self.agent.create_system_prompt() ================================================ FILE: src/serena/util/class_decorators.py ================================================ from typing import Any # duplicate of interprompt.class_decorators # We don't want to depend on interprompt for this in serena, so we duplicate it here def singleton(cls: type[Any]) -> Any: instance = None def get_instance(*args: Any, **kwargs: Any) -> Any: nonlocal instance if instance is None: instance = cls(*args, **kwargs) return instance return get_instance ================================================ FILE: src/serena/util/cli_util.py ================================================ def ask_yes_no(question: str, default: bool | None = None) -> bool: default_prompt = "Y/n" if default else "y/N" while True: answer = input(f"{question} [{default_prompt}] ").strip().lower() if answer == "" and default is not None: return default if answer in ("y", "yes"): return True if answer in ("n", "no"): return False print("Please answer yes/y or no/n.") ================================================ FILE: src/serena/util/dataclass.py ================================================ from dataclasses import MISSING, Field from typing import Any, cast def get_dataclass_default(cls: type, field_name: str) -> Any: """ Gets the default value of a dataclass field. :param cls: The dataclass type. :param field_name: The name of the field. :return: The default value of the field (either from default or default_factory). """ field = cast(Field, cls.__dataclass_fields__[field_name]) # type: ignore[attr-defined] if field.default is not MISSING: return field.default if field.default_factory is not MISSING: # default_factory is a function return field.default_factory() raise AttributeError(f"{field_name} has no default") ================================================ FILE: src/serena/util/dotnet.py ================================================ import logging import platform import re import shutil import subprocess import urllib from pathlib import Path from serena.util.version import Version from solidlsp.ls_exceptions import SolidLSPException log = logging.getLogger(__name__) class DotNETUtil: def __init__(self, required_version: str, allow_higher_version: bool = True): """ :param required_version: the required .NET runtime version specified as a string (e.g. "10.0" for .NET 10.0) :param allow_higher_version: whether to allow higher versions than the required version """ self._system_dotnet = shutil.which("dotnet") self._required_version_str = required_version self._required_version_components = [int(c) for c in required_version.split(".")] self._allow_higher_version = allow_higher_version self._installed_versions = self._determine_installed_versions() def _determine_installed_versions(self) -> list[Version]: if self._system_dotnet: try: result = subprocess.run([self._system_dotnet, "--list-runtimes"], capture_output=True, text=True, check=True) version_strings = re.findall(r"Microsoft.NETCore.App\s+([^\s]+)", result.stdout) log.info("Installed .NET runtime versions: %s", version_strings) return [Version(v) for v in version_strings] except: log.warning("Failed to run 'dotnet --list-runtimes' to check .NET version; assuming no installed .NET versions") return [] else: log.info("Found no `dotnet` on system PATH; assuming no installed .NET versions") return [] def is_required_version_available(self) -> bool: """ Checks whether the required .NET runtime version is installed and raises an exception if not. :param required_version_components: the required .NET runtime version specified as a list of integers representing the version components (e.g., [6, 1] for .NET 6.1) :param allow_higher_version: whether to allow higher versions than the required version (e.g., if True, .NET 7.0 would satisfy a requirement of .NET 6.1) """ required_version_str = ".".join(str(c) for c in self._required_version_components) for v in self._installed_versions: if self._allow_higher_version: if v.is_at_least(*self._required_version_components): log.info(f"Found installed .NET runtime version {v} which satisfies requirement of {required_version_str} or higher") return True else: if v.is_equal(*self._required_version_components): log.info(f"Found installed .NET runtime version {v} which satisfies requirement of {required_version_str}") return True return False def get_dotnet_path_or_raise(self) -> str: """ Returns the path to the dotnet executable if the required .NET runtime version is available, otherwise raises an exception. """ if not self.is_required_version_available(): raise SolidLSPException( f"Required .NET runtime version {self._required_version_str} not found " f"(installed versions: {self._installed_versions}). " "Please install the required .NET runtime version from https://dotnet.microsoft.com/en-us/download/dotnet " "and ensure that `dotnet` is on the system PATH." ) assert self._system_dotnet is not None return self._system_dotnet @staticmethod def install_dotnet_with_script(version: str, base_path: str) -> str: """ Install .NET runtime using Microsoft's official installation script. NOTE: This method is unreliable and therefore currently unused. It is kept for reference. :version: the version to install as a string (e.g. "10.0") :return: the path to the dotnet executable. """ dotnet_dir = Path(base_path) / f"dotnet-runtime-{version}" # Determine binary name based on platform is_windows = platform.system().lower() == "windows" dotnet_exe = dotnet_dir / ("dotnet.exe" if is_windows else "dotnet") if dotnet_exe.exists(): log.info(f"Using cached .NET {version} runtime from {dotnet_exe}") return str(dotnet_exe) # Download and run install script log.info(f"Installing .NET {version} runtime using official Microsoft install script...") dotnet_dir.mkdir(parents=True, exist_ok=True) try: if is_windows: # PowerShell script for Windows script_url = "https://dot.net/v1/dotnet-install.ps1" script_path = dotnet_dir / "dotnet-install.ps1" urllib.request.urlretrieve(script_url, script_path) cmd = [ "pwsh", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(script_path), "-Version", version, "-InstallDir", str(dotnet_dir), "-Runtime", "dotnet", "-NoPath", ] else: # Bash script for Linux/macOS script_url = "https://dot.net/v1/dotnet-install.sh" script_path = dotnet_dir / "dotnet-install.sh" urllib.request.urlretrieve(script_url, script_path) script_path.chmod(0o755) cmd = [ "bash", str(script_path), "--version", version, "--install-dir", str(dotnet_dir), "--runtime", "dotnet", "--no-path", ] # Run the install script log.info("Running .NET install script: %s", cmd) result = subprocess.run(cmd, capture_output=True, text=True, check=True) log.debug(f"Install script output: {result.stdout}") if not dotnet_exe.exists(): raise SolidLSPException(f"dotnet executable not found at {dotnet_exe} after installation") log.info(f"Successfully installed .NET {version} runtime to {dotnet_exe}") return str(dotnet_exe) except subprocess.CalledProcessError as e: raise SolidLSPException(f"Failed to install .NET {version} runtime using install script: {e.stderr if e.stderr else e}") from e except Exception as e: message = f"Failed to install .NET {version} runtime: {e}" if is_windows and isinstance(e, FileNotFoundError): message += "; pwsh, i.e. PowerShell 7+, is required to install .NET runtime. Make sure pwsh is available on your system." raise SolidLSPException(message) from e ================================================ FILE: src/serena/util/exception.py ================================================ import os import sys from serena.agent import log def is_headless_environment() -> bool: """ Detect if we're running in a headless environment where GUI operations would fail. Returns True if: - No DISPLAY variable on Linux/Unix - Running in SSH session - Running in WSL without X server - Running in Docker container """ # Check if we're on Windows - GUI usually works there if sys.platform == "win32": return False # Check for DISPLAY variable (required for X11) if not os.environ.get("DISPLAY"): # type: ignore return True # Check for SSH session if os.environ.get("SSH_CONNECTION") or os.environ.get("SSH_CLIENT"): return True # Check for common CI/container environments if os.environ.get("CI") or os.environ.get("CONTAINER") or os.path.exists("/.dockerenv"): return True # Check for WSL (only on Unix-like systems where os.uname exists) if hasattr(os, "uname"): if "microsoft" in os.uname().release.lower(): # In WSL, even with DISPLAY set, X server might not be running # This is a simplified check - could be improved return True return False def show_fatal_exception_safe(e: Exception) -> None: """ Shows the given exception in the GUI log viewer on the main thread and ensures that the exception is logged or at least printed to stderr. """ # Log the error and print it to stderr log.error(f"Fatal exception: {e}", exc_info=e) print(f"Fatal exception: {e}", file=sys.stderr) # Don't attempt GUI in headless environments if is_headless_environment(): log.debug("Skipping GUI error display in headless environment") return # attempt to show the error in the GUI try: # NOTE: The import can fail on macOS if Tk is not available (depends on Python interpreter installation, which uv # used as a base); while tkinter as such is always available, its dependencies can be unavailable on macOS. from serena.gui_log_viewer import show_fatal_exception show_fatal_exception(e) except Exception as gui_error: log.debug(f"Failed to show GUI error dialog: {gui_error}") ================================================ FILE: src/serena/util/file_system.py ================================================ import logging import os from collections.abc import Callable, Iterator from dataclasses import dataclass, field from pathlib import Path from typing import NamedTuple import pathspec from pathspec import PathSpec from sensai.util.logging import LogTime log = logging.getLogger(__name__) class ScanResult(NamedTuple): """Result of scanning a directory.""" directories: list[str] files: list[str] def scan_directory( path: str, recursive: bool = False, relative_to: str | None = None, is_ignored_dir: Callable[[str], bool] | None = None, is_ignored_file: Callable[[str], bool] | None = None, ) -> ScanResult: """ :param path: the path to scan :param recursive: whether to recursively scan subdirectories :param relative_to: the path to which the results should be relative to; if None, provide absolute paths :param is_ignored_dir: a function with which to determine whether the given directory (abs. path) shall be ignored :param is_ignored_file: a function with which to determine whether the given file (abs. path) shall be ignored :return: the list of directories and files """ if is_ignored_file is None: is_ignored_file = lambda x: False if is_ignored_dir is None: is_ignored_dir = lambda x: False files = [] directories = [] abs_path = os.path.abspath(path) rel_base = os.path.abspath(relative_to) if relative_to else None try: with os.scandir(abs_path) as entries: for entry in entries: try: entry_path = entry.path if rel_base: try: result_path = os.path.relpath(entry_path, rel_base) except: log.debug(f"Skipping entry due to relative path conversion error: {entry.path}") continue else: result_path = entry_path if entry.is_file(): if not is_ignored_file(entry_path): files.append(result_path) elif entry.is_dir(): if not is_ignored_dir(entry_path): directories.append(result_path) if recursive: sub_result = scan_directory( entry_path, recursive=True, relative_to=relative_to, is_ignored_dir=is_ignored_dir, is_ignored_file=is_ignored_file, ) files.extend(sub_result.files) directories.extend(sub_result.directories) except PermissionError as ex: # Skip files/directories that cannot be accessed due to permission issues log.debug(f"Skipping entry due to permission error: {entry.path}", exc_info=ex) continue except PermissionError as ex: # Skip the entire directory if it cannot be accessed log.debug(f"Skipping directory due to permission error: {abs_path}", exc_info=ex) return ScanResult([], []) return ScanResult(directories, files) def find_all_non_ignored_files(repo_root: str) -> list[str]: """ Find all non-ignored files in the repository, respecting all gitignore files in the repository. :param repo_root: The root directory of the repository :return: A list of all non-ignored files in the repository """ gitignore_parser = GitignoreParser(repo_root) _, files = scan_directory( repo_root, recursive=True, is_ignored_dir=gitignore_parser.should_ignore, is_ignored_file=gitignore_parser.should_ignore ) return files @dataclass class GitignoreSpec: file_path: str """Path to the gitignore file.""" patterns: list[str] = field(default_factory=list) """List of patterns from the gitignore file. The patterns are adjusted based on the gitignore file location. """ pathspec: PathSpec = field(init=False) """Compiled PathSpec object for pattern matching.""" def __post_init__(self) -> None: """Initialize the PathSpec from patterns.""" self.pathspec = PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, self.patterns) def matches(self, relative_path: str) -> bool: """ Check if the given path matches any pattern in this gitignore spec. :param relative_path: Path to check (should be relative to repo root) :return: True if path matches any pattern """ return match_path(relative_path, self.pathspec, root_path=os.path.dirname(self.file_path)) class GitignoreParser: """ Parser for gitignore files in a repository. This class handles parsing multiple gitignore files throughout a repository and provides methods to check if paths should be ignored. """ def __init__(self, repo_root: str) -> None: """ Initialize the parser for a repository. :param repo_root: Root directory of the repository """ self.repo_root = os.path.abspath(repo_root) self.ignore_specs: list[GitignoreSpec] = [] self._load_gitignore_files() def _load_gitignore_files(self) -> None: """Load all gitignore files from the repository.""" with LogTime("Loading of .gitignore files", logger=log): for gitignore_path in self._iter_gitignore_files(): log.info("Processing .gitignore file: %s", gitignore_path) spec = self._create_ignore_spec(gitignore_path) if spec.patterns: # Only add non-empty specs self.ignore_specs.append(spec) def _iter_gitignore_files(self, follow_symlinks: bool = False) -> Iterator[str]: """ Iteratively discover .gitignore files in a top-down fashion, starting from the repository root. Directory paths are skipped if they match any already loaded ignore patterns. :return: an iterator yielding paths to .gitignore files (top-down) """ queue: list[str] = [self.repo_root] def scan(abs_path: str | None) -> Iterator[str]: for entry in os.scandir(abs_path): if entry.is_dir(follow_symlinks=follow_symlinks): queue.append(entry.path) elif entry.is_file(follow_symlinks=follow_symlinks) and entry.name == ".gitignore": yield entry.path while queue: next_abs_path = queue.pop(0) if next_abs_path != self.repo_root: rel_path = os.path.relpath(next_abs_path, self.repo_root) if self.should_ignore(rel_path): continue yield from scan(next_abs_path) def _create_ignore_spec(self, gitignore_file_path: str) -> GitignoreSpec: """ Create a GitignoreSpec from a single gitignore file. :param gitignore_file_path: Path to the .gitignore file :return: GitignoreSpec object for the gitignore patterns """ try: with open(gitignore_file_path, encoding="utf-8") as f: content = f.read() except (OSError, UnicodeDecodeError): # If we can't read the file, return an empty spec return GitignoreSpec(gitignore_file_path, []) gitignore_dir = os.path.dirname(gitignore_file_path) patterns = self._parse_gitignore_content(content, gitignore_dir) return GitignoreSpec(gitignore_file_path, patterns) def _parse_gitignore_content(self, content: str, gitignore_dir: str) -> list[str]: """ Parse gitignore content and adjust patterns based on the gitignore file location. :param content: Content of the .gitignore file :param gitignore_dir: Directory containing the .gitignore file (absolute path) :return: List of adjusted patterns """ patterns = [] # Get the relative path from repo root to the gitignore directory rel_dir = os.path.relpath(gitignore_dir, self.repo_root) if rel_dir == ".": rel_dir = "" for line in content.splitlines(): # Strip trailing whitespace (but preserve leading whitespace for now) line = line.rstrip() # Skip empty lines and comments if not line or line.lstrip().startswith("#"): continue # Store whether this is a negation pattern is_negation = line.startswith("!") if is_negation: line = line[1:] # Strip leading/trailing whitespace after removing negation line = line.strip() if not line: continue # Handle escaped characters at the beginning if line.startswith(("\\#", "\\!")): line = line[1:] # Determine if pattern is anchored to the gitignore directory and remove leading slash for processing is_anchored = line.startswith("/") if is_anchored: line = line[1:] # Adjust pattern based on gitignore file location if rel_dir: if is_anchored: # Anchored patterns are relative to the gitignore directory adjusted_pattern = os.path.join(rel_dir, line) else: # Non-anchored patterns can match anywhere below the gitignore directory # We need to preserve this behavior if line.startswith("**/"): # Even if pattern starts with **, it should still be scoped to the subdirectory adjusted_pattern = os.path.join(rel_dir, line) else: # Add the directory prefix but also allow matching in subdirectories adjusted_pattern = os.path.join(rel_dir, "**", line) else: if is_anchored: # Anchored patterns in root should only match at root level # Add leading slash back to indicate root-only matching adjusted_pattern = "/" + line else: # Non-anchored patterns can match anywhere adjusted_pattern = line # Re-add negation if needed if is_negation: adjusted_pattern = "!" + adjusted_pattern # Normalize path separators to forward slashes (gitignore uses forward slashes) adjusted_pattern = adjusted_pattern.replace(os.sep, "/") patterns.append(adjusted_pattern) return patterns def should_ignore(self, path: str) -> bool: """ Check if a path should be ignored based on the gitignore rules. :param path: Path to check (absolute or relative to repo_root) :return: True if the path should be ignored, False otherwise """ # Convert to relative path from repo root if os.path.isabs(path): try: rel_path = os.path.relpath(path, self.repo_root) except Exception as e: # If the path could not be converted to a relative path, # it is outside the repository root, so we ignore it log.info("Ignoring path '%s' which is outside of the repository root (%s)", path, e) return True else: rel_path = path # Ignore paths inside .git rel_path_first_path = Path(rel_path).parts[0] if rel_path_first_path == ".git": return True abs_path = os.path.join(self.repo_root, rel_path) # Normalize path separators rel_path = rel_path.replace(os.sep, "/") if os.path.exists(abs_path) and os.path.isdir(abs_path) and not rel_path.endswith("/"): rel_path = rel_path + "/" # Check against each ignore spec for spec in self.ignore_specs: if spec.matches(rel_path): return True return False def get_ignore_specs(self) -> list[GitignoreSpec]: """ Get all loaded gitignore specs. :return: List of GitignoreSpec objects """ return self.ignore_specs def reload(self) -> None: """Reload all gitignore files from the repository.""" self.ignore_specs.clear() self._load_gitignore_files() def match_path(relative_path: str, path_spec: PathSpec, root_path: str = "") -> bool: """ Match a relative path against a given pathspec. Just pathspec.match_file() is not enough, we need to do some massaging to fix issues with pathspec matching. :param relative_path: relative path to match against the pathspec :param path_spec: the pathspec to match against :param root_path: the root path from which the relative path is derived :return: """ normalized_path = str(relative_path).replace(os.path.sep, "/") # We can have patterns like /src/..., which would only match corresponding paths from the repo root # Unfortunately, pathspec can't know whether a relative path is relative to the repo root or not, # so it will never match src/... # The fix is to just always assume that the input path is relative to the repo root and to # prefix it with /. if not normalized_path.startswith("/"): normalized_path = "/" + normalized_path # pathspec can't handle the matching of directories if they don't end with a slash! # see https://github.com/cpburnz/python-pathspec/issues/89 abs_path = os.path.abspath(os.path.join(root_path, relative_path)) if os.path.isdir(abs_path) and not normalized_path.endswith("/"): normalized_path = normalized_path + "/" return path_spec.match_file(normalized_path) ================================================ FILE: src/serena/util/git.py ================================================ import logging from sensai.util.git import GitStatus from .shell import subprocess_check_output log = logging.getLogger(__name__) def get_git_status() -> GitStatus | None: try: commit_hash = subprocess_check_output(["git", "rev-parse", "HEAD"]) unstaged = bool(subprocess_check_output(["git", "diff", "--name-only"])) staged = bool(subprocess_check_output(["git", "diff", "--staged", "--name-only"])) untracked = bool(subprocess_check_output(["git", "ls-files", "--others", "--exclude-standard"])) return GitStatus( commit=commit_hash, has_unstaged_changes=unstaged, has_staged_uncommitted_changes=staged, has_untracked_files=untracked ) except: return None ================================================ FILE: src/serena/util/gui.py ================================================ import os import platform def system_has_usable_display() -> bool: system = platform.system() # macOS and native Windows: assume display is available for desktop usage if system == "Darwin" or system == "Windows": return True # Other systems, assumed to be Unix-like (Linux, FreeBSD, Cygwin/MSYS, etc.): # detect display availability since users may operate in CLI contexts else: # Check X11 or Wayland - if environment variables are set to non-empty values, assume display is usable display = os.environ.get("DISPLAY", "") wayland_display = os.environ.get("WAYLAND_DISPLAY", "") if display or wayland_display: return True return False ================================================ FILE: src/serena/util/inspection.py ================================================ import logging import os from collections.abc import Callable, Iterator from typing import TypeVar from serena.util.file_system import find_all_non_ignored_files from solidlsp.ls_config import Language T = TypeVar("T") log = logging.getLogger(__name__) def iter_subclasses( cls: type[T], recursive: bool = True, inclusion_predicate: Callable[[type[T]], bool] = lambda t: True ) -> Iterator[type[T]]: """Iterate over all subclasses of a class. :param cls: The class whose subclasses to iterate over. :param recursive: If True, also iterate over all subclasses of all subclasses. :param inclusion_predicate: a predicate function to decide whether to include a subclass in the result """ for subclass in cls.__subclasses__(): if inclusion_predicate(subclass): yield subclass if recursive: yield from iter_subclasses(subclass, recursive, inclusion_predicate) def determine_programming_language_composition(repo_path: str) -> dict[Language, float]: """ Determine the programming language composition of a repository. :param repo_path: Path to the repository to analyze :return: Dictionary mapping languages to percentages of files matching each language """ all_files = find_all_non_ignored_files(repo_path) if not all_files: return {} # Count files for each language language_counts: dict[Language, int] = {} total_files = len(all_files) for language in Language.iter_all(include_experimental=False): matcher = language.get_source_fn_matcher() count = 0 for file_path in all_files: # Use just the filename for matching, not the full path filename = os.path.basename(file_path) if matcher.is_relevant_filename(filename): count += 1 if count > 0: language_counts[language] = count # Convert counts to percentages language_percentages: dict[Language, float] = {} for language, count in language_counts.items(): percentage = (count / total_files) * 100 language_percentages[language] = round(percentage, 2) return language_percentages ================================================ FILE: src/serena/util/logging.py ================================================ import queue import threading from collections.abc import Callable from dataclasses import dataclass from typing import Optional from sensai.util import logging from serena.constants import LOG_MESSAGES_BUFFER_SIZE, SERENA_LOG_FORMAT lg = logging @dataclass class LogMessages: messages: list[str] """ the list of log messages, ordered from oldest to newest """ max_idx: int """ the 0-based index of the last message in `messages` (in the full log history) """ class MemoryLogHandler(logging.Handler): def __init__(self, level: int = logging.NOTSET, max_messages: int | None = LOG_MESSAGES_BUFFER_SIZE) -> None: super().__init__(level=level) self.setFormatter(logging.Formatter(SERENA_LOG_FORMAT)) self._log_buffer = LogBuffer(max_messages=max_messages) self._log_queue: queue.Queue[str] = queue.Queue() self._stop_event = threading.Event() self._emit_callbacks: list[Callable[[str], None]] = [] # start background thread to process logs self.worker_thread = threading.Thread(target=self._process_queue, daemon=True) self.worker_thread.start() def add_emit_callback(self, callback: Callable[[str], None]) -> None: """ Adds a callback that will be called with each log message. The callback should accept a single string argument (the log message). """ self._emit_callbacks.append(callback) def emit(self, record: logging.LogRecord) -> None: msg = self.format(record) self._log_queue.put_nowait(msg) def _process_queue(self) -> None: while not self._stop_event.is_set(): try: msg = self._log_queue.get(timeout=1) self._log_buffer.append(msg) for callback in self._emit_callbacks: try: callback(msg) except: pass self._log_queue.task_done() except queue.Empty: continue def get_log_messages(self, from_idx: int = 0) -> LogMessages: return self._log_buffer.get_log_messages(from_idx=from_idx) def clear_log_messages(self) -> None: self._log_buffer.clear() class LogBuffer: """ A thread-safe buffer for storing (an optionally limited number of) log messages. """ def __init__(self, max_messages: int | None = None) -> None: self._max_messages = max_messages self._log_messages: list[str] = [] self._lock = threading.Lock() self._max_idx = -1 """ the 0-based index of the most recently added log message """ def append(self, msg: str) -> None: with self._lock: self._log_messages.append(msg) self._max_idx += 1 if self._max_messages is not None and len(self._log_messages) > self._max_messages: excess = len(self._log_messages) - self._max_messages self._log_messages = self._log_messages[excess:] def clear(self) -> None: with self._lock: self._log_messages = [] self._max_idx = -1 def get_log_messages(self, from_idx: int = 0) -> LogMessages: """ :param from_idx: the 0-based index of the first log message to return. If from_idx is less than or equal to the index of the oldest message in the buffer, then all messages in the buffer will be returned. :return: the list of messages """ from_idx = max(from_idx, 0) with self._lock: first_stored_idx = self._max_idx - len(self._log_messages) + 1 if from_idx <= first_stored_idx: messages = self._log_messages.copy() else: start_idx = from_idx - first_stored_idx messages = self._log_messages[start_idx:].copy() return LogMessages(messages=messages, max_idx=self._max_idx) class SuspendedLoggersContext: """A context manager that provides an isolated logging environment. Temporarily removes all root log handlers upon entry, providing a clean slate for defining new log handlers within the context. Upon exit, restores the original logging configuration. This is useful when you need to temporarily configure an isolated logging setup with well-defined log handlers. The context manager: - Removes all existing (root) log handlers on entry - Allows defining new temporary handlers within the context - Restores the original configuration (handlers and root log level) on exit Example: >>> with SuspendedLoggersContext(): ... # No handlers are active here (configure your own and set desired log level) ... pass >>> # Original log handlers are restored here """ def __init__(self) -> None: self.saved_root_handlers: list = [] self.saved_root_level: Optional[int] = None def __enter__(self) -> "SuspendedLoggersContext": root_logger = lg.getLogger() self.saved_root_handlers = root_logger.handlers.copy() self.saved_root_level = root_logger.level root_logger.handlers.clear() return self def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore root_logger = lg.getLogger() root_logger.handlers = self.saved_root_handlers if self.saved_root_level is not None: root_logger.setLevel(self.saved_root_level) ================================================ FILE: src/serena/util/shell.py ================================================ import os import subprocess from pydantic import BaseModel from solidlsp.util.subprocess_util import subprocess_kwargs class ShellCommandResult(BaseModel): stdout: str return_code: int cwd: str stderr: str | None = None def execute_shell_command(command: str, cwd: str | None = None, capture_stderr: bool = False) -> ShellCommandResult: """ Execute a shell command and return the output. :param command: The command to execute. :param cwd: The working directory to execute the command in. If None, the current working directory will be used. :param capture_stderr: Whether to capture the stderr output. :return: The output of the command. """ if cwd is None: cwd = os.getcwd() process = subprocess.Popen( command, shell=True, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE if capture_stderr else None, text=True, encoding="utf-8", errors="replace", cwd=cwd, **subprocess_kwargs(), ) stdout, stderr = process.communicate() return ShellCommandResult(stdout=stdout, stderr=stderr, return_code=process.returncode, cwd=cwd) def subprocess_check_output(args: list[str], encoding: str = "utf-8", strip: bool = True, timeout: float | None = None) -> str: output = subprocess.check_output(args, stdin=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=timeout, env=os.environ.copy(), **subprocess_kwargs()).decode(encoding) # type: ignore if strip: output = output.strip() return output ================================================ FILE: src/serena/util/text_utils.py ================================================ import fnmatch import logging import os import re from collections.abc import Callable from dataclasses import dataclass, field from enum import StrEnum from typing import Any, Literal, Self from bs4 import BeautifulSoup from joblib import Parallel, delayed from serena.constants import DEFAULT_SOURCE_FILE_ENCODING log = logging.getLogger(__name__) class LineType(StrEnum): """Enum for different types of lines in search results.""" MATCH = "match" """Part of the matched lines""" BEFORE_MATCH = "prefix" """Lines before the match""" AFTER_MATCH = "postfix" """Lines after the match""" @dataclass(kw_only=True) class TextLine: """Represents a line of text with information on how it relates to the match.""" line_number: int line_content: str match_type: LineType """Represents the type of line (match, prefix, postfix)""" def get_display_prefix(self) -> str: """Get the display prefix for this line based on the match type.""" if self.match_type == LineType.MATCH: return " >" return "..." def format_line(self, include_line_numbers: bool = True) -> str: """Format the line for display (e.g.,for logging or passing to an LLM). :param include_line_numbers: Whether to include the line number in the result. """ prefix = self.get_display_prefix() if include_line_numbers: line_num = str(self.line_number).rjust(4) prefix = f"{prefix}{line_num}" return f"{prefix}:{self.line_content}" @dataclass(kw_only=True) class MatchedConsecutiveLines: """Represents a collection of consecutive lines found through some criterion in a text file or a string. May include lines before, after, and matched. """ lines: list[TextLine] """All lines in the context of the match. At least one of them is of `match_type` `MATCH`.""" source_file_path: str | None = None """Path to the file where the match was found (Metadata).""" # set in post-init lines_before_matched: list[TextLine] = field(default_factory=list) matched_lines: list[TextLine] = field(default_factory=list) lines_after_matched: list[TextLine] = field(default_factory=list) def __post_init__(self) -> None: for line in self.lines: if line.match_type == LineType.BEFORE_MATCH: self.lines_before_matched.append(line) elif line.match_type == LineType.MATCH: self.matched_lines.append(line) elif line.match_type == LineType.AFTER_MATCH: self.lines_after_matched.append(line) assert len(self.matched_lines) > 0, "At least one matched line is required" @property def start_line(self) -> int: return self.lines[0].line_number @property def end_line(self) -> int: return self.lines[-1].line_number @property def num_matched_lines(self) -> int: return len(self.matched_lines) def to_display_string(self, include_line_numbers: bool = True) -> str: return "\n".join([line.format_line(include_line_numbers) for line in self.lines]) @classmethod def from_file_contents( cls, file_contents: str, line: int, context_lines_before: int = 0, context_lines_after: int = 0, source_file_path: str | None = None ) -> Self: line_contents = file_contents.split("\n") start_lineno = max(0, line - context_lines_before) end_lineno = min(len(line_contents) - 1, line + context_lines_after) text_lines: list[TextLine] = [] # before the line for lineno in range(start_lineno, line): text_lines.append(TextLine(line_number=lineno, line_content=line_contents[lineno], match_type=LineType.BEFORE_MATCH)) # the line text_lines.append(TextLine(line_number=line, line_content=line_contents[line], match_type=LineType.MATCH)) # after the line for lineno in range(line + 1, end_lineno + 1): text_lines.append(TextLine(line_number=lineno, line_content=line_contents[lineno], match_type=LineType.AFTER_MATCH)) return cls(lines=text_lines, source_file_path=source_file_path) def glob_to_regex(glob_pat: str) -> str: regex_parts: list[str] = [] i = 0 while i < len(glob_pat): ch = glob_pat[i] if ch == "*": regex_parts.append(".*") elif ch == "?": regex_parts.append("..") elif ch == "\\": i += 1 if i < len(glob_pat): regex_parts.append(re.escape(glob_pat[i])) else: regex_parts.append("\\") else: regex_parts.append(re.escape(ch)) i += 1 return "".join(regex_parts) def search_text( pattern: str, content: str | None = None, source_file_path: str | None = None, allow_multiline_match: bool = False, context_lines_before: int = 0, context_lines_after: int = 0, is_glob: bool = False, ) -> list[MatchedConsecutiveLines]: """ Search for a pattern in text content. Supports both regex and glob-like patterns. :param pattern: Pattern to search for (regex or glob-like pattern) :param content: The text content to search. May be None if source_file_path is provided. :param source_file_path: Optional path to the source file. If content is None, this has to be passed and the file will be read. :param allow_multiline_match: Whether to search across multiple lines. Currently, the default option (False) is very inefficient, so it is recommended to set this to True. :param context_lines_before: Number of context lines to include before matches :param context_lines_after: Number of context lines to include after matches :param is_glob: If True, pattern is treated as a glob-like pattern (e.g., "*.py", "test_??.py") and will be converted to regex internally :return: List of `TextSearchMatch` objects :raises: ValueError if the pattern is not valid """ if source_file_path and content is None: with open(source_file_path) as f: content = f.read() if content is None: raise ValueError("Pass either content or source_file_path") matches = [] lines = content.splitlines() total_lines = len(lines) # Convert pattern to a compiled regex if it's a string if is_glob: pattern = glob_to_regex(pattern) if allow_multiline_match: # For multiline matches, we need to use the DOTALL flag to make '.' match newlines compiled_pattern = re.compile(pattern, re.DOTALL) # Search across the entire content as a single string for match in compiled_pattern.finditer(content): start_pos = match.start() end_pos = match.end() # Find the line numbers for the start and end positions start_line_num = content[:start_pos].count("\n") + 1 end_line_num = content[:end_pos].count("\n") + 1 # Calculate the range of lines to include in the context context_start = max(1, start_line_num - context_lines_before) context_end = min(total_lines, end_line_num + context_lines_after) # Create TextLine objects for the context context_lines = [] for i in range(context_start - 1, context_end): line_num = i + 1 if context_start <= line_num < start_line_num: match_type = LineType.BEFORE_MATCH elif end_line_num < line_num <= context_end: match_type = LineType.AFTER_MATCH else: match_type = LineType.MATCH context_lines.append(TextLine(line_number=line_num, line_content=lines[i], match_type=match_type)) matches.append(MatchedConsecutiveLines(lines=context_lines, source_file_path=source_file_path)) else: # TODO: extremely inefficient! Since we currently don't use this option in SerenaAgent or LanguageServer, # it is not urgent to fix, but should be either improved or the option should be removed. # Search line by line, normal compile without DOTALL compiled_pattern = re.compile(pattern) for i, line in enumerate(lines): line_num = i + 1 if compiled_pattern.search(line): # Calculate the range of lines to include in the context context_start = max(0, i - context_lines_before) context_end = min(total_lines - 1, i + context_lines_after) # Create TextLine objects for the context context_lines = [] for j in range(context_start, context_end + 1): context_line_num = j + 1 if j < i: match_type = LineType.BEFORE_MATCH elif j > i: match_type = LineType.AFTER_MATCH else: match_type = LineType.MATCH context_lines.append(TextLine(line_number=context_line_num, line_content=lines[j], match_type=match_type)) matches.append(MatchedConsecutiveLines(lines=context_lines, source_file_path=source_file_path)) return matches def default_file_reader(file_path: str) -> str: """Reads using the default encoding.""" with open(file_path, encoding=DEFAULT_SOURCE_FILE_ENCODING) as f: return f.read() def expand_braces(pattern: str) -> list[str]: """ Expands brace patterns in a glob string. For example, "**/*.{js,jsx,ts,tsx}" becomes ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"]. Handles multiple brace sets as well. """ patterns = [pattern] while any("{" in p for p in patterns): new_patterns = [] for p in patterns: match = re.search(r"\{([^{}]+)\}", p) if match: prefix = p[: match.start()] suffix = p[match.end() :] options = match.group(1).split(",") for option in options: new_patterns.append(f"{prefix}{option}{suffix}") else: new_patterns.append(p) patterns = new_patterns return patterns def glob_match(pattern: str, path: str) -> bool: """ Match a file path against a glob pattern. Supports standard glob patterns: - * matches any number of characters except / - ** matches any number of directories (zero or more) - ? matches a single character except / - [seq] matches any character in seq Supports brace expansion: - {a,b,c} expands to multiple patterns (including nesting) Unsupported patterns: - Bash extended glob features are unavailable in Python's fnmatch - Extended globs like !(), ?(), +(), *(), @() are not supported :param pattern: Glob pattern (e.g., 'src/**/*.py', '**agent.py') :param path: File path to match against :return: True if path matches pattern """ pattern = pattern.replace("\\", "/") # Normalize backslashes to forward slashes path = path.replace("\\", "/") # Normalize path backslashes to forward slashes # Handle ** patterns that should match zero or more directories if "**" in pattern: # Method 1: Standard fnmatch (matches one or more directories) regex1 = fnmatch.translate(pattern) if re.match(regex1, path): return True # Method 2: Handle zero-directory case by removing /** entirely # Convert "src/**/test.py" to "src/test.py" if "/**/" in pattern: zero_dir_pattern = pattern.replace("/**/", "/") regex2 = fnmatch.translate(zero_dir_pattern) if re.match(regex2, path): return True # Method 3: Handle leading ** case by removing **/ # Convert "**/test.py" to "test.py" if pattern.startswith("**/"): zero_dir_pattern = pattern[3:] # Remove "**/" regex3 = fnmatch.translate(zero_dir_pattern) if re.match(regex3, path): return True return False else: # Simple pattern without **, use fnmatch directly return fnmatch.fnmatch(path, pattern) def search_files( relative_file_paths: list[str], pattern: str, root_path: str = "", file_reader: Callable[[str], str] = default_file_reader, context_lines_before: int = 0, context_lines_after: int = 0, paths_include_glob: str | None = None, paths_exclude_glob: str | None = None, ) -> list[MatchedConsecutiveLines]: """ Search for a pattern in a list of files. :param relative_file_paths: List of relative file paths in which to search :param pattern: Pattern to search for :param root_path: Root path to resolve relative paths against (by default, current working directory). :param file_reader: Function to read a file, by default will just use os.open. All files that can't be read by it will be skipped. :param context_lines_before: Number of context lines to include before matches :param context_lines_after: Number of context lines to include after matches :param paths_include_glob: Optional glob pattern to include files from the list :param paths_exclude_glob: Optional glob pattern to exclude files from the list :return: List of MatchedConsecutiveLines objects """ # Pre-filter paths (done sequentially to avoid overhead) # Use proper glob matching instead of gitignore patterns include_patterns = expand_braces(paths_include_glob) if paths_include_glob else None exclude_patterns = expand_braces(paths_exclude_glob) if paths_exclude_glob else None filtered_paths = [] for path in relative_file_paths: if include_patterns: if not any(glob_match(p, path) for p in include_patterns): log.debug(f"Skipping {path}: does not match include pattern {paths_include_glob}") continue if exclude_patterns: if any(glob_match(p, path) for p in exclude_patterns): log.debug(f"Skipping {path}: matches exclude pattern {paths_exclude_glob}") continue filtered_paths.append(path) log.info(f"Processing {len(filtered_paths)} files.") def process_single_file(path: str) -> dict[str, Any]: """Process a single file - this function will be parallelized.""" try: abs_path = os.path.join(root_path, path) file_content = file_reader(abs_path) search_results = search_text( pattern, content=file_content, source_file_path=path, allow_multiline_match=True, context_lines_before=context_lines_before, context_lines_after=context_lines_after, ) if len(search_results) > 0: log.debug(f"Found {len(search_results)} matches in {path}") return {"path": path, "results": search_results, "error": None} except Exception as e: log.debug(f"Error processing {path}: {e}") return {"path": path, "results": [], "error": str(e)} # Execute in parallel using joblib results = Parallel( n_jobs=-1, backend="threading", )(delayed(process_single_file)(path) for path in filtered_paths) # Collect results and errors matches = [] skipped_file_error_tuples = [] for result in results: if result["error"]: skipped_file_error_tuples.append((result["path"], result["error"])) else: matches.extend(result["results"]) if skipped_file_error_tuples: log.debug(f"Failed to read {len(skipped_file_error_tuples)} files: {skipped_file_error_tuples}") log.info(f"Found {len(matches)} total matches across {len(filtered_paths)} files") return matches def render_html(html: str) -> str: """ Remove HTML tags and decode HTML entities from text while preserving the actual content. This keeps type information and structure but removes all formatting. :param html: HTML text to clean :return: Plain text without HTML tags and with decoded entities """ soup = BeautifulSoup(html, "html.parser") # join text with spaces to avoid concatenation of words text = soup.get_text(separator=" ", strip=True) # normalize non-breaking spaces text = text.replace("\xa0", " ") return text.strip() class ContentReplacer: """ This is an LLM-optimised content replacer, which elegantly circumvents escaping and which provides dual modes for maximum flexibility. """ def __init__(self, mode: Literal["literal", "regex"], allow_multiple_occurrences: bool): """ :param mode: the mode indicating whether to the needle in replacements corresponds to a regular expression (mode "regex") or to a literal string (mode "literal") :param allow_multiple_occurrences: whether it is allowed that the search expression matches multiple occurrences. If False, an error will be raised if more than one match is found. """ self.mode = mode self.allow_multiple_occurrences = allow_multiple_occurrences @staticmethod def _create_replacement_function(regex_pattern: str, repl_template: str, regex_flags: int) -> Callable[[re.Match], str]: """ Creates a replacement function that validates for ambiguity and handles backreferences. :param regex_pattern: The regex pattern being used for matching :param repl_template: The replacement template with $!1, $!2, etc. for backreferences :param regex_flags: The flags to use when searching (e.g., re.DOTALL | re.MULTILINE) :return: A function suitable for use with re.sub() or re.subn() """ def validate_and_replace(match: re.Match) -> str: matched_text = match.group(0) # For multi-line match, check if the same pattern matches again within the already-matched text, # rendering the match ambiguous. Typical pattern in the code: # # When matching # .*? # this will match the entire span above, while only the suffix may have been intended. # (See test case for a practical example.) # To detect this, we check if the same pattern matches again within the matched text, if "\n" in matched_text and re.search(regex_pattern, matched_text[1:], flags=regex_flags): raise ValueError( "Match is ambiguous: the search pattern matches multiple overlapping occurrences. " "Please revise the search pattern to be more specific to avoid ambiguity, " "e.g. by matching specific context after the match, or try using the literal mode." ) # Handle backreferences: replace $!1, $!2, etc. with actual matched groups def expand_backreference(m: re.Match) -> str: group_num = int(m.group(1)) group_value = match.group(group_num) return group_value if group_value is not None else m.group(0) result = re.sub(r"\$!(\d+)", expand_backreference, repl_template) return result return validate_and_replace def replace( self, content: str, needle: str, repl: str, ) -> str: """ Performs the replacement. Raises ValueError if no match is found, or if multiple matches are found while allow_multiple_occurrences is False. :param content: the content in which to perform the replacement :param needle: the search expression, which is either a literal string or a regular expression, depending on the mode :param repl: the replacement string, which, in regex mode, may contain backreferences in the form of $!1, $!2, etc. to refer to matched groups in the search expression :return: the updated content after performing the replacement """ if self.mode == "literal": regex = re.escape(needle) elif self.mode == "regex": regex = needle else: raise ValueError(f"Invalid mode: '{self.mode}', expected 'literal' or 'regex'.") regex_flags = re.DOTALL | re.MULTILINE # create replacement function with validation and backreference handling repl_fn = self._create_replacement_function(regex, repl, regex_flags=regex_flags) # perform replacement updated_content, n = re.subn(regex, repl_fn, content, flags=regex_flags) if n == 0: raise ValueError("Error: No matches of search expression found.") if not self.allow_multiple_occurrences and n > 1: raise ValueError( f"Expression matches {n} occurrences. " "Please revise the expression to be more specific or enable allow_multiple_occurrences if this is expected." ) return updated_content ================================================ FILE: src/serena/util/thread.py ================================================ import threading from collections.abc import Callable from enum import Enum from typing import Generic, TypeVar from sensai.util.string import ToStringMixin class TimeoutException(Exception): def __init__(self, message: str, timeout: float) -> None: super().__init__(message) self.timeout = timeout T = TypeVar("T") class ExecutionResult(Generic[T], ToStringMixin): class Status(Enum): SUCCESS = "success" TIMEOUT = "timeout" EXCEPTION = "error" def __init__(self) -> None: self.result_value: T | None = None self.status: ExecutionResult.Status | None = None self.exception: Exception | None = None def set_result_value(self, value: T) -> None: self.result_value = value self.status = ExecutionResult.Status.SUCCESS def set_timed_out(self, exception: TimeoutException) -> None: self.exception = exception self.status = ExecutionResult.Status.TIMEOUT def set_exception(self, exception: Exception) -> None: self.exception = exception self.status = ExecutionResult.Status.EXCEPTION def execute_with_timeout(func: Callable[[], T], timeout: float, function_name: str) -> ExecutionResult[T]: """ Executes the given function with a timeout :param func: the function to execute :param timeout: the timeout in seconds :param function_name: the name of the function (for error messages) :returns: the execution result """ execution_result: ExecutionResult[T] = ExecutionResult() def target() -> None: try: value = func() execution_result.set_result_value(value) except Exception as e: execution_result.set_exception(e) thread = threading.Thread(target=target, daemon=True) thread.start() thread.join(timeout=timeout) if thread.is_alive(): timeout_exception = TimeoutException(f"Execution of '{function_name}' timed out after {timeout} seconds.", timeout) execution_result.set_timed_out(timeout_exception) return execution_result ================================================ FILE: src/serena/util/version.py ================================================ class Version: """ Represents a version, specifically the numeric components of a version string. Suffixes like "rc1" or "-dev" are ignored, i.e. for a version string like "1.2.3rc1", the components are [1, 2, 3]. """ def __init__(self, package_or_version: object | str): """ :param package_or_version: a package object (with a `__version__` attribute) or a version string like "1.2.3". If a version contains a suffix (like "1.2.3rc1" or "1.2.3-dev"), the suffix is ignored. """ if isinstance(package_or_version, str): version_string = package_or_version elif hasattr(package_or_version, "__version__"): package_version_string = getattr(package_or_version, "__version__", None) if package_version_string is None: raise ValueError(f"The given package object {package_or_version} has no __version__ attribute") version_string = package_version_string else: raise ValueError("The given argument must be either a version string or a package object with a __version__ attribute") self.version_string = version_string self.components = self._get_version_components(version_string) def __repr__(self) -> str: return self.version_string @staticmethod def _get_version_components(version_string: str) -> list[int]: components = version_string.split(".") int_components = [] for c in components: num_str = "" for ch in c: if ch.isdigit(): num_str += ch else: break if num_str == "": break int_components.append(int(num_str)) return int_components def is_at_least(self, *components: int) -> bool: """ Checks this version against the given version components. This version object must contain at least the respective number of components :param components: version components in order (i.e. major, minor, patch, etc.) :return: True if the version is at least the given version, False otherwise """ for i, desired_min_version in enumerate(components): actual_version = self.components[i] if actual_version < desired_min_version: return False elif actual_version > desired_min_version: return True return True def is_at_most(self, *components: int) -> bool: """ Checks this version against the given version components. This version object must contain at least the respective number of components :param components: version components in order (i.e. major, minor, patch, etc.) :return: True if the version is at most the given version, False otherwise """ for i, desired_max_version in enumerate(components): actual_version = self.components[i] if actual_version > desired_max_version: return False elif actual_version < desired_max_version: return True return True def is_equal(self, *components: int) -> bool: """ Checks this version against the given version components. This version object must contain at least the respective number of components :param components: version components in order (i.e. major, minor, patch, etc.) :return: True if the version is the given version, False otherwise """ return self.components[: len(components)] == list(components) ================================================ FILE: src/serena/util/yaml.py ================================================ import logging import os from collections.abc import Sequence from enum import Enum from typing import Any from ruamel.yaml import YAML, CommentToken, StreamMark from ruamel.yaml.comments import CommentedMap from serena.constants import SERENA_FILE_ENCODING log = logging.getLogger(__name__) def _create_yaml(preserve_comments: bool = False) -> YAML: """ Creates a YAML that can load/save with comments if preserve_comments is True. """ typ = None if preserve_comments else "safe" result = YAML(typ=typ) result.preserve_quotes = preserve_comments return result class YamlCommentNormalisation(Enum): """ Defines a normalisation to be applied to the comment representation in a ruamel CommentedMap. Note that even though a YAML document may seem to consistently contain, for example, leading comments before a key only, ruamel may still parse some comments as trailing comments of the previous key or as document-level comments. The normalisations define ways to adjust the comment representation accordingly, clearly associating comments with the keys they belong to. """ NONE = "none" """ No comment normalisation is performed. Comments are kept as parsed by ruamel.yaml. """ LEADING = "leading" """ Document is assumed to have leading comments only, i.e. comments before keys, only full-line comments. This normalisation achieves that comments are properly associated with keys as leading comments. """ LEADING_WITH_CONVERSION_FROM_TRAILING = "leading_with_conversion_from_trailing" """ Document is assumed to have a mixture of leading comments (before keys) and trailing comments (after values), only full-line comments. This normalisation achieves that all comments are converted to leading comments and properly associated with keys. """ # NOTE: Normalisation for trailing comments was attempted but is extremely hard, because # it is difficult to position the comments properly after values, especially for complex values. DOC_COMMENT_INDEX_POST = 0 DOC_COMMENT_INDEX_PRE = 1 # item comment indices: (post key, pre key, post value, pre value) ITEM_COMMENT_INDEX_BEFORE = 1 # (pre-key; must be a list of CommentToken at this index) ITEM_COMMENT_INDEX_AFTER = 2 # (post-value; must be an instance of CommentToken at this index) def load_yaml(path: str, comment_normalisation: YamlCommentNormalisation = YamlCommentNormalisation.NONE) -> CommentedMap: """ :param path: the path to the YAML file to load :param comment_normalisation: the comment normalisation to apply after loading :return: the loaded commented map """ with open(path, encoding=SERENA_FILE_ENCODING) as f: yaml = _create_yaml(preserve_comments=True) commented_map: CommentedMap | None = yaml.load(f) if commented_map is None: # ruamel returns None for empty documents, but we want an empty CommentedMap commented_map = CommentedMap() normalise_yaml_comments(commented_map, comment_normalisation) return commented_map def normalise_yaml_comments(commented_map: CommentedMap, comment_normalisation: YamlCommentNormalisation) -> None: """ Applies the given comment normalisation to the given commented map in-place. :param commented_map: the commented map whose comments are to be normalised :param comment_normalisation: the comment normalisation to apply """ def make_list(comment_entry: Any) -> list: if not isinstance(comment_entry, list): return [comment_entry] return comment_entry def make_unit(comment_entry: Any) -> Any: """ Converts a list-valued comment entry into a single comment entry. """ if isinstance(comment_entry, list): if len(comment_entry) == 0: return None elif len(comment_entry) == 1: return comment_entry[0] else: if all(isinstance(item, CommentToken) for item in comment_entry): start_mark = StreamMark(name="", index=0, line=0, column=0) comment_str = "".join(item.value for item in comment_entry) if not comment_str.startswith("\n"): comment_str = "\n" + comment_str return CommentToken(value=comment_str, start_mark=start_mark, end_mark=None) else: types = set(type(item) for item in comment_entry) log.warning("Unhandled types in list-valued comment entry: %s; not updating entry", types) return None else: return comment_entry def trailing_to_leading(comment_entry: Any) -> Any: if comment_entry is None: return None token_list = make_list(comment_entry) first_token = token_list[0] if isinstance(first_token, CommentToken): # remove leading newline if present if first_token.value.startswith("\n"): first_token.value = first_token.value[1:] return token_list match comment_normalisation: case YamlCommentNormalisation.NONE: pass case YamlCommentNormalisation.LEADING | YamlCommentNormalisation.LEADING_WITH_CONVERSION_FROM_TRAILING: # Comments are supposed to be leading comments (i.e., before a key and associated with the key). # When ruamel parses a YAML, however, comments belonging to a key may be stored as trailing # comments of the previous key or as a document-level comment. # Move them accordingly. keys = list(commented_map.keys()) comment_items = commented_map.ca.items doc_comment = commented_map.ca.comment preceding_comment = None for i, key in enumerate(keys): current_comment = comment_items.get(key, [None] * 4) comment_items[key] = current_comment if current_comment[ITEM_COMMENT_INDEX_BEFORE] is None: if i == 0 and doc_comment is not None and doc_comment[DOC_COMMENT_INDEX_PRE] is not None: # move document pre-comment to leading comment of first key current_comment[ITEM_COMMENT_INDEX_BEFORE] = make_list(doc_comment[DOC_COMMENT_INDEX_PRE]) doc_comment[DOC_COMMENT_INDEX_PRE] = None elif preceding_comment is not None and preceding_comment[ITEM_COMMENT_INDEX_AFTER] is not None: # move trailing comment of preceding key to leading comment of current key current_comment[ITEM_COMMENT_INDEX_BEFORE] = trailing_to_leading(preceding_comment[ITEM_COMMENT_INDEX_AFTER]) preceding_comment[ITEM_COMMENT_INDEX_AFTER] = None preceding_comment = current_comment if comment_normalisation == YamlCommentNormalisation.LEADING_WITH_CONVERSION_FROM_TRAILING: # Second pass: conversion of trailing comments # If a leading comment ends with "\n\n", i.e. it has an empty line between the comment and the key, # it was actually intended as a trailing comment for the preceding key, so we associate it with # the preceding key instead (if the preceding key has no leading comment already). preceding_comment = None for key in keys: current_comment = comment_items.get(key, [None] * 4) if current_comment[ITEM_COMMENT_INDEX_BEFORE] is not None: token_list = make_list(current_comment[ITEM_COMMENT_INDEX_BEFORE]) if len(token_list) > 0: last_token = token_list[-1] if isinstance(last_token, CommentToken) and last_token.value.endswith("\n\n"): # move comment to preceding key, removing the empty line, # and adding an empty line at the beginning instead if preceding_comment is not None and yaml_comment_entry_is_empty( preceding_comment[ITEM_COMMENT_INDEX_BEFORE] ): last_token.value = last_token.value[:-1] first_token = token_list[0] if isinstance(first_token, CommentToken): if not first_token.value.startswith("\n"): first_token.value = "\n" + first_token.value preceding_comment[ITEM_COMMENT_INDEX_BEFORE] = token_list current_comment[ITEM_COMMENT_INDEX_BEFORE] = None preceding_comment = current_comment case _: raise ValueError(f"Unhandled comment normalisation: {comment_normalisation}") def save_yaml(path: str, data: dict | CommentedMap, preserve_comments: bool = True) -> None: yaml = _create_yaml(preserve_comments) os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", encoding=SERENA_FILE_ENCODING) as f: yaml.dump(data, f) def yaml_comment_entry_is_empty(comment_entry: Any) -> bool: if comment_entry is None: return True elif isinstance(comment_entry, list): for item in comment_entry: if isinstance(item, CommentToken): if item.value.strip() != "": return False else: return False return True elif isinstance(comment_entry, CommentToken): return comment_entry.value.strip() == "" else: return False def transfer_missing_yaml_comments_by_index( source: CommentedMap, target: CommentedMap, indices: list[int], forced_update_keys: Sequence[str] = () ) -> None: """ :param source: the source, from which to transfer missing comments :param target: the target map, whose comments will be updated :param indices: list of comment indices to transfer :param forced_update_keys: keys for which comments are always transferred, even if present in target """ for key in target.keys(): if key in source: source_comment = source.ca.items.get(key) if source_comment is None: continue target_comment = target.ca.items.get(key) # initialise target comment if needed if target_comment is None: target_comment = [None] * 4 target.ca.items[key] = target_comment # transfer comments at specified indices for index in indices: is_forced_update = key in forced_update_keys if is_forced_update or yaml_comment_entry_is_empty(target_comment[index]): target_comment[index] = source_comment[index] def transfer_missing_yaml_comments( source: CommentedMap, target: CommentedMap, comment_normalisation: YamlCommentNormalisation, forced_update_keys: Sequence[str] = () ) -> None: """ Transfers missing comments from source to target YAML. :param source: the source, from which to transfer missing comments :param target: the target map, whose comments will be updated. :param comment_normalisation: the comment normalisation to assume; if NONE, no comments are transferred :param forced_update_keys: keys for which comments are always transferred, even if present in target """ match comment_normalisation: case YamlCommentNormalisation.NONE: pass case YamlCommentNormalisation.LEADING | YamlCommentNormalisation.LEADING_WITH_CONVERSION_FROM_TRAILING: transfer_missing_yaml_comments_by_index(source, target, [ITEM_COMMENT_INDEX_BEFORE], forced_update_keys=forced_update_keys) case _: raise ValueError(f"Unhandled comment normalisation: {comment_normalisation}") ================================================ FILE: src/solidlsp/.gitignore ================================================ language_servers/static ================================================ FILE: src/solidlsp/__init__.py ================================================ # ruff: noqa from .ls import SolidLanguageServer ================================================ FILE: src/solidlsp/language_servers/al_language_server.py ================================================ """AL Language Server implementation for Microsoft Dynamics 365 Business Central.""" import logging import os import pathlib import platform import re import stat import time import zipfile from pathlib import Path import requests from overrides import override from solidlsp import ls_types from solidlsp.language_servers.common import quote_windows_path from solidlsp.ls import DocumentSymbols, LSPFileBuffer, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_types import SymbolKind, UnifiedSymbolInformation from solidlsp.lsp_protocol_handler.lsp_types import Definition, DefinitionParams, LocationLink from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) class ALLanguageServer(SolidLanguageServer): """ Language server implementation for AL (Microsoft Dynamics 365 Business Central). This implementation uses the AL Language Server from the VS Code AL extension (ms-dynamics-smb.al). The extension must be installed or available locally. Key Features: - Automatic download of AL extension from VS Code marketplace if not present - Platform-specific executable detection (Windows/Linux/macOS) - Special initialization sequence required by AL Language Server - Custom AL-specific LSP commands (al/gotodefinition, al/setActiveWorkspace) - File opening requirement before symbol retrieval """ # Regex pattern to match AL object names like: # - 'Table 50000 "TEST Customer"' -> captures 'TEST Customer' # - 'Codeunit 50000 CustomerMgt' -> captures 'CustomerMgt' # - 'Interface IPaymentProcessor' -> captures 'IPaymentProcessor' # - 'Enum 50000 CustomerType' -> captures 'CustomerType' # Pattern: [] (|) _AL_OBJECT_NAME_PATTERN = re.compile( r"^(?:Table|Page|Codeunit|Enum|Interface|Report|Query|XMLPort|PermissionSet|" r"PermissionSetExtension|Profile|PageExtension|TableExtension|EnumExtension|" r"PageCustomization|ReportExtension|ControlAddin|DotNetPackage)" # Object type r"(?:\s+\d+)?" # Optional object ID r"\s+" # Required space before name r'(?:"([^"]+)"|(\S+))$' # Quoted name (group 1) or unquoted identifier (group 2) ) @staticmethod def _extract_al_display_name(full_name: str) -> str: """ Extract the display name from an AL symbol's full name. AL Language Server returns symbol names in format: - 'Table 50000 "TEST Customer"' -> 'TEST Customer' - 'Codeunit 50000 CustomerMgt' -> 'CustomerMgt' - 'Interface IPaymentProcessor' -> 'IPaymentProcessor' - 'fields' -> 'fields' (non-AL-object symbols pass through unchanged) Args: full_name: The full symbol name as returned by AL Language Server Returns: The extracted display name for matching, or the original name if not an AL object """ match = ALLanguageServer._AL_OBJECT_NAME_PATTERN.match(full_name) if match: # Return quoted name (group 1) or unquoted name (group 2) return match.group(1) or match.group(2) or full_name return full_name def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Initialize the AL Language Server. Args: config: Language server configuration logger: Logger instance for debugging repository_root_path: Root path of the AL project (must contain app.json) solidlsp_settings: Solid LSP settings Note: The initialization process will automatically: 1. Check for AL extension in the resources directory 2. Download it from VS Code marketplace if not found 3. Extract and configure the platform-specific executable """ # Setup runtime dependencies and get the language server command # This will download the AL extension if needed cmd = self._setup_runtime_dependencies(config, solidlsp_settings) self._project_load_check_supported: bool = True """Whether the AL server supports the project load status check request. Some AL server versions don't support the 'al/hasProjectClosureLoadedRequest' custom LSP request. This flag starts as True and is set to False if the request fails, preventing repeated unsuccessful attempts. """ super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd=cmd, cwd=repository_root_path), "al", solidlsp_settings) # Cache mapping (file_path, line, char) -> original_full_name for hover injection self._al_original_names: dict[tuple[str, int, int], str] = {} @staticmethod def _normalize_path(path: str) -> str: """Normalize file path for consistent cache key usage across platforms.""" return path.replace("\\", "/") @classmethod def _download_al_extension(cls, url: str, target_dir: str) -> bool: """ Download and extract the AL extension from VS Code marketplace. The VS Code marketplace packages extensions as .vsix files (which are ZIP archives). This method downloads the VSIX file and extracts it to get the language server binaries. Args: logger: Logger for tracking download progress url: VS Code marketplace URL for the AL extension target_dir: Directory where the extension will be extracted Returns: True if successful, False otherwise Note: The download includes progress tracking and proper user-agent headers to ensure compatibility with the VS Code marketplace. """ try: log.info(f"Downloading AL extension from {url}") # Create target directory for the extension os.makedirs(target_dir, exist_ok=True) # Download with proper headers to mimic VS Code marketplace client # These headers are required for the marketplace to serve the VSIX file headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "application/octet-stream, application/vsix, */*", } response = requests.get(url, headers=headers, stream=True, timeout=300) response.raise_for_status() # Save to temporary VSIX file (will be deleted after extraction) temp_file = os.path.join(target_dir, "al_extension_temp.vsix") total_size = int(response.headers.get("content-length", 0)) log.info(f"Downloading {total_size / 1024 / 1024:.1f} MB...") with open(temp_file, "wb") as f: downloaded = 0 for chunk in response.iter_content(chunk_size=8192): if chunk: f.write(chunk) downloaded += len(chunk) if total_size > 0 and downloaded % (10 * 1024 * 1024) == 0: # Log progress every 10MB progress = (downloaded / total_size) * 100 log.info(f"Download progress: {progress:.1f}%") log.info("Download complete, extracting...") # Extract VSIX file (VSIX files are just ZIP archives with a different extension) # This will extract the extension folder containing the language server binaries with zipfile.ZipFile(temp_file, "r") as zip_ref: zip_ref.extractall(target_dir) # Clean up temp file os.remove(temp_file) log.info("AL extension extracted successfully") return True except Exception as e: log.error(f"Error downloading/extracting AL extension: {e}") return False @classmethod def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> str: """ Setup runtime dependencies for AL Language Server and return the command to start the server. This method handles the complete setup process: 1. Checks for existing AL extension installations 2. Downloads from VS Code marketplace if not found 3. Configures executable permissions on Unix systems 4. Returns the properly formatted command string The AL Language Server executable is located in different paths based on the platform: - Windows: bin/win32/Microsoft.Dynamics.Nav.EditorServices.Host.exe - Linux: bin/linux/Microsoft.Dynamics.Nav.EditorServices.Host - macOS: bin/darwin/Microsoft.Dynamics.Nav.EditorServices.Host """ system = platform.system() # Find existing extension or download if needed extension_path = cls._find_al_extension(solidlsp_settings) if extension_path is None: log.info("AL extension not found on disk, attempting to download...") extension_path = cls._download_and_install_al_extension(solidlsp_settings) if extension_path is None: raise RuntimeError( "Failed to locate or download AL Language Server. Please either:\n" "1. Set AL_EXTENSION_PATH environment variable to the AL extension directory\n" "2. Install the AL extension in VS Code (ms-dynamics-smb.al)\n" "3. Ensure internet connection for automatic download" ) # Build executable path based on platform executable_path = cls._get_executable_path(extension_path, system) if not os.path.exists(executable_path): raise RuntimeError(f"AL Language Server executable not found at: {executable_path}") # Prepare and return the executable command return cls._prepare_executable(executable_path, system) @classmethod def _find_al_extension(cls, solidlsp_settings: SolidLSPSettings) -> str | None: """ Find AL extension in various locations. Search order: 1. Environment variable (AL_EXTENSION_PATH) 2. Default download location (~/.serena/ls_resources/al-extension) 3. VS Code installed extensions Returns: Path to AL extension directory or None if not found """ # Check environment variable env_path = os.environ.get("AL_EXTENSION_PATH") if env_path and os.path.exists(env_path): log.debug(f"Found AL extension via AL_EXTENSION_PATH: {env_path}") return env_path elif env_path: log.warning(f"AL_EXTENSION_PATH set but directory not found: {env_path}") # Check default download location default_path = os.path.join(cls.ls_resources_dir(solidlsp_settings), "al-extension", "extension") if os.path.exists(default_path): log.debug(f"Found AL extension in default location: {default_path}") return default_path # Search VS Code extensions vscode_path = cls._find_al_extension_in_vscode() if vscode_path: log.debug(f"Found AL extension in VS Code: {vscode_path}") return vscode_path log.debug("AL extension not found in any known location") return None @classmethod def _download_and_install_al_extension(cls, solidlsp_settings: SolidLSPSettings) -> str | None: """ Download and install AL extension from VS Code marketplace. Returns: Path to installed extension or None if download failed """ al_extension_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "al-extension") # AL extension version - using latest stable version AL_VERSION = "latest" url = f"https://marketplace.visualstudio.com/_apis/public/gallery/publishers/ms-dynamics-smb/vsextensions/al/{AL_VERSION}/vspackage" log.info(f"Downloading AL extension from: {url}") if cls._download_al_extension(url, al_extension_dir): extension_path = os.path.join(al_extension_dir, "extension") if os.path.exists(extension_path): log.info("AL extension downloaded and installed successfully") return extension_path else: log.error(f"Download completed but extension not found at: {extension_path}") else: log.error("Failed to download AL extension from marketplace") return None @classmethod def _get_executable_path(cls, extension_path: str, system: str) -> str: """ Build platform-specific executable path. Args: extension_path: Path to AL extension directory system: Operating system name Returns: Full path to executable """ if system == "Windows": return os.path.join(extension_path, "bin", "win32", "Microsoft.Dynamics.Nav.EditorServices.Host.exe") elif system == "Linux": return os.path.join(extension_path, "bin", "linux", "Microsoft.Dynamics.Nav.EditorServices.Host") elif system == "Darwin": return os.path.join(extension_path, "bin", "darwin", "Microsoft.Dynamics.Nav.EditorServices.Host") else: raise RuntimeError(f"Unsupported platform: {system}") @classmethod def _prepare_executable(cls, executable_path: str, system: str) -> str: """ Prepare the executable by setting permissions and handling path quoting. Args: executable_path: Path to the executable system: Operating system name logger: Logger instance Returns: Properly formatted command string """ # Make sure executable has proper permissions on Unix-like systems if system in ["Linux", "Darwin"]: st = os.stat(executable_path) os.chmod(executable_path, st.st_mode | stat.S_IEXEC) log.debug(f"Set execute permission on: {executable_path}") log.info(f"Using AL Language Server executable: {executable_path}") # The AL Language Server uses stdio for LSP communication by default # Use the utility function to handle Windows path quoting return quote_windows_path(executable_path) @classmethod def _get_language_server_command_fallback(cls) -> str: """ Get the command to start the AL language server. Returns: Command string to launch the AL language server Raises: RuntimeError: If AL extension cannot be found """ # Check if AL extension path is configured via environment variable al_extension_path = os.environ.get("AL_EXTENSION_PATH") if not al_extension_path: # Try to find the extension in the current working directory # (for development/testing when extension is in the serena repo) cwd_path = Path.cwd() potential_extension = None # Look for ms-dynamics-smb.al-* directories for item in cwd_path.iterdir(): if item.is_dir() and item.name.startswith("ms-dynamics-smb.al-"): potential_extension = item break if potential_extension: al_extension_path = str(potential_extension) log.debug(f"Found AL extension in current directory: {al_extension_path}") else: # Try to find in common VS Code extension locations al_extension_path = cls._find_al_extension_in_vscode() if not al_extension_path: raise RuntimeError( "AL Language Server not found. Please either:\n" "1. Set AL_EXTENSION_PATH environment variable to the VS Code AL extension directory\n" "2. Install the AL extension in VS Code (ms-dynamics-smb.al)\n" "3. Place the extension directory in the current working directory" ) # Determine platform-specific executable system = platform.system() if system == "Windows": executable = os.path.join(al_extension_path, "bin", "win32", "Microsoft.Dynamics.Nav.EditorServices.Host.exe") elif system == "Linux": executable = os.path.join(al_extension_path, "bin", "linux", "Microsoft.Dynamics.Nav.EditorServices.Host") elif system == "Darwin": executable = os.path.join(al_extension_path, "bin", "darwin", "Microsoft.Dynamics.Nav.EditorServices.Host") else: raise RuntimeError(f"Unsupported platform: {system}") # Verify executable exists if not os.path.exists(executable): raise RuntimeError( f"AL Language Server executable not found at: {executable}\nPlease ensure the AL extension is properly installed." ) # Make sure executable has proper permissions on Unix-like systems if system in ["Linux", "Darwin"]: st = os.stat(executable) os.chmod(executable, st.st_mode | stat.S_IEXEC) log.info(f"Using AL Language Server executable: {executable}") # The AL Language Server uses stdio for LSP communication (no --stdio flag needed) # Use the utility function to handle Windows path quoting return quote_windows_path(executable) @classmethod def _find_al_extension_in_vscode(cls) -> str | None: """ Try to find AL extension in common VS Code extension locations. Returns: Path to AL extension directory or None if not found """ home = Path.home() possible_paths = [] # Common VS Code extension paths if platform.system() == "Windows": possible_paths.extend( [ home / ".vscode" / "extensions", home / ".vscode-insiders" / "extensions", Path(os.environ.get("APPDATA", "")) / "Code" / "User" / "extensions", Path(os.environ.get("APPDATA", "")) / "Code - Insiders" / "User" / "extensions", ] ) else: possible_paths.extend( [ home / ".vscode" / "extensions", home / ".vscode-server" / "extensions", home / ".vscode-insiders" / "extensions", ] ) for base_path in possible_paths: if base_path.exists(): log.debug(f"Searching for AL extension in: {base_path}") # Look for AL extension directories for item in base_path.iterdir(): if item.is_dir() and item.name.startswith("ms-dynamics-smb.al-"): log.debug(f"Found AL extension at: {item}") return str(item) return None @staticmethod def _get_initialize_params(repository_absolute_path: str) -> dict: """ Returns the initialize params for the AL Language Server. """ # Ensure we have an absolute path for URI generation repository_path = pathlib.Path(repository_absolute_path).resolve() root_uri = repository_path.as_uri() # AL requires extensive capabilities based on VS Code trace initialize_params = { "processId": os.getpid(), "rootPath": str(repository_path), "rootUri": root_uri, "capabilities": { "workspace": { "applyEdit": True, "workspaceEdit": { "documentChanges": True, "resourceOperations": ["create", "rename", "delete"], "failureHandling": "textOnlyTransactional", "normalizesLineEndings": True, }, "configuration": True, "didChangeWatchedFiles": {"dynamicRegistration": True}, "symbol": {"dynamicRegistration": True, "symbolKind": {"valueSet": list(range(1, 27))}}, "executeCommand": {"dynamicRegistration": True}, "didChangeConfiguration": {"dynamicRegistration": True}, "workspaceFolders": True, }, "textDocument": { "synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True}, "completion": { "dynamicRegistration": True, "contextSupport": True, "completionItem": { "snippetSupport": True, "commitCharactersSupport": True, "documentationFormat": ["markdown", "plaintext"], "deprecatedSupport": True, "preselectSupport": True, }, }, "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, "definition": {"dynamicRegistration": True, "linkSupport": True}, "references": {"dynamicRegistration": True}, "documentHighlight": {"dynamicRegistration": True}, "documentSymbol": { "dynamicRegistration": True, "symbolKind": {"valueSet": list(range(1, 27))}, "hierarchicalDocumentSymbolSupport": True, }, "codeAction": {"dynamicRegistration": True}, "formatting": {"dynamicRegistration": True}, "rangeFormatting": {"dynamicRegistration": True}, "rename": {"dynamicRegistration": True, "prepareSupport": True}, }, "window": { "showMessage": {"messageActionItem": {"additionalPropertiesSupport": True}}, "showDocument": {"support": True}, "workDoneProgress": True, }, }, "trace": "verbose", "workspaceFolders": [{"uri": root_uri, "name": repository_path.name}], } return initialize_params @override def _start_server(self) -> None: """ Starts the AL Language Server process and initializes it. This method sets up custom notification handlers for AL-specific messages before starting the server. The AL server sends various notifications during initialization and project loading that need to be handled. """ # Set up event handlers def do_nothing(params: str) -> None: return def window_log_message(msg: dict) -> None: log.info(f"AL LSP: window/logMessage: {msg}") def publish_diagnostics(params: dict) -> None: # AL server publishes diagnostics during initialization uri = params.get("uri", "") diagnostics = params.get("diagnostics", []) log.debug(f"AL LSP: Diagnostics for {uri}: {len(diagnostics)} issues") def handle_al_notifications(params: dict) -> None: # AL server sends custom notifications during project loading log.debug("AL LSP: Notification received") # Register handlers for AL-specific notifications # These notifications are sent by the AL server during initialization and operation self.server.on_notification("window/logMessage", window_log_message) # Server log messages self.server.on_notification("textDocument/publishDiagnostics", publish_diagnostics) # Compilation diagnostics self.server.on_notification("$/progress", do_nothing) # Progress notifications during loading self.server.on_notification("al/refreshExplorerObjects", handle_al_notifications) # AL-specific object updates # Start the server process log.info("Starting AL Language Server process") self.server.start() # Send initialize request initialize_params = self._get_initialize_params(self.repository_root_path) log.info("Sending initialize request from LSP client to AL LSP server and awaiting response") # Send initialize and wait for response resp = self.server.send_request("initialize", initialize_params) if resp is None: raise RuntimeError("AL Language Server initialization failed - no response") log.info("AL Language Server initialized successfully") # Send initialized notification self.server.send_notification("initialized", {}) log.info("Sent initialized notification") @override def start(self) -> "ALLanguageServer": """ Start the AL Language Server with special initialization. """ # Call parent start method super().start() # AL-specific post-initialization self._post_initialize_al_workspace() # Note: set_active_workspace() can be called manually if needed for multi-workspace scenarios # We don't call it automatically to avoid issues during single-workspace initialization return self def _post_initialize_al_workspace(self) -> None: """ Post-initialization setup for AL Language Server. The AL server requires additional setup after initialization: 1. Send workspace configuration - provides AL settings and paths 2. Open app.json to trigger project loading - AL uses app.json to identify project structure 3. Optionally wait for project to be loaded if supported This special initialization sequence is unique to AL and necessary for proper symbol resolution and navigation features. """ # No sleep needed - server is already initialized # Send workspace configuration first # This tells AL about assembly paths, package caches, and code analysis settings try: self.server.send_notification( "workspace/didChangeConfiguration", { "settings": { "workspacePath": self.repository_root_path, "alResourceConfigurationSettings": { "assemblyProbingPaths": ["./.netpackages"], "codeAnalyzers": [], "enableCodeAnalysis": False, "backgroundCodeAnalysis": "Project", "packageCachePaths": ["./.alpackages"], "ruleSetPath": None, "enableCodeActions": True, "incrementalBuild": False, "outputAnalyzerStatistics": True, "enableExternalRulesets": True, }, "setActiveWorkspace": True, "expectedProjectReferenceDefinitions": [], "activeWorkspaceClosure": [self.repository_root_path], } }, ) log.debug("Sent workspace configuration") except Exception as e: log.warning(f"Failed to send workspace config: {e}") # Check if app.json exists and open it # app.json is the AL project manifest file (similar to package.json for Node.js) # Opening it triggers AL to load the project and index all AL files app_json_path = Path(self.repository_root_path) / "app.json" if app_json_path.exists(): try: with open(app_json_path, encoding="utf-8") as f: app_json_content = f.read() # Use forward slashes for URI app_json_uri = app_json_path.as_uri() # Send textDocument/didOpen for app.json self.server.send_notification( "textDocument/didOpen", {"textDocument": {"uri": app_json_uri, "languageId": "json", "version": 1, "text": app_json_content}}, ) log.debug(f"Opened app.json: {app_json_uri}") except Exception as e: log.warning(f"Failed to open app.json: {e}") # Try to set active workspace (AL-specific custom LSP request) # This is optional and may not be supported by all AL server versions workspace_uri = Path(self.repository_root_path).resolve().as_uri() try: result = self.server.send_request( "al/setActiveWorkspace", { "currentWorkspaceFolderPath": {"uri": workspace_uri, "name": Path(self.repository_root_path).name, "index": 0}, "settings": { "workspacePath": self.repository_root_path, "setActiveWorkspace": True, }, "timeout": 2, # Quick timeout since this is optional }, ) log.debug(f"Set active workspace result: {result}") except Exception as e: # This is a custom AL request, not critical if it fails log.debug(f"Failed to set active workspace (non-critical): {e}") # Check if project supports load status check (optional) # Many AL server versions don't support this, so we use a short timeout # and continue regardless of the result self._wait_for_project_load(timeout=3) @override def is_ignored_dirname(self, dirname: str) -> bool: """ Define AL-specific directories to ignore during file scanning. These directories contain generated files, dependencies, or cache data that should not be analyzed for symbols. Args: dirname: Directory name to check Returns: True if directory should be ignored """ al_ignore_dirs = { ".alpackages", # AL package cache - downloaded dependencies ".alcache", # AL compiler cache - intermediate compilation files ".altemplates", # AL templates - code generation templates ".snapshots", # Test snapshots - test result snapshots "out", # Compiled output - generated .app files ".vscode", # VS Code settings - editor configuration "Reference", # Reference assemblies - .NET dependencies ".netpackages", # .NET packages - NuGet packages for AL "bin", # Binary output - compiled binaries "obj", # Object files - intermediate build artifacts } # Check parent class ignore list first, then AL-specific return super().is_ignored_dirname(dirname) or dirname in al_ignore_dirs @override def request_full_symbol_tree(self, within_relative_path: str | None = None) -> list[UnifiedSymbolInformation]: """ Override to handle AL's requirement of opening files before requesting symbols. The AL Language Server requires files to be explicitly opened via textDocument/didOpen before it can provide meaningful symbols. Without this, it only returns directory symbols. This is different from most language servers which can provide symbols for unopened files. This method: 1. Scans the repository for all AL files (.al and .dal extensions) 2. Opens each file with the AL server 3. Requests symbols for each file 4. Combines all symbols into a hierarchical tree structure 5. Closes the files to free resources Args: within_relative_path: Restrict search to this file or directory path include_body: Whether to include symbol body content Returns: Full symbol tree with all AL symbols from opened files organized by directory """ log.debug("AL: Starting request_full_symbol_tree with file opening") # Determine the root path for scanning if within_relative_path is not None: within_abs_path = os.path.join(self.repository_root_path, within_relative_path) if not os.path.exists(within_abs_path): raise FileNotFoundError(f"File or directory not found: {within_abs_path}") if os.path.isfile(within_abs_path): # Single file case - use parent class implementation root_nodes = self.request_document_symbols(within_relative_path).root_symbols return root_nodes # Directory case - scan within this directory scan_root = Path(within_abs_path) else: # Scan entire repository scan_root = Path(self.repository_root_path) # For AL, we always need to open files to get symbols al_files = [] # Walk through the repository to find all AL files for root, dirs, files in os.walk(scan_root): # Skip ignored directories dirs[:] = [d for d in dirs if not self.is_ignored_dirname(d)] # Find AL files for file in files: if file.endswith((".al", ".dal")): file_path = Path(root) / file # Use forward slashes for consistent paths try: relative_path = str(file_path.relative_to(self.repository_root_path)).replace("\\", "/") al_files.append((file_path, relative_path)) except ValueError: # File is outside repository root, skip it continue log.debug(f"AL: Found {len(al_files)} AL files") if not al_files: log.warning("AL: No AL files found in repository") return [] # Collect all symbols from all files all_file_symbols: list[UnifiedSymbolInformation] = [] file_symbol: UnifiedSymbolInformation for file_path, relative_path in al_files: try: # Use our overridden request_document_symbols which handles opening log.debug(f"AL: Getting symbols for {relative_path}") all_syms, root_syms = self.request_document_symbols(relative_path).get_all_symbols_and_roots() if root_syms: # Create a file-level symbol containing the document symbols file_symbol = { "name": file_path.stem, # Just the filename without extension "kind": SymbolKind.File, "children": root_syms, "location": { "uri": file_path.as_uri(), "relativePath": relative_path, "absolutePath": str(file_path), "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}}, }, } all_file_symbols.append(file_symbol) log.debug(f"AL: Added {len(root_syms)} symbols from {relative_path}") elif all_syms: # If we only got all_syms but not root, use all_syms file_symbol = { "name": file_path.stem, "kind": SymbolKind.File, "children": all_syms, "location": { "uri": file_path.as_uri(), "relativePath": relative_path, "absolutePath": str(file_path), "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}}, }, } all_file_symbols.append(file_symbol) log.debug(f"AL: Added {len(all_syms)} symbols from {relative_path}") except Exception as e: log.warning(f"AL: Failed to get symbols for {relative_path}: {e}") if all_file_symbols: log.debug(f"AL: Returning symbols from {len(all_file_symbols)} files") # Group files by directory directory_structure: dict[str, list] = {} for file_symbol in all_file_symbols: rel_path = file_symbol["location"]["relativePath"] assert rel_path is not None path_parts = rel_path.split("/") if len(path_parts) > 1: # File is in a subdirectory dir_path = "/".join(path_parts[:-1]) if dir_path not in directory_structure: directory_structure[dir_path] = [] directory_structure[dir_path].append(file_symbol) else: # File is in root if "." not in directory_structure: directory_structure["."] = [] directory_structure["."].append(file_symbol) # Build hierarchical structure result = [] repo_path = Path(self.repository_root_path) for dir_path, file_symbols in directory_structure.items(): if dir_path == ".": # Root level files result.extend(file_symbols) else: # Create directory symbol dir_symbol = { "name": Path(dir_path).name, "kind": SymbolKind.Package, # Package/Directory "children": file_symbols, "location": { "relativePath": dir_path, "absolutePath": str(repo_path / dir_path), "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}}, }, } result.append(dir_symbol) return result else: log.warning("AL: No symbols found in any files") return [] # ===== Phase 1: Custom AL Command Implementations ===== @override def _send_definition_request(self, definition_params: DefinitionParams) -> Definition | list[LocationLink] | None: """ Override to use AL's custom gotodefinition command. AL Language Server uses 'al/gotodefinition' instead of the standard 'textDocument/definition' request. This custom command provides better navigation for AL-specific constructs like table extensions, page extensions, and codeunit references. If the custom command fails, we fall back to the standard LSP method. """ # Convert standard params to AL format (same structure, different method) al_params = {"textDocument": definition_params["textDocument"], "position": definition_params["position"]} try: # Use custom AL command instead of standard LSP response = self.server.send_request("al/gotodefinition", al_params) log.debug(f"AL gotodefinition response: {response}") return response # type: ignore[return-value] except Exception as e: log.warning(f"Failed to use al/gotodefinition, falling back to standard: {e}") # Fallback to standard LSP method if custom command fails return super()._send_definition_request(definition_params) def check_project_loaded(self) -> bool: """ Check if AL project closure is fully loaded. Uses AL's custom 'al/hasProjectClosureLoadedRequest' to determine if the project and all its dependencies have been fully loaded and indexed. This is important because AL operations may fail or return incomplete results if the project is still loading. Returns: bool: True if project is loaded, False otherwise """ if not hasattr(self, "server") or not self.server_started: log.debug("Cannot check project load - server not started") return False # Check if we've already determined this request isn't supported if not self._project_load_check_supported: return True # Assume loaded if check isn't supported try: # Use a very short timeout since this is just a status check response = self.server.send_request("al/hasProjectClosureLoadedRequest", {"timeout": 1}) # Response can be boolean directly, dict with 'loaded' field, or None if isinstance(response, bool): return response elif isinstance(response, dict): return response.get("loaded", False) elif response is None: # None typically means the project is still loading log.debug("Project load check returned None") return False else: log.debug(f"Unexpected response type for project load check: {type(response)}") return False except Exception as e: # Mark as unsupported to avoid repeated failed attempts self._project_load_check_supported = False log.debug(f"Project load check not supported by this AL server version: {e}") # Assume loaded if we can't check return True def _wait_for_project_load(self, timeout: int = 3) -> bool: """ Wait for project to be fully loaded. Polls the AL server to check if the project is loaded. This is optional as not all AL server versions support this check. We use a short timeout and continue regardless of the result. Args: timeout: Maximum time to wait in seconds (default 3s) Returns: bool: True if project loaded within timeout, False otherwise """ start_time = time.time() log.debug(f"Checking AL project load status (timeout: {timeout}s)...") while time.time() - start_time < timeout: if self.check_project_loaded(): elapsed = time.time() - start_time log.info(f"AL project fully loaded after {elapsed:.1f}s") return True time.sleep(0.5) log.debug(f"Project load check timed out after {timeout}s (non-critical)") return False def set_active_workspace(self, workspace_uri: str | None = None) -> None: """ Set the active AL workspace. This is important when multiple workspaces exist to ensure operations target the correct workspace. The AL server can handle multiple projects simultaneously, but only one can be "active" at a time for operations like symbol search and navigation. This uses the custom 'al/setActiveWorkspace' LSP command. Args: workspace_uri: URI of workspace to set as active, or None to use repository root """ if not hasattr(self, "server") or not self.server_started: log.debug("Cannot set active workspace - server not started") return if workspace_uri is None: workspace_uri = Path(self.repository_root_path).resolve().as_uri() params = {"workspaceUri": workspace_uri} try: self.server.send_request("al/setActiveWorkspace", params) log.info(f"Set active workspace to: {workspace_uri}") except Exception as e: log.warning(f"Failed to set active workspace: {e}") # Non-critical error, continue operation @override def request_document_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None) -> DocumentSymbols: """ Override to normalize AL symbol names by stripping object type and ID metadata. AL Language Server returns symbol names with full object format like 'Table 50000 "TEST Customer"', but symbol names should be pure without metadata. This follows the same pattern as Java LS which strips type information from names. Metadata (object type, ID) is available via the hover LSP method when using include_info=True in find_symbol. """ # Normalize path separators for cross-platform compatibility (backslash → forward slash) relative_file_path = self._normalize_path(relative_file_path) # Get symbols from parent implementation document_symbols = super().request_document_symbols(relative_file_path, file_buffer=file_buffer) # Normalize names by stripping AL object metadata, storing originals for hover def normalize_name(symbol: UnifiedSymbolInformation) -> None: original_name = symbol["name"] normalized_name = self._extract_al_display_name(original_name) # Store original name if it was normalized (for hover injection) # Only store if we have valid position data to avoid false matches at (0, 0) if original_name != normalized_name: sel_range = symbol.get("selectionRange") if sel_range: start = sel_range.get("start") if start and "line" in start and "character" in start: line = start["line"] char = start["character"] self._al_original_names[(relative_file_path, line, char)] = original_name symbol["name"] = normalized_name # Process children recursively if symbol.get("children"): for child in symbol["children"]: normalize_name(child) # Apply to all root symbols for sym in document_symbols.root_symbols: normalize_name(sym) return document_symbols @override def request_hover( self, relative_file_path: str, line: int, column: int, file_buffer: LSPFileBuffer | None = None ) -> ls_types.Hover | None: """ Override to inject original AL object name (with type and ID) into hover responses. When hovering over a symbol whose name was normalized, we prepend the original full name (e.g., 'Table 50000 "TEST Customer"') to the hover content. """ # Normalize path separators for cross-platform compatibility (backslash → forward slash) relative_file_path = self._normalize_path(relative_file_path) hover = super().request_hover(relative_file_path, line, column, file_buffer=file_buffer) if hover is None: return None # Check if we have an original name for this position original_name = self._al_original_names.get((relative_file_path, line, column)) if original_name and "contents" in hover: contents = hover["contents"] if isinstance(contents, dict) and "value" in contents: # Prepend the original full name to the hover content prefix = f"**{original_name}**\n\n---\n\n" contents["value"] = prefix + contents["value"] return hover ================================================ FILE: src/solidlsp/language_servers/ansible_language_server.py ================================================ """ Provides Ansible specific instantiation of the LanguageServer class using ansible-language-server. Contains various configurations and settings specific to Ansible YAML files (playbooks, roles, etc.). """ import fnmatch import logging import os import pathlib import shutil from typing import Any, ClassVar from overrides import override from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) def _deep_merge(base: dict, override: dict) -> None: """Recursively merge *override* into *base*, modifying *base* in-place.""" for key, value in override.items(): if key in base and isinstance(base[key], dict) and isinstance(value, dict): _deep_merge(base[key], value) else: base[key] = value class AnsibleLanguageServer(SolidLanguageServer): """Provides Ansible specific instantiation of the LanguageServer class using ansible-language-server. Contains various configurations and settings specific to Ansible YAML files (playbooks, roles, inventories, etc.). Supported ``ls_specific_settings`` keys (via ``serena_config.yml``): * ``ls_path`` (str) — path to the ansible-language-server executable (handled by the base class). * ``ansible_path`` (str, default ``"ansible"``) — path to the ``ansible`` executable. * ``python_interpreter_path`` (str, default ``"python3"``) — path to the Python interpreter. * ``python_activation_script`` (str, default ``""``) — virtualenv activation script. * ``lint_enabled`` (bool, default ``False``) — enable ansible-lint (requires a separate installation of ``ansible-lint``). * ``lint_path`` (str, default ``"ansible-lint"``) — path to ``ansible-lint``. * ``ansible_settings`` (dict) — full settings dict, deep-merged on top of defaults. The structure mirrors the Ansible Language Server settings (``ansible.*``, ``python.*``, ``validation.*``, ``completion.*``, ``executionEnvironment.*``). """ # directory names that signal ansible content at ANY nesting level _ANSIBLE_DIR_NAMES: ClassVar[set[str]] = { "roles", "playbooks", "tasks", "handlers", "group_vars", "host_vars", "inventory", "inventories", "defaults", "vars", "meta", } # filename patterns handled by ansible LS regardless of path _ANSIBLE_FILENAME_PATTERNS: ClassVar[list[str]] = [ "playbook*.yml", "playbook*.yaml", "site.yml", "site.yaml", "requirements.yml", "requirements.yaml", ] @staticmethod def _is_ansible_path(relative_path: str) -> bool: """Check if a file is in an ansible-specific location. Matches if ANY component of the path is an ansible-specific directory name (e.g. ``roles``, ``tasks``, ``group_vars``), or if the filename matches an ansible-specific pattern. This works regardless of nesting depth: ``project/deploy/roles/web/tasks/main.yml`` matches on both ``roles`` and ``tasks``. :param relative_path: path relative to the repository root :return: True if the path is an ansible-specific location """ normalized = relative_path.replace("\\", "/") parts = normalized.split("/") # check if any directory component is ansible-specific dir_parts = parts[:-1] for part in dir_parts: if part in AnsibleLanguageServer._ANSIBLE_DIR_NAMES: return True # check filename patterns (e.g. playbook.yml, site.yaml) filename = parts[-1] for pattern in AnsibleLanguageServer._ANSIBLE_FILENAME_PATTERNS: if fnmatch.fnmatch(filename, pattern): return True return False @override def is_ignored_path(self, relative_path: str, ignore_unsupported_files: bool = True) -> bool: # standard ignore rules (extension, gitignore, etc.) if super().is_ignored_path(relative_path, ignore_unsupported_files): return True # for yml/yaml files, check if they are in ansible-specific paths if relative_path.endswith((".yml", ".yaml")): if not self._is_ansible_path(relative_path): return True return False @staticmethod def _determine_log_level(line: str) -> int: """Classify ansible-language-server stderr output to avoid false-positive errors.""" line_lower = line.lower() if any( [ "ansible is not installed" in line_lower, "ansible-lint" in line_lower and "not found" in line_lower, "cannot find module" in line_lower, ] ): return logging.DEBUG return SolidLanguageServer._determine_log_level(line) def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """Creates an AnsibleLanguageServer instance. This class is not meant to be instantiated directly. Use ``SolidLanguageServer.create()`` instead. """ super().__init__( config, repository_root_path, None, "ansible", solidlsp_settings, ) def _create_dependency_provider(self) -> LanguageServerDependencyProvider: return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) class DependencyProvider(LanguageServerDependencyProviderSinglePath): def _get_or_install_core_dependency(self) -> str: """Setup runtime dependencies for Ansible Language Server and return the path to the executable.""" # verify both node and npm are installed is_node_installed = shutil.which("node") is not None assert is_node_installed, "node is not installed or isn't in PATH. Please install Node.js and try again." is_npm_installed = shutil.which("npm") is not None assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." deps = RuntimeDependencyCollection( [ RuntimeDependency( id="ansible-language-server", description="Ansible Language Server (@ansible/ansible-language-server)", command="npm install --prefix ./ @ansible/ansible-language-server@1.2.3", platform_id="any", ), ] ) # install ansible-language-server if not already installed ansible_ls_dir = os.path.join(self._ls_resources_dir, "ansible-lsp") ansible_executable_path = os.path.join(ansible_ls_dir, "node_modules", ".bin", "ansible-language-server") # handle Windows executable extension if os.name == "nt": ansible_executable_path += ".cmd" if not os.path.exists(ansible_executable_path): log.info(f"Ansible Language Server executable not found at {ansible_executable_path}. Installing...") deps.install(ansible_ls_dir) log.info("Ansible Language Server dependencies installed successfully") if not os.path.exists(ansible_executable_path): raise FileNotFoundError( f"ansible-language-server executable not found at {ansible_executable_path}, " "something went wrong with the installation." ) return ansible_executable_path def _create_launch_command(self, core_path: str) -> list[str]: return [core_path, "--stdio"] def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: """Returns the initialize params for the Ansible Language Server. Reads shortcut keys and the ``ansible_settings`` dict from ``_custom_settings`` to build ``initializationOptions``. """ root_uri = pathlib.Path(repository_absolute_path).as_uri() # default ansible settings, populated from shortcut keys ansible_settings: dict[str, Any] = { "ansible": { "path": self._custom_settings.get("ansible_path", "ansible"), "useFullyQualifiedCollectionNames": True, }, "python": { "interpreterPath": self._custom_settings.get("python_interpreter_path", "python3"), "activationScript": self._custom_settings.get("python_activation_script", ""), }, "validation": { "enabled": True, "lint": { "enabled": self._custom_settings.get("lint_enabled", False), "path": self._custom_settings.get("lint_path", "ansible-lint"), }, }, "completion": { "provideRedirectModules": True, "provideModuleOptionAliases": True, }, "executionEnvironment": {"enabled": False}, } # full override via ansible_settings dict for advanced configuration user_settings = self._custom_settings.settings.get("ansible_settings") if user_settings: if not isinstance(user_settings, dict): raise TypeError( f"ansible_settings must be a dict, got {type(user_settings).__name__}. " "Expected structure matching Ansible LS settings: " "{'ansible': {...}, 'python': {...}, 'validation': {...}, ...}" ) _deep_merge(ansible_settings, user_settings) initialize_params = { "locale": "en", "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, "definition": {"dynamicRegistration": True}, "references": {"dynamicRegistration": True}, "documentSymbol": { "dynamicRegistration": True, "hierarchicalDocumentSymbolSupport": True, "symbolKind": {"valueSet": list(range(1, 27))}, }, "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, "codeAction": {"dynamicRegistration": True}, }, "workspace": { "workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}, "symbol": {"dynamicRegistration": True}, }, }, "processId": os.getpid(), "rootPath": repository_absolute_path, "rootUri": root_uri, "workspaceFolders": [ { "uri": root_uri, "name": os.path.basename(repository_absolute_path), } ], "initializationOptions": {"ansible": ansible_settings}, } return initialize_params # type: ignore def _start_server(self) -> None: """Starts the Ansible Language Server, waits for the server to be ready.""" def register_capability_handler(params: Any) -> None: return def show_message_request_handler(params: Any) -> None: """Handle ``window/showMessageRequest`` by returning ``null``. Per the LSP spec, returning ``null`` means no action was selected. Without this handler the client replies with ``MethodNotFound``, which the ansible LS treats as fatal. """ log.info(f"LSP: window/showMessageRequest (dismissed): {params.get('message', params)}") return def do_nothing(params: Any) -> None: return def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_request("window/showMessageRequest", show_message_request_handler) self.server.on_notification("window/logMessage", window_log_message) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) log.info("Starting Ansible server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) log.debug(f"Received initialize response from Ansible server: {init_response}") log.debug(f"Ansible server capabilities: {list(init_response['capabilities'].keys())}") self.server.notify.initialized({}) log.info("Ansible server initialization complete") ================================================ FILE: src/solidlsp/language_servers/bash_language_server.py ================================================ """ Provides Bash specific instantiation of the LanguageServer class using bash-language-server. Contains various configurations and settings specific to Bash scripting. """ import logging import os import pathlib import shutil import threading from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection from solidlsp.ls import ( DocumentSymbols, LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, LSPFileBuffer, SolidLanguageServer, ) from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) class BashLanguageServer(SolidLanguageServer): """ Provides Bash specific instantiation of the LanguageServer class using bash-language-server. Contains various configurations and settings specific to Bash scripting. """ def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a BashLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ super().__init__( config, repository_root_path, None, "bash", solidlsp_settings, ) self.server_ready = threading.Event() self.initialize_searcher_command_available = threading.Event() def _create_dependency_provider(self) -> LanguageServerDependencyProvider: return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) class DependencyProvider(LanguageServerDependencyProviderSinglePath): def _get_or_install_core_dependency(self) -> str: """ Setup runtime dependencies for Bash Language Server and return the command to start the server. """ # Verify both node and npm are installed is_node_installed = shutil.which("node") is not None assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." is_npm_installed = shutil.which("npm") is not None assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." deps = RuntimeDependencyCollection( [ RuntimeDependency( id="bash-language-server", description="bash-language-server package", command="npm install --prefix ./ bash-language-server@5.6.0", platform_id="any", ), ] ) # Install bash-language-server if not already installed bash_ls_dir = os.path.join(self._ls_resources_dir, "bash-lsp") bash_executable_path = os.path.join(bash_ls_dir, "node_modules", ".bin", "bash-language-server") # Handle Windows executable extension if os.name == "nt": bash_executable_path += ".cmd" if not os.path.exists(bash_executable_path): log.info(f"Bash Language Server executable not found at {bash_executable_path}. Installing...") deps.install(bash_ls_dir) log.info("Bash language server dependencies installed successfully") if not os.path.exists(bash_executable_path): raise FileNotFoundError( f"bash-language-server executable not found at {bash_executable_path}, something went wrong with the installation." ) return bash_executable_path def _create_launch_command(self, core_path: str) -> list[str]: return [core_path, "start"] @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the Bash Language Server. """ root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, "definition": {"dynamicRegistration": True}, "references": {"dynamicRegistration": True}, "documentSymbol": { "dynamicRegistration": True, "hierarchicalDocumentSymbolSupport": True, "symbolKind": {"valueSet": list(range(1, 27))}, }, "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, "signatureHelp": {"dynamicRegistration": True}, "codeAction": {"dynamicRegistration": True}, }, "workspace": { "workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}, "symbol": {"dynamicRegistration": True}, }, }, "processId": os.getpid(), "rootPath": repository_absolute_path, "rootUri": root_uri, "workspaceFolders": [ { "uri": root_uri, "name": os.path.basename(repository_absolute_path), } ], } return initialize_params # type: ignore def _start_server(self) -> None: """ Starts the Bash Language Server, waits for the server to be ready and yields the LanguageServer instance. """ def register_capability_handler(params: dict) -> None: assert "registrations" in params for registration in params["registrations"]: if registration["method"] == "workspace/executeCommand": self.initialize_searcher_command_available.set() return def execute_client_command_handler(params: dict) -> list: return [] def do_nothing(params: dict) -> None: return def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") # Check for bash-language-server ready signals message_text = msg.get("message", "") if "Analyzing" in message_text or "analysis complete" in message_text.lower(): log.info("Bash language server analysis signals detected") self.server_ready.set() self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_notification("window/logMessage", window_log_message) self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) log.info("Starting Bash server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) log.debug(f"Received initialize response from bash server: {init_response}") # Enhanced capability checks for bash-language-server 5.6.0 assert init_response["capabilities"]["textDocumentSync"] in [1, 2] # Full or Incremental assert "completionProvider" in init_response["capabilities"] # Verify document symbol support is available if "documentSymbolProvider" in init_response["capabilities"]: log.info("Bash server supports document symbols") else: log.warning("Warning: Bash server does not report document symbol support") self.server.notify.initialized({}) # Wait for server readiness with timeout log.info("Waiting for Bash language server to be ready...") if not self.server_ready.wait(timeout=3.0): # Fallback: assume server is ready after timeout # This is common. bash-language-server doesn't always send explicit ready signals. Log as info log.info("Timeout waiting for bash server ready signal, proceeding anyway") self.server_ready.set() else: log.info("Bash server initialization complete") def request_document_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None) -> DocumentSymbols: # Uses the standard LSP documentSymbol request which provides reliable function detection # for all bash function syntaxes including: # - function name() { ... } (with function keyword) # - name() { ... } (traditional syntax) # - Functions with various indentation levels # - Functions with comments before/after/inside log.debug(f"Requesting document symbols via LSP for {relative_file_path}") # Use the standard LSP approach - bash-language-server handles all function syntaxes correctly document_symbols = super().request_document_symbols(relative_file_path, file_buffer=file_buffer) # Log detection results for debugging functions = [s for s in document_symbols.iter_symbols() if s.get("kind") == 12] log.info(f"LSP function detection for {relative_file_path}: Found {len(functions)} functions") return document_symbols ================================================ FILE: src/solidlsp/language_servers/ccls_language_server.py ================================================ """ This is an alternative to clangd for large C++ codebases where ccls may perform better for indexing and navigation. Requires ccls to be installed and available on PATH, or configured via ls_specific_settings with key "ls_path". Installation ------------ ccls must be installed manually as there are no prebuilt binaries available for direct download. Install using your system package manager: **Linux:** - Ubuntu/Debian (22.04+): ``sudo apt-get install ccls`` - Fedora/RHEL: ``sudo dnf install ccls`` - Arch Linux: ``sudo pacman -S ccls`` - openSUSE Tumbleweed: ``sudo zypper install ccls`` - Gentoo: ``sudo emerge dev-util/ccls`` **macOS:** - Homebrew: ``brew install ccls`` **Windows:** - Chocolatey: ``choco install ccls`` For alternative installation methods and build-from-source instructions, see: https://github.com/MaskRay/ccls/wiki/Build Official documentation: https://github.com/MaskRay/ccls """ import logging import os import pathlib import threading from typing import Any, cast from solidlsp.ls import ( LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer, ) from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) class CCLS(SolidLanguageServer): """ C/C++ language server implementation using ccls. Notes: - ccls should be installed and on PATH (or specify ls_path in settings) - compile_commands.json at repo root is recommended for accurate indexing """ def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a CclsLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ super().__init__(config, repository_root_path, None, "cpp", solidlsp_settings) self.server_ready = threading.Event() def _create_dependency_provider(self) -> LanguageServerDependencyProvider: return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) class DependencyProvider(LanguageServerDependencyProviderSinglePath): def _get_or_install_core_dependency(self) -> str: """ Resolve ccls path from system or raise helpful error if missing. Allows override via ls_specific_settings[language].ls_path. """ import shutil ccls_path = shutil.which("ccls") if not ccls_path: raise FileNotFoundError( "ccls is not installed on your system.\n" "Please install ccls using your system package manager:\n" " Linux (Ubuntu/Debian): sudo apt-get install ccls\n" " Linux (Fedora/RHEL): sudo dnf install ccls\n" " Linux (Arch): sudo pacman -S ccls\n" " macOS (Homebrew): brew install ccls\n" " Windows: choco install ccls\n\n" "For build instructions and more details, see:\n" " https://github.com/MaskRay/ccls/wiki/Build" ) log.info(f"Using system-installed ccls at {ccls_path}") return ccls_path def _create_launch_command(self, core_path: str) -> list[str]: return [core_path] @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the ccls Language Server. """ root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, "definition": {"dynamicRegistration": True}, "references": {"dynamicRegistration": True}, "documentSymbol": {"dynamicRegistration": True}, }, "workspace": {"workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}}, }, "processId": os.getpid(), "rootPath": repository_absolute_path, "rootUri": root_uri, "workspaceFolders": [ { "uri": root_uri, "name": "$name", } ], # ccls supports initializationOptions but none are required for basic functionality } return cast(InitializeParams, initialize_params) def _start_server(self) -> None: """ Starts the ccls language server and initializes the LSP connection. """ def do_nothing(params: Any) -> None: pass def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") # Register minimal handlers self.server.on_notification("window/logMessage", window_log_message) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) log.info("Starting ccls server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.info("Sending initialize request from LSP client to ccls and awaiting response") self.server.send.initialize(initialize_params) # Do not assert clangd-specific capability shapes; ccls differs self.server.notify.initialized({}) # Basic readiness self.server_ready.set() ================================================ FILE: src/solidlsp/language_servers/clangd_language_server.py ================================================ import json import logging import os import pathlib import threading from typing import Any, cast from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, ProcessLaunchInfo, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings from .common import RuntimeDependency, RuntimeDependencyCollection log = logging.getLogger(__name__) class ClangdLanguageServer(SolidLanguageServer): """ Provides C/C++ specific instantiation of the LanguageServer class. Contains various configurations and settings specific to C/C++. As the project gets bigger in size, building index will take time. Try running clangd multiple times to ensure index is built properly. Also make sure compile_commands.json is created at root of the source directory. Check clangd test case for example. """ def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a ClangdLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ super().__init__(config, repository_root_path, None, "cpp", solidlsp_settings) self.server_ready = threading.Event() self.service_ready_event = threading.Event() self.initialize_searcher_command_available = threading.Event() self.resolve_main_method_available = threading.Event() def _prepare_compile_commands(self) -> str | None: """ Prepare clangd compilation database with absolute directory paths. Clangd requires absolute directory paths in compile_commands.json for correct cross-file reference finding. This method reads the compile_commands.json, converts relative directory paths to absolute paths, and writes a transformed compilation database to the serena managed directory. The transformed file is persisted in .serena/serena_compile_commands.json (or a configurable directory via ls_specific_settings) and is not deleted on cleanup. This allows clangd to use the absolute-path version without modifying the user's original compile_commands.json. Returns the path to the serena directory containing the transformed database, or None if no transformation was needed. """ compile_db_path = os.path.join(self.repository_root_path, "compile_commands.json") if not os.path.exists(compile_db_path): # No compile_commands.json, nothing to do return None try: with open(compile_db_path, encoding="utf-8") as f: compile_commands = json.load(f) if not compile_commands: return None # Check if any entries have relative directory paths has_relative = False for entry in compile_commands: directory = entry.get("directory", "") if directory and not os.path.isabs(directory): has_relative = True # Convert to absolute path entry["directory"] = os.path.abspath(os.path.join(self.repository_root_path, directory)) if not has_relative: # No relative paths found, no need to create transformed database return None # Get the target directory from ls_specific_settings, default to .serena cpp_settings: dict[str, Any] = self._custom_settings or {} compile_commands_rel_dir = cpp_settings.get("compile_commands_dir", ".serena") compile_commands_dir = os.path.join(self.repository_root_path, compile_commands_rel_dir) os.makedirs(compile_commands_dir, exist_ok=True) # Write the transformed compile_commands.json # clangd looks for compile_commands.json in the --compile-commands-dir compile_commands_path = os.path.join(compile_commands_dir, "compile_commands.json") with open(compile_commands_path, "w", encoding="utf-8") as f: json.dump(compile_commands, f, indent=2) # Track the directory for --compile-commands-dir log.info(f"Created serena compilation database with absolute paths at {compile_commands_path}") return compile_commands_dir except (OSError, json.JSONDecodeError) as e: log.warning(f"Failed to prepare compile_commands.json: {e}") return None def _create_process_launch_info(self) -> ProcessLaunchInfo: """ Override to add --compile-commands-dir argument if we created a serena compilation database. """ # First, ensure the serena compile commands database is prepared compile_commands_dir = self._prepare_compile_commands() # Get the default launch info from parent launch_info = super()._create_process_launch_info() # If we created a serena compilation database, add --compile-commands-dir to the command if compile_commands_dir: # Insert --compile-commands-dir after the executable path cmd = launch_info.cmd assert isinstance(cmd, list) launch_info.cmd = [cmd[0], f"--compile-commands-dir={compile_commands_dir}"] + cmd[1:] return launch_info def _create_dependency_provider(self) -> LanguageServerDependencyProvider: return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) class DependencyProvider(LanguageServerDependencyProviderSinglePath): def _get_or_install_core_dependency(self) -> str: """ Setup runtime dependencies for ClangdLanguageServer and return the path to the executable. """ import shutil deps = RuntimeDependencyCollection( [ RuntimeDependency( id="Clangd", description="Clangd for Linux (x64)", url="https://github.com/clangd/clangd/releases/download/19.1.2/clangd-linux-19.1.2.zip", platform_id="linux-x64", archive_type="zip", binary_name="clangd_19.1.2/bin/clangd", ), RuntimeDependency( id="Clangd", description="Clangd for Windows (x64)", url="https://github.com/clangd/clangd/releases/download/19.1.2/clangd-windows-19.1.2.zip", platform_id="win-x64", archive_type="zip", binary_name="clangd_19.1.2/bin/clangd.exe", ), RuntimeDependency( id="Clangd", description="Clangd for macOS (x64)", url="https://github.com/clangd/clangd/releases/download/19.1.2/clangd-mac-19.1.2.zip", platform_id="osx-x64", archive_type="zip", binary_name="clangd_19.1.2/bin/clangd", ), RuntimeDependency( id="Clangd", description="Clangd for macOS (Arm64)", url="https://github.com/clangd/clangd/releases/download/19.1.2/clangd-mac-19.1.2.zip", platform_id="osx-arm64", archive_type="zip", binary_name="clangd_19.1.2/bin/clangd", ), ] ) clangd_ls_dir = os.path.join(self._ls_resources_dir, "clangd") try: dep = deps.get_single_dep_for_current_platform() except RuntimeError: dep = None if dep is None: # No prebuilt binary available, look for system-installed clangd clangd_executable_path = shutil.which("clangd") if not clangd_executable_path: raise FileNotFoundError( "Clangd is not installed on your system.\n" + "Please install clangd using your system package manager:\n" + " Ubuntu/Debian: sudo apt-get install clangd\n" + " Fedora/RHEL: sudo dnf install clang-tools-extra\n" + " Arch Linux: sudo pacman -S clang\n" + "See https://clangd.llvm.org/installation for more details." ) log.info(f"Using system-installed clangd at {clangd_executable_path}") else: # Standard download and install for platforms with prebuilt binaries clangd_executable_path = deps.binary_path(clangd_ls_dir) if not os.path.exists(clangd_executable_path): log.info(f"Clangd executable not found at {clangd_executable_path}. Downloading from {dep.url}") _ = deps.install(clangd_ls_dir) if not os.path.exists(clangd_executable_path): raise FileNotFoundError( f"Clangd executable not found at {clangd_executable_path}.\n" + "Make sure you have installed clangd. See https://clangd.llvm.org/installation" ) os.chmod(clangd_executable_path, 0o755) return clangd_executable_path def _create_launch_command(self, core_path: str) -> list[str]: # --background-index enables clangd to index all files in the project, # which is required for finding cross-file references return [core_path, "--background-index"] @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the clangd Language Server. """ root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, "definition": {"dynamicRegistration": True}, "references": {"dynamicRegistration": True}, "documentSymbol": { "dynamicRegistration": True, "hierarchicalDocumentSymbolSupport": True, }, }, "workspace": {"workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}}, }, "processId": os.getpid(), "rootPath": repository_absolute_path, "rootUri": root_uri, "workspaceFolders": [ { "uri": root_uri, "name": "$name", } ], } return cast(InitializeParams, initialize_params) def _start_server(self) -> None: """ Starts the Clangd Language Server, waits for the server to be ready and yields the LanguageServer instance. Usage: ``` async with lsp.start_server(): # LanguageServer has been initialized and ready to serve requests await lsp.request_definition(...) await lsp.request_references(...) # Shutdown the LanguageServer on exit from scope # LanguageServer has been shutdown ``` """ def register_capability_handler(params: Any) -> None: assert "registrations" in params for registration in params["registrations"]: if registration["method"] == "workspace/executeCommand": self.initialize_searcher_command_available.set() self.resolve_main_method_available.set() return def lang_status_handler(params: Any) -> None: # TODO: Should we wait for # server -> client: {'jsonrpc': '2.0', 'method': 'language/status', 'params': {'type': 'ProjectStatus', 'message': 'OK'}} # Before proceeding? if params["type"] == "ServiceReady" and params["message"] == "ServiceReady": self.service_ready_event.set() def execute_client_command_handler(params: Any) -> list: return [] def do_nothing(params: Any) -> None: return def check_experimental_status(params: Any) -> None: if params["quiescent"] == True: self.server_ready.set() def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_notification("language/status", lang_status_handler) self.server.on_notification("window/logMessage", window_log_message) self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) self.server.on_notification("language/actionableNotification", do_nothing) self.server.on_notification("experimental/serverStatus", check_experimental_status) log.info("Starting Clangd server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) assert init_response["capabilities"]["textDocumentSync"]["change"] == 2 # type: ignore assert "completionProvider" in init_response["capabilities"] assert init_response["capabilities"]["completionProvider"] == { "triggerCharacters": [".", "<", ">", ":", '"', "/", "*"], "resolveProvider": False, } self.server.notify.initialized({}) # set ready flag, clangd sends no meaningful notification when ready # TODO This defeats the purpose of the event; we should wait for the server to actually be ready self.server_ready.set() # wait for server to be ready self.server_ready.wait() ================================================ FILE: src/solidlsp/language_servers/clojure_lsp.py ================================================ """ Provides Clojure specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Clojure. """ import logging import os import pathlib import shutil import subprocess import threading from typing import cast from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings from .common import RuntimeDependency, RuntimeDependencyCollection log = logging.getLogger(__name__) def run_command(cmd: list, capture_output: bool = True) -> subprocess.CompletedProcess: return subprocess.run( cmd, stdout=subprocess.PIPE if capture_output else None, stderr=subprocess.STDOUT if capture_output else None, text=True, check=True ) def verify_clojure_cli() -> None: install_msg = "Please install the official Clojure CLI from:\n https://clojure.org/guides/getting_started" if shutil.which("clojure") is None: raise FileNotFoundError("`clojure` not found.\n" + install_msg) help_proc = run_command(["clojure", "--help"]) if "-Aaliases" not in help_proc.stdout: raise RuntimeError("Detected a Clojure executable, but it does not support '-Aaliases'.\n" + install_msg) spath_proc = run_command(["clojure", "-Spath"], capture_output=False) if spath_proc.returncode != 0: raise RuntimeError("`clojure -Spath` failed; please upgrade to Clojure CLI ≥ 1.10.") class ClojureLSP(SolidLanguageServer): """ Provides a clojure-lsp specific instantiation of the LanguageServer class. Contains various configurations and settings specific to clojure. """ clojure_lsp_releases = "https://github.com/clojure-lsp/clojure-lsp/releases/latest/download" runtime_dependencies = RuntimeDependencyCollection( [ RuntimeDependency( id="clojure-lsp", url=f"{clojure_lsp_releases}/clojure-lsp-native-macos-aarch64.zip", platform_id="osx-arm64", archive_type="zip", binary_name="clojure-lsp", ), RuntimeDependency( id="clojure-lsp", url=f"{clojure_lsp_releases}/clojure-lsp-native-macos-amd64.zip", platform_id="osx-x64", archive_type="zip", binary_name="clojure-lsp", ), RuntimeDependency( id="clojure-lsp", url=f"{clojure_lsp_releases}/clojure-lsp-native-linux-aarch64.zip", platform_id="linux-arm64", archive_type="zip", binary_name="clojure-lsp", ), RuntimeDependency( id="clojure-lsp", url=f"{clojure_lsp_releases}/clojure-lsp-native-linux-amd64.zip", platform_id="linux-x64", archive_type="zip", binary_name="clojure-lsp", ), RuntimeDependency( id="clojure-lsp", url=f"{clojure_lsp_releases}/clojure-lsp-native-windows-amd64.zip", platform_id="win-x64", archive_type="zip", binary_name="clojure-lsp.exe", ), ] ) def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a ClojureLSP instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ super().__init__( config, repository_root_path, None, "clojure", solidlsp_settings, ) self.server_ready = threading.Event() self.initialize_searcher_command_available = threading.Event() self.resolve_main_method_available = threading.Event() self.service_ready_event = threading.Event() def _create_dependency_provider(self) -> LanguageServerDependencyProvider: return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) class DependencyProvider(LanguageServerDependencyProviderSinglePath): def _get_or_install_core_dependency(self) -> str: """Setup runtime dependencies for clojure-lsp and return the path to the executable.""" verify_clojure_cli() deps = ClojureLSP.runtime_dependencies dependency = deps.get_single_dep_for_current_platform() clojurelsp_executable_path = deps.binary_path(self._ls_resources_dir) if not os.path.exists(clojurelsp_executable_path): log.info( f"Downloading and extracting clojure-lsp from {dependency.url} to {self._ls_resources_dir}", ) deps.install(self._ls_resources_dir) if not os.path.exists(clojurelsp_executable_path): raise FileNotFoundError(f"Download failed? Could not find clojure-lsp executable at {clojurelsp_executable_path}") os.chmod(clojurelsp_executable_path, 0o755) return clojurelsp_executable_path def _create_launch_command(self, core_path: str) -> list[str]: return [core_path] @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """Returns the init params for clojure-lsp.""" root_uri = pathlib.Path(repository_absolute_path).as_uri() result = { # type: ignore "processId": os.getpid(), "rootPath": repository_absolute_path, "rootUri": root_uri, "capabilities": { "workspace": { "applyEdit": True, "workspaceEdit": {"documentChanges": True}, "symbol": {"symbolKind": {"valueSet": list(range(1, 27))}}, "workspaceFolders": True, }, "textDocument": { "synchronization": {"didSave": True}, "publishDiagnostics": {"relatedInformation": True, "tagSupport": {"valueSet": [1, 2]}}, "definition": {"linkSupport": True}, "references": {}, "hover": {"contentFormat": ["markdown", "plaintext"]}, "documentSymbol": { "hierarchicalDocumentSymbolSupport": True, "symbolKind": {"valueSet": list(range(1, 27))}, # }, }, "general": {"positionEncodings": ["utf-16"]}, }, "initializationOptions": {"dependency-scheme": "jar", "text-document-sync-kind": "incremental"}, "trace": "off", "workspaceFolders": [{"uri": root_uri, "name": os.path.basename(repository_absolute_path)}], } return cast(InitializeParams, result) def _start_server(self) -> None: def register_capability_handler(params: dict) -> None: assert "registrations" in params for registration in params["registrations"]: if registration["method"] == "workspace/executeCommand": self.initialize_searcher_command_available.set() self.resolve_main_method_available.set() return def lang_status_handler(params: dict) -> None: # TODO: Should we wait for # server -> client: {'jsonrpc': '2.0', 'method': 'language/status', 'params': {'type': 'ProjectStatus', 'message': 'OK'}} # Before proceeding? if params["type"] == "ServiceReady" and params["message"] == "ServiceReady": self.service_ready_event.set() def execute_client_command_handler(params: dict) -> list: return [] def do_nothing(params: dict) -> None: return def check_experimental_status(params: dict) -> None: if params["quiescent"] is True: self.server_ready.set() def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_notification("language/status", lang_status_handler) self.server.on_notification("window/logMessage", window_log_message) self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) self.server.on_notification("language/actionableNotification", do_nothing) self.server.on_notification("experimental/serverStatus", check_experimental_status) log.info("Starting clojure-lsp server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) assert init_response["capabilities"]["textDocumentSync"]["change"] == 2 # type: ignore assert "completionProvider" in init_response["capabilities"] # Clojure-lsp completion provider capabilities are more flexible than other servers' completion_provider = init_response["capabilities"]["completionProvider"] assert completion_provider["resolveProvider"] is True assert "triggerCharacters" in completion_provider self.server.notify.initialized({}) # after initialize, Clojure-lsp is ready to serve self.server_ready.set() ================================================ FILE: src/solidlsp/language_servers/common.py ================================================ from __future__ import annotations import logging import os import platform import subprocess from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass, replace from typing import Any, cast from solidlsp.ls_utils import FileUtils, PlatformUtils from solidlsp.util.subprocess_util import subprocess_kwargs log = logging.getLogger(__name__) @dataclass(kw_only=True) class RuntimeDependency: """Represents a runtime dependency for a language server.""" id: str platform_id: str | None = None url: str | None = None archive_type: str | None = None binary_name: str | None = None command: str | list[str] | None = None package_name: str | None = None package_version: str | None = None extract_path: str | None = None description: str | None = None class RuntimeDependencyCollection: """Utility to handle installation of runtime dependencies.""" def __init__(self, dependencies: Sequence[RuntimeDependency], overrides: Iterable[Mapping[str, Any]] = ()) -> None: """Initialize the collection with a list of dependencies and optional overrides. :param dependencies: List of base RuntimeDependency instances. The combination of 'id' and 'platform_id' must be unique. :param overrides: List of dictionaries which represent overrides or additions to the base dependencies. Each entry must contain at least the 'id' key, and optionally 'platform_id' to uniquely identify the dependency to override. """ self._id_and_platform_id_to_dep: dict[tuple[str, str | None], RuntimeDependency] = {} for dep in dependencies: dep_key = (dep.id, dep.platform_id) if dep_key in self._id_and_platform_id_to_dep: raise ValueError(f"Duplicate runtime dependency with id '{dep.id}' and platform_id '{dep.platform_id}':\n{dep}") self._id_and_platform_id_to_dep[dep_key] = dep for dep_values_override in overrides: override_key = cast(tuple[str, str | None], (dep_values_override["id"], dep_values_override.get("platform_id"))) base_dep = self._id_and_platform_id_to_dep.get(override_key) if base_dep is None: new_runtime_dep = RuntimeDependency(**dep_values_override) self._id_and_platform_id_to_dep[override_key] = new_runtime_dep else: self._id_and_platform_id_to_dep[override_key] = replace(base_dep, **dep_values_override) def get_dependencies_for_platform(self, platform_id: str) -> list[RuntimeDependency]: return [d for d in self._id_and_platform_id_to_dep.values() if d.platform_id in (platform_id, "any", "platform-agnostic", None)] def get_dependencies_for_current_platform(self) -> list[RuntimeDependency]: return self.get_dependencies_for_platform(PlatformUtils.get_platform_id().value) def get_single_dep_for_current_platform(self, dependency_id: str | None = None) -> RuntimeDependency: deps = self.get_dependencies_for_current_platform() if dependency_id is not None: deps = [d for d in deps if d.id == dependency_id] if len(deps) != 1: raise RuntimeError( f"Expected exactly one runtime dependency for platform-{PlatformUtils.get_platform_id().value} and {dependency_id=}, found {len(deps)}" ) return deps[0] def binary_path(self, target_dir: str) -> str: dep = self.get_single_dep_for_current_platform() if not dep.binary_name: return target_dir return os.path.join(target_dir, dep.binary_name) def install(self, target_dir: str) -> dict[str, str]: """Install all dependencies for the current platform into *target_dir*. Returns a mapping from dependency id to the resolved binary path. """ os.makedirs(target_dir, exist_ok=True) results: dict[str, str] = {} for dep in self.get_dependencies_for_current_platform(): if dep.url: self._install_from_url(dep, target_dir) if dep.command: self._run_command(dep.command, target_dir) if dep.binary_name: results[dep.id] = os.path.join(target_dir, dep.binary_name) else: results[dep.id] = target_dir return results @staticmethod def _run_command(command: str | list[str], cwd: str) -> None: kwargs = subprocess_kwargs() if not PlatformUtils.get_platform_id().is_windows(): import pwd kwargs["user"] = pwd.getpwuid(os.getuid()).pw_name # type: ignore is_windows = platform.system() == "Windows" if not isinstance(command, str) and not is_windows: # Since we are using the shell, we need to convert the command list to a single string # on Linux/macOS command = " ".join(command) log.info("Running command %s in '%s'", f"'{command}'" if isinstance(command, str) else command, cwd) completed_process = subprocess.run( command, shell=True, check=True, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, **kwargs, ) # type: ignore if completed_process.returncode != 0: log.warning("Command '%s' failed with return code %d", command, completed_process.returncode) log.warning("Command output:\n%s", completed_process.stdout) else: log.info( "Command completed successfully", ) @staticmethod def _install_from_url(dep: RuntimeDependency, target_dir: str) -> None: if not dep.url: raise ValueError(f"Dependency {dep.id} has no URL") if dep.archive_type in ("gz", "binary") and dep.binary_name: dest = os.path.join(target_dir, dep.binary_name) FileUtils.download_and_extract_archive(dep.url, dest, dep.archive_type) else: FileUtils.download_and_extract_archive(dep.url, target_dir, dep.archive_type or "zip") def quote_windows_path(path: str) -> str: """ Quote a path for Windows command execution if needed. On Windows, paths need to be quoted for proper command execution. The function checks if the path is already quoted to avoid double-quoting. On other platforms, the path is returned unchanged. Args: path: The file path to potentially quote Returns: The quoted path on Windows (if not already quoted), unchanged path on other platforms """ if platform.system() == "Windows": # Check if already quoted to avoid double-quoting if path.startswith('"') and path.endswith('"'): return path return f'"{path}"' return path ================================================ FILE: src/solidlsp/language_servers/csharp_language_server.py ================================================ """ CSharp Language Server using Roslyn Language Server (Official Roslyn-based LSP server from NuGet.org) """ import logging import os import platform import shutil import threading import urllib.request from collections.abc import Iterable from pathlib import Path from typing import Any, cast from overrides import override from serena.util.dotnet import DotNETUtil from solidlsp.ls import DocumentSymbols, LanguageServerDependencyProvider, LSPFileBuffer, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_exceptions import SolidLSPException from solidlsp.ls_types import Hover, UnifiedSymbolInformation from solidlsp.ls_utils import PathUtils from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams, InitializeResult from solidlsp.settings import SolidLSPSettings from solidlsp.util.zip import SafeZipExtractor from .common import RuntimeDependency, RuntimeDependencyCollection log = logging.getLogger(__name__) _RUNTIME_DEPENDENCIES = [ RuntimeDependency( id="CSharpLanguageServer", description="Roslyn Language Server for Windows (x64)", package_name="roslyn-language-server.win-x64", package_version="5.5.0-2.26078.4", url="https://www.nuget.org/api/v2/package/roslyn-language-server.win-x64/5.5.0-2.26078.4", platform_id="win-x64", archive_type="nupkg", binary_name="Microsoft.CodeAnalysis.LanguageServer.dll", extract_path="tools/net10.0/win-x64", ), RuntimeDependency( id="CSharpLanguageServer", description="Roslyn Language Server for Windows (ARM64)", package_name="roslyn-language-server.win-arm64", package_version="5.5.0-2.26078.4", url="https://www.nuget.org/api/v2/package/roslyn-language-server.win-arm64/5.5.0-2.26078.4", platform_id="win-arm64", archive_type="nupkg", binary_name="Microsoft.CodeAnalysis.LanguageServer.dll", extract_path="tools/net10.0/win-arm64", ), RuntimeDependency( id="CSharpLanguageServer", description="Roslyn Language Server for macOS (x64)", package_name="roslyn-language-server.osx-x64", package_version="5.5.0-2.26078.4", url="https://www.nuget.org/api/v2/package/roslyn-language-server.osx-x64/5.5.0-2.26078.4", platform_id="osx-x64", archive_type="nupkg", binary_name="Microsoft.CodeAnalysis.LanguageServer.dll", extract_path="tools/net10.0/osx-x64", ), RuntimeDependency( id="CSharpLanguageServer", description="Roslyn Language Server for macOS (ARM64)", package_name="roslyn-language-server.osx-arm64", package_version="5.5.0-2.26078.4", url="https://www.nuget.org/api/v2/package/roslyn-language-server.osx-arm64/5.5.0-2.26078.4", platform_id="osx-arm64", archive_type="nupkg", binary_name="Microsoft.CodeAnalysis.LanguageServer.dll", extract_path="tools/net10.0/osx-arm64", ), RuntimeDependency( id="CSharpLanguageServer", description="Roslyn Language Server for Linux (x64)", package_name="roslyn-language-server.linux-x64", package_version="5.5.0-2.26078.4", url="https://www.nuget.org/api/v2/package/roslyn-language-server.linux-x64/5.5.0-2.26078.4", platform_id="linux-x64", archive_type="nupkg", binary_name="Microsoft.CodeAnalysis.LanguageServer.dll", extract_path="tools/net10.0/linux-x64", ), RuntimeDependency( id="CSharpLanguageServer", description="Roslyn Language Server for Linux (ARM64)", package_name="roslyn-language-server.linux-arm64", package_version="5.5.0-2.26078.4", url="https://www.nuget.org/api/v2/package/roslyn-language-server.linux-arm64/5.5.0-2.26078.4", platform_id="linux-arm64", archive_type="nupkg", binary_name="Microsoft.CodeAnalysis.LanguageServer.dll", extract_path="tools/net10.0/linux-arm64", ), ] def breadth_first_file_scan(root_dir: str) -> Iterable[str]: """ Perform a breadth-first scan of files in the given directory. Yields file paths in breadth-first order. """ queue = [root_dir] while queue: current_dir = queue.pop(0) try: for item in os.listdir(current_dir): if item.startswith("."): continue item_path = os.path.join(current_dir, item) if os.path.isdir(item_path): queue.append(item_path) elif os.path.isfile(item_path): yield item_path except (PermissionError, OSError): # Skip directories we can't access pass def find_solution_or_project_file(root_dir: str) -> str | None: """ Find the first .sln or .slnx file in breadth-first order. If no solution file is found, look for a .csproj file. """ sln_file = None csproj_file = None for filename in breadth_first_file_scan(root_dir): if filename.endswith((".sln", ".slnx")) and sln_file is None: sln_file = filename elif filename.endswith(".csproj") and csproj_file is None: csproj_file = filename # If we found a solution file, return it immediately if sln_file: return sln_file # If no solution file was found, return the first .csproj file return csproj_file class CSharpLanguageServer(SolidLanguageServer): """ Provides C# specific instantiation of the LanguageServer class using the official Roslyn-based language server from NuGet.org. You can pass a list of runtime dependency overrides in ls_specific_settings["csharp"]["runtime_dependencies"]. This is a list of dicts, each containing at least the "id" key, and optionally "platform_id" to uniquely identify the dependency to override. Example - Override Roslyn Language Server URL: ``` { "id": "CSharpLanguageServer", "platform_id": "win-x64", "url": "https://example.com/custom-roslyn-server.nupkg" } ``` See the `_RUNTIME_DEPENDENCIES` variable above for the available dependency ids and platform_ids. Note: .NET runtime (version 10+) is required and installed automatically via Microsoft's official install scripts. If you have a custom .NET installation, ensure 'dotnet' is available in PATH with version 10 or higher. """ def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a CSharpLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ super().__init__(config, repository_root_path, None, "csharp", solidlsp_settings) # Cache for original Roslyn symbol names with type annotations # Key: (relative_file_path, line, character) -> Value: original name self._original_symbol_names: dict[tuple[str, int, int], str] = {} def _create_dependency_provider(self) -> LanguageServerDependencyProvider: return self.DependencyProvider(self._custom_settings, self._ls_resources_dir, self._solidlsp_settings, self.repository_root_path) @override def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in ["bin", "obj", "packages", ".vs"] @override def request_document_symbols(self, relative_file_path: str, file_buffer: Any = None) -> DocumentSymbols: """ Override to normalize Roslyn symbol names and cache originals. Roslyn 5.5.0+ returns symbol names with type annotations: - Properties: "Name : string" - Methods: "Add(int, int) : int" This method: 1. Normalizes names to base form ("Name", "Add") 2. Caches original names for rich information display 3. Populates LSP spec's 'detail' field with type/signature info """ symbols = super().request_document_symbols(relative_file_path, file_buffer) # Normalize all symbols recursively for symbol in symbols.iter_symbols(): self._normalize_symbol_name(symbol, relative_file_path) return symbols @override def request_hover(self, relative_file_path: str, line: int, column: int, file_buffer: LSPFileBuffer | None = None) -> Hover | None: """ Override to inject original Roslyn symbol names (with type annotations) into hover responses. When hovering over a symbol whose name was normalized, we prepend the original full name (e.g., 'Add(int, int) : int') to the hover content. """ hover = super().request_hover(relative_file_path, line, column, file_buffer=file_buffer) if hover is None: return None # Check if we have an original name for this position original_name = self._original_symbol_names.get((relative_file_path, line, column)) if original_name and "contents" in hover: contents = hover["contents"] if isinstance(contents, dict) and "value" in contents: # Prepend the original full name with type information to the hover content prefix = f"**{original_name}**\n\n---\n\n" contents["value"] = prefix + contents["value"] return hover def _normalize_symbol_name(self, symbol: UnifiedSymbolInformation, relative_file_path: str) -> None: """ Normalize a single symbol's name and cache the original. Processes children recursively. """ original_name = symbol.get("name", "") # Extract base name and type/signature info normalized_name, type_info = self._extract_base_name_and_type(original_name) # Store original name if it was normalized if original_name != normalized_name: sel_range = symbol.get("selectionRange") if sel_range: start = sel_range.get("start") if start and "line" in start and "character" in start: line = start["line"] char = start["character"] cache_key = (relative_file_path, line, char) self._original_symbol_names[cache_key] = original_name # Populate LSP spec's 'detail' field with type/signature information if type_info and "detail" not in symbol: symbol["detail"] = type_info # Update the symbol name symbol["name"] = normalized_name # Process children recursively children = symbol.get("children", []) for child in children: self._normalize_symbol_name(child, relative_file_path) @staticmethod def _extract_base_name_and_type(roslyn_name: str) -> tuple[str, str]: """ Extract base name and type/signature information from Roslyn symbol names. Examples: "Name : string" -> ("Name", ": string") "Add(int, int) : int" -> ("Add", "(int, int) : int") "ToString()" -> ("ToString", "()") "SimpleMethod" -> ("SimpleMethod", "") Returns: Tuple of (base_name, type_info) """ # Check for property pattern: "Name : Type" if " : " in roslyn_name and "(" not in roslyn_name: base_name, type_part = roslyn_name.split(" : ", 1) return base_name.strip(), f": {type_part.strip()}" # Check for method pattern: "MethodName(params) : ReturnType" if "(" in roslyn_name: paren_idx = roslyn_name.index("(") base_name = roslyn_name[:paren_idx].strip() signature = roslyn_name[paren_idx:].strip() return base_name, signature # No type annotation return roslyn_name, "" class DependencyProvider(LanguageServerDependencyProvider): def __init__( self, custom_settings: SolidLSPSettings.CustomLSSettings, ls_resources_dir: str, solidlsp_settings: SolidLSPSettings, repository_root_path: str, ): super().__init__(custom_settings, ls_resources_dir) self._solidlsp_settings = solidlsp_settings self._repository_root_path = repository_root_path self._dotnet_path, self._language_server_path = self._ensure_server_installed() def create_launch_command(self) -> list[str]: # Find solution or project file solution_or_project = find_solution_or_project_file(self._repository_root_path) # Create log directory log_dir = Path(self._ls_resources_dir) / "logs" log_dir.mkdir(parents=True, exist_ok=True) # Build command using dotnet directly cmd = [self._dotnet_path, self._language_server_path, "--logLevel=Information", f"--extensionLogDirectory={log_dir}", "--stdio"] # The language server will discover the solution/project from the workspace root if solution_or_project: log.info(f"Found solution/project file: {solution_or_project}") else: log.warning("No .sln/.slnx or .csproj file found, language server will attempt auto-discovery") log.debug(f"Language server command: {' '.join(cmd)}") return cmd def _ensure_server_installed(self) -> tuple[str, str]: """ Ensure .NET runtime and Microsoft.CodeAnalysis.LanguageServer are available. Returns a tuple of (dotnet_path, language_server_dll_path). """ runtime_dependency_overrides = cast(list[dict[str, Any]], self._custom_settings.get("runtime_dependencies", [])) # Filter out deprecated DotNetRuntime overrides and warn users filtered_overrides = [] for dep_override in runtime_dependency_overrides: if dep_override.get("id") == "DotNetRuntime": log.warning( "The 'DotNetRuntime' runtime_dependencies override is no longer supported. " ".NET is now installed automatically via Microsoft's official install scripts. " "Please remove this override from your configuration." ) else: filtered_overrides.append(dep_override) log.debug("Resolving runtime dependencies") runtime_dependencies = RuntimeDependencyCollection( _RUNTIME_DEPENDENCIES, overrides=filtered_overrides, ) log.debug( f"Available runtime dependencies: {runtime_dependencies.get_dependencies_for_current_platform}", ) # Find the dependencies for our platform lang_server_dep = runtime_dependencies.get_single_dep_for_current_platform("CSharpLanguageServer") dotnet_path = self._ensure_dotnet_runtime() server_dll_path = self._ensure_language_server(lang_server_dep) return dotnet_path, server_dll_path def _ensure_dotnet_runtime(self) -> str: """Ensure .NET runtime is available and return the dotnet executable path.""" return DotNETUtil("10.0", allow_higher_version=True).get_dotnet_path_or_raise() def _ensure_language_server(self, lang_server_dep: RuntimeDependency) -> str: """Ensure language server is available and return the DLL path.""" package_name = lang_server_dep.package_name package_version = lang_server_dep.package_version server_dir = Path(self._ls_resources_dir) / f"{package_name}.{package_version}" assert lang_server_dep.binary_name is not None server_dll = server_dir / lang_server_dep.binary_name if server_dll.exists(): log.info(f"Using cached Roslyn Language Server from {server_dll}") return str(server_dll) # Download and install the language server log.info(f"Downloading {package_name} version {package_version} from NuGet.org...") package_path = self._download_nuget_package(lang_server_dep) # Extract and install self._extract_language_server(lang_server_dep, package_path, server_dir) if not server_dll.exists(): raise SolidLSPException("Roslyn Language Server DLL not found after extraction") # Make executable on Unix systems if platform.system().lower() != "windows": server_dll.chmod(0o755) log.info(f"Successfully installed Roslyn Language Server to {server_dll}") return str(server_dll) @staticmethod def _extract_language_server(lang_server_dep: RuntimeDependency, package_path: Path, server_dir: Path) -> None: """Extract language server files from downloaded package.""" extract_path = lang_server_dep.extract_path or "lib/net9.0" source_dir = package_path / extract_path if not source_dir.exists(): # Try alternative locations for possible_dir in [ package_path / "tools" / "net9.0" / "any", package_path / "lib" / "net9.0", package_path / "contentFiles" / "any" / "net9.0", ]: if possible_dir.exists(): source_dir = possible_dir break else: raise SolidLSPException(f"Could not find language server files in package. Searched in {package_path}") # Copy files to cache directory server_dir.mkdir(parents=True, exist_ok=True) shutil.copytree(source_dir, server_dir, dirs_exist_ok=True) def _download_nuget_package(self, dependency: RuntimeDependency) -> Path: """ Download a NuGet package from NuGet.org and extract it. Returns the path to the extracted package directory. """ package_name = dependency.package_name package_version = dependency.package_version url = dependency.url if url is None: raise SolidLSPException(f"No URL specified for package {package_name} version {package_version}") # Create temporary directory for package download temp_dir = Path(self._ls_resources_dir) / "temp_downloads" temp_dir.mkdir(parents=True, exist_ok=True) try: log.debug(f"Downloading package from: {url}") # Download the .nupkg file nupkg_file = temp_dir / f"{package_name}.{package_version}.nupkg" urllib.request.urlretrieve(url, nupkg_file) # Extract the .nupkg file (it's just a zip file) package_extract_dir = temp_dir / f"{package_name}.{package_version}" package_extract_dir.mkdir(exist_ok=True) # Use SafeZipExtractor to handle long paths and skip errors extractor = SafeZipExtractor(archive_path=nupkg_file, extract_dir=package_extract_dir, verbose=False) extractor.extract_all() # Clean up the nupkg file nupkg_file.unlink() log.info(f"Successfully downloaded and extracted {package_name} version {package_version} from NuGet.org") return package_extract_dir except Exception as e: raise SolidLSPException(f"Failed to download package {package_name} version {package_version} from NuGet.org: {e}") from e def _get_initialize_params(self) -> InitializeParams: """ Returns the initialize params for the Microsoft.CodeAnalysis.LanguageServer. """ root_uri = PathUtils.path_to_uri(self.repository_root_path) root_name = os.path.basename(self.repository_root_path) return cast( InitializeParams, { "workspaceFolders": [{"uri": root_uri, "name": root_name}], "processId": os.getpid(), "rootPath": self.repository_root_path, "rootUri": root_uri, "capabilities": { "window": { "workDoneProgress": True, "showMessage": {"messageActionItem": {"additionalPropertiesSupport": True}}, "showDocument": {"support": True}, }, "workspace": { "applyEdit": True, "workspaceEdit": {"documentChanges": True}, "didChangeConfiguration": {"dynamicRegistration": True}, "didChangeWatchedFiles": {"dynamicRegistration": True}, "symbol": { "dynamicRegistration": True, "symbolKind": {"valueSet": list(range(1, 27))}, }, "executeCommand": {"dynamicRegistration": True}, "configuration": True, "workspaceFolders": True, "workDoneProgress": True, }, "textDocument": { "synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True}, "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, "signatureHelp": { "dynamicRegistration": True, "signatureInformation": { "documentationFormat": ["markdown", "plaintext"], "parameterInformation": {"labelOffsetSupport": True}, }, }, "definition": {"dynamicRegistration": True}, "references": {"dynamicRegistration": True}, "documentSymbol": { "dynamicRegistration": True, "symbolKind": {"valueSet": list(range(1, 27))}, "hierarchicalDocumentSymbolSupport": True, }, }, }, }, ) def _start_server(self) -> None: indexing_complete = threading.Event() def do_nothing(params: dict) -> None: return def window_log_message(msg: dict) -> None: """Log messages from the language server.""" message_text = msg.get("message", "") level = msg.get("type", 4) # Default to Log level # Map LSP message types to Python logging levels level_map = {1: logging.ERROR, 2: logging.WARNING, 3: logging.INFO, 4: logging.DEBUG} # Error # Warning # Info # Log log.log(level_map.get(level, logging.DEBUG), f"LSP: {message_text}") def handle_progress(params: dict) -> None: """Handle progress notifications from the language server.""" token = params.get("token", "") value = params.get("value", {}) # Log raw progress for debugging log.debug(f"Progress notification received: {params}") # Handle different progress notification types kind = value.get("kind") if kind == "begin": title = value.get("title", "Operation in progress") message = value.get("message", "") percentage = value.get("percentage") if percentage is not None: log.debug(f"Progress [{token}]: {title} - {message} ({percentage}%)") else: log.info(f"Progress [{token}]: {title} - {message}") elif kind == "report": message = value.get("message", "") percentage = value.get("percentage") if percentage is not None: log.info(f"Progress [{token}]: {message} ({percentage}%)") elif message: log.info(f"Progress [{token}]: {message}") elif kind == "end": message = value.get("message", "Operation completed") log.info(f"Progress [{token}]: {message}") def handle_workspace_configuration(params: dict) -> list: """Handle workspace/configuration requests from the server.""" items = params.get("items", []) result: list[Any] = [] for item in items: section = item.get("section", "") # Provide default values based on the configuration section if section.startswith(("dotnet", "csharp")): # Default configuration for C# settings if "enable" in section or "show" in section or "suppress" in section or "navigate" in section: # Boolean settings result.append(False) elif "scope" in section: # Scope settings - use appropriate enum values if "analyzer_diagnostics_scope" in section: result.append("openFiles") # BackgroundAnalysisScope elif "compiler_diagnostics_scope" in section: result.append("openFiles") # CompilerDiagnosticsScope else: result.append("openFiles") elif section == "dotnet_member_insertion_location": # ImplementTypeInsertionBehavior enum result.append("with_other_members_of_the_same_kind") elif section == "dotnet_property_generation_behavior": # ImplementTypePropertyGenerationBehavior enum result.append("prefer_throwing_properties") elif "location" in section or "behavior" in section: # Other enum settings - return null to avoid parsing errors result.append(None) else: # Default for other dotnet/csharp settings result.append(None) elif section == "tab_width" or section == "indent_size": # Tab and indent settings result.append(4) elif section == "insert_final_newline": # Editor settings result.append(True) else: # Unknown configuration - return null result.append(None) return result def handle_work_done_progress_create(params: dict) -> None: """Handle work done progress create requests.""" # Just acknowledge the request return def handle_register_capability(params: dict) -> None: """Handle client/registerCapability requests.""" # Just acknowledge the request - we don't need to track these for now return def handle_project_needs_restore(params: dict) -> None: return def handle_workspace_indexing_complete(params: dict) -> None: indexing_complete.set() # Set up notification handlers self.server.on_notification("window/logMessage", window_log_message) self.server.on_notification("$/progress", handle_progress) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) self.server.on_notification("workspace/projectInitializationComplete", handle_workspace_indexing_complete) self.server.on_request("workspace/configuration", handle_workspace_configuration) self.server.on_request("window/workDoneProgress/create", handle_work_done_progress_create) self.server.on_request("client/registerCapability", handle_register_capability) self.server.on_request("workspace/_roslyn_projectNeedsRestore", handle_project_needs_restore) log.info("Starting Microsoft.CodeAnalysis.LanguageServer process") try: self.server.start() except Exception as e: log.info(f"Failed to start language server process: {e}", logging.ERROR) raise SolidLSPException(f"Failed to start C# language server: {e}") # Send initialization initialize_params = self._get_initialize_params() log.info("Sending initialize request to language server") try: init_response = self.server.send.initialize(initialize_params) log.info(f"Received initialize response: {init_response}") except Exception as e: raise SolidLSPException(f"Failed to initialize C# language server for {self.repository_root_path}: {e}") from e # Apply diagnostic capabilities self._force_pull_diagnostics(init_response) # Verify required capabilities capabilities = init_response.get("capabilities", {}) required_capabilities = [ "textDocumentSync", "definitionProvider", "referencesProvider", "documentSymbolProvider", ] missing = [cap for cap in required_capabilities if cap not in capabilities] if missing: raise RuntimeError( f"Language server is missing required capabilities: {', '.join(missing)}. " "Initialization failed. Please ensure the correct version of Microsoft.CodeAnalysis.LanguageServer is installed and the .NET runtime is working." ) # Complete initialization self.server.notify.initialized({}) # Open solution and project files self._open_solution_and_projects() log.info( "Microsoft.CodeAnalysis.LanguageServer initialized and ready\n" "Waiting for language server to index project files...\n" "This may take a while for large projects" ) if indexing_complete.wait(30): # Wait up to 30 seconds for indexing log.info("Indexing complete") else: log.warning("Timeout waiting for indexing to complete, proceeding anyway") def _force_pull_diagnostics(self, init_response: dict | InitializeResult) -> None: """ Apply the diagnostic capabilities hack. Forces the server to support pull diagnostics. """ capabilities = init_response.get("capabilities", {}) diagnostic_provider: Any = capabilities.get("diagnosticProvider", {}) # Add the diagnostic capabilities hack if isinstance(diagnostic_provider, dict): diagnostic_provider.update( { "interFileDependencies": True, "workDoneProgress": True, "workspaceDiagnostics": True, } ) log.debug("Applied diagnostic capabilities hack for better C# diagnostics") def _open_solution_and_projects(self) -> None: """ Open solution and project files using notifications. """ # Find solution file (.sln or .slnx) solution_file = None for filename in breadth_first_file_scan(self.repository_root_path): if filename.endswith((".sln", ".slnx")): solution_file = filename break # Send solution/open notification if solution file found if solution_file: solution_uri = PathUtils.path_to_uri(solution_file) self.server.notify.send_notification("solution/open", {"solution": solution_uri}) log.debug(f"Opened solution file: {solution_file}") # Find and open project files project_files = [] for filename in breadth_first_file_scan(self.repository_root_path): if filename.endswith(".csproj"): project_files.append(filename) # Send project/open notifications for each project file if project_files: project_uris = [PathUtils.path_to_uri(project_file) for project_file in project_files] self.server.notify.send_notification("project/open", {"projects": project_uris}) log.debug(f"Opened project files: {project_files}") @override def _get_wait_time_for_cross_file_referencing(self) -> float: return 2 ================================================ FILE: src/solidlsp/language_servers/dart_language_server.py ================================================ import logging import os import pathlib from typing import cast from solidlsp.ls import SolidLanguageServer from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings from ..ls_config import LanguageServerConfig from ..lsp_protocol_handler.lsp_types import InitializeParams from .common import RuntimeDependency, RuntimeDependencyCollection log = logging.getLogger(__name__) class DartLanguageServer(SolidLanguageServer): """ Provides Dart specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Dart. """ def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings) -> None: """ Creates a DartServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ executable_path = self._setup_runtime_dependencies(solidlsp_settings) super().__init__( config, repository_root_path, ProcessLaunchInfo(cmd=executable_path, cwd=repository_root_path), "dart", solidlsp_settings ) @classmethod def _setup_runtime_dependencies(cls, solidlsp_settings: SolidLSPSettings) -> str: deps = RuntimeDependencyCollection( [ RuntimeDependency( id="DartLanguageServer", description="Dart Language Server for Linux (x64)", url="https://storage.googleapis.com/dart-archive/channels/stable/release/3.7.1/sdk/dartsdk-linux-x64-release.zip", platform_id="linux-x64", archive_type="zip", binary_name="dart-sdk/bin/dart", ), RuntimeDependency( id="DartLanguageServer", description="Dart Language Server for Windows (x64)", url="https://storage.googleapis.com/dart-archive/channels/stable/release/3.7.1/sdk/dartsdk-windows-x64-release.zip", platform_id="win-x64", archive_type="zip", binary_name="dart-sdk/bin/dart.exe", ), RuntimeDependency( id="DartLanguageServer", description="Dart Language Server for Windows (arm64)", url="https://storage.googleapis.com/dart-archive/channels/stable/release/3.7.1/sdk/dartsdk-windows-arm64-release.zip", platform_id="win-arm64", archive_type="zip", binary_name="dart-sdk/bin/dart.exe", ), RuntimeDependency( id="DartLanguageServer", description="Dart Language Server for macOS (x64)", url="https://storage.googleapis.com/dart-archive/channels/stable/release/3.7.1/sdk/dartsdk-macos-x64-release.zip", platform_id="osx-x64", archive_type="zip", binary_name="dart-sdk/bin/dart", ), RuntimeDependency( id="DartLanguageServer", description="Dart Language Server for macOS (arm64)", url="https://storage.googleapis.com/dart-archive/channels/stable/release/3.7.1/sdk/dartsdk-macos-arm64-release.zip", platform_id="osx-arm64", archive_type="zip", binary_name="dart-sdk/bin/dart", ), ] ) dart_ls_dir = cls.ls_resources_dir(solidlsp_settings) dart_executable_path = deps.binary_path(dart_ls_dir) if not os.path.exists(dart_executable_path): deps.install(dart_ls_dir) assert os.path.exists(dart_executable_path) os.chmod(dart_executable_path, 0o755) return f"{dart_executable_path} language-server --client-id multilspy.dart --client-version 1.2" @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the Dart Language Server. """ root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "capabilities": {}, "initializationOptions": { "onlyAnalyzeProjectsWithOpenFiles": False, "closingLabels": False, "outline": False, "flutterOutline": False, "allowOpenUri": False, }, "trace": "verbose", "processId": os.getpid(), "rootPath": repository_absolute_path, "rootUri": pathlib.Path(repository_absolute_path).as_uri(), "workspaceFolders": [ { "uri": root_uri, "name": os.path.basename(repository_absolute_path), } ], } return cast(InitializeParams, initialize_params) def _start_server(self) -> None: """ Start the language server and yield when the server is ready. """ def execute_client_command_handler(params: dict) -> list: return [] def do_nothing(params: dict) -> None: return def check_experimental_status(params: dict) -> None: pass def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") self.server.on_request("client/registerCapability", do_nothing) self.server.on_notification("language/status", do_nothing) self.server.on_notification("window/logMessage", window_log_message) self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) self.server.on_notification("language/actionableNotification", do_nothing) self.server.on_notification("experimental/serverStatus", check_experimental_status) log.info("Starting dart-language-server server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.debug("Sending initialize request to dart-language-server") init_response = self.server.send_request("initialize", initialize_params) # type: ignore log.info(f"Received initialize response from dart-language-server: {init_response}") self.server.notify.initialized({}) ================================================ FILE: src/solidlsp/language_servers/eclipse_jdtls.py ================================================ """ Provides Java specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Java. """ import dataclasses import logging import os import pathlib import shutil import threading import uuid from pathlib import PurePath from time import sleep from typing import cast from overrides import override from solidlsp import ls_types from solidlsp.ls import LanguageServerDependencyProvider, LSPFileBuffer, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_types import UnifiedSymbolInformation from solidlsp.ls_utils import FileUtils, PlatformUtils from solidlsp.lsp_protocol_handler.lsp_types import DocumentSymbol, InitializeParams, SymbolInformation from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @dataclasses.dataclass class RuntimeDependencyPaths: """ Stores the paths to the runtime dependencies of EclipseJDTLS """ gradle_path: str lombok_jar_path: str jre_path: str jre_home_path: str jdtls_launcher_jar_path: str jdtls_readonly_config_path: str intellicode_jar_path: str intellisense_members_path: str class EclipseJDTLS(SolidLanguageServer): r""" The EclipseJDTLS class provides a Java specific implementation of the LanguageServer class You can configure the following options in ls_specific_settings (in serena_config.yml): - maven_user_settings: Path to Maven settings.xml file (default: ~/.m2/settings.xml) - gradle_user_home: Path to Gradle user home directory (default: ~/.gradle) - gradle_wrapper_enabled: Whether to use the project's Gradle wrapper (default: false) - gradle_java_home: Path to JDK for Gradle (default: null, uses bundled JRE) - use_system_java_home: Whether to use the system's JAVA_HOME for JDTLS itself (default: false) Example configuration in ~/.serena/serena_config.yml: ```yaml ls_specific_settings: java: maven_user_settings: "/home/user/.m2/settings.xml" # Unix/Linux/Mac # maven_user_settings: 'C:\\Users\\YourName\\.m2\\settings.xml' # Windows (use single quotes!) gradle_user_home: "/home/user/.gradle" # Unix/Linux/Mac # gradle_user_home: 'C:\\Users\\YourName\\.gradle' # Windows (use single quotes!) gradle_wrapper_enabled: true # set to true for projects with custom plugins/repositories gradle_java_home: "/path/to/jdk" # set to override Gradle's JDK use_system_java_home: true # set to true to use system JAVA_HOME for JDTLS ``` """ def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a new EclipseJDTLS instance initializing the language server settings appropriately. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ super().__init__(config, repository_root_path, None, "java", solidlsp_settings) # Extract runtime_dependency_paths from the dependency provider assert isinstance(self._dependency_provider, self.DependencyProvider) self.runtime_dependency_paths = self._dependency_provider.runtime_dependency_paths self._service_ready_event = threading.Event() self._project_ready_event = threading.Event() self._intellicode_enable_command_available = threading.Event() def _create_dependency_provider(self) -> LanguageServerDependencyProvider: ls_resources_dir = self.ls_resources_dir(self._solidlsp_settings) return self.DependencyProvider(self._custom_settings, ls_resources_dir, self._solidlsp_settings, self.repository_root_path) @override def is_ignored_dirname(self, dirname: str) -> bool: # Ignore common Java build directories from different build tools: # - Maven: target # - Gradle: build, .gradle # - Eclipse: bin, .settings # - IntelliJ IDEA: out, .idea # - General: classes, dist, lib return super().is_ignored_dirname(dirname) or dirname in [ "target", # Maven "build", # Gradle "bin", # Eclipse "out", # IntelliJ IDEA "classes", # General "dist", # General "lib", # General ] class DependencyProvider(LanguageServerDependencyProvider): def __init__( self, custom_settings: SolidLSPSettings.CustomLSSettings, ls_resources_dir: str, solidlsp_settings: SolidLSPSettings, repository_root_path: str, ): super().__init__(custom_settings, ls_resources_dir) self._solidlsp_settings = solidlsp_settings self._repository_root_path = repository_root_path self.runtime_dependency_paths = self._setup_runtime_dependencies(ls_resources_dir) @classmethod def _setup_runtime_dependencies(cls, ls_resources_dir: str) -> RuntimeDependencyPaths: """ Setup runtime dependencies for EclipseJDTLS and return the paths. """ platformId = PlatformUtils.get_platform_id() runtime_dependencies = { "gradle": { "platform-agnostic": { "url": "https://services.gradle.org/distributions/gradle-8.14.2-bin.zip", "archiveType": "zip", "relative_extraction_path": ".", } }, "vscode-java": { "darwin-arm64": { "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-darwin-arm64-1.42.0-561.vsix", "archiveType": "zip", "relative_extraction_path": "vscode-java", }, "osx-arm64": { "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-darwin-arm64-1.42.0-561.vsix", "archiveType": "zip", "relative_extraction_path": "vscode-java", "jre_home_path": "extension/jre/21.0.7-macosx-aarch64", "jre_path": "extension/jre/21.0.7-macosx-aarch64/bin/java", "lombok_jar_path": "extension/lombok/lombok-1.18.36.jar", "jdtls_launcher_jar_path": "extension/server/plugins/org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar", "jdtls_readonly_config_path": "extension/server/config_mac_arm", }, "osx-x64": { "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-darwin-x64-1.42.0-561.vsix", "archiveType": "zip", "relative_extraction_path": "vscode-java", "jre_home_path": "extension/jre/21.0.7-macosx-x86_64", "jre_path": "extension/jre/21.0.7-macosx-x86_64/bin/java", "lombok_jar_path": "extension/lombok/lombok-1.18.36.jar", "jdtls_launcher_jar_path": "extension/server/plugins/org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar", "jdtls_readonly_config_path": "extension/server/config_mac", }, "linux-arm64": { "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-linux-arm64-1.42.0-561.vsix", "archiveType": "zip", "relative_extraction_path": "vscode-java", "jre_home_path": "extension/jre/21.0.7-linux-aarch64", "jre_path": "extension/jre/21.0.7-linux-aarch64/bin/java", "lombok_jar_path": "extension/lombok/lombok-1.18.36.jar", "jdtls_launcher_jar_path": "extension/server/plugins/org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar", "jdtls_readonly_config_path": "extension/server/config_linux_arm", }, "linux-x64": { "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-linux-x64-1.42.0-561.vsix", "archiveType": "zip", "relative_extraction_path": "vscode-java", "jre_home_path": "extension/jre/21.0.7-linux-x86_64", "jre_path": "extension/jre/21.0.7-linux-x86_64/bin/java", "lombok_jar_path": "extension/lombok/lombok-1.18.36.jar", "jdtls_launcher_jar_path": "extension/server/plugins/org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar", "jdtls_readonly_config_path": "extension/server/config_linux", }, "win-x64": { "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-win32-x64-1.42.0-561.vsix", "archiveType": "zip", "relative_extraction_path": "vscode-java", "jre_home_path": "extension/jre/21.0.7-win32-x86_64", "jre_path": "extension/jre/21.0.7-win32-x86_64/bin/java.exe", "lombok_jar_path": "extension/lombok/lombok-1.18.36.jar", "jdtls_launcher_jar_path": "extension/server/plugins/org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar", "jdtls_readonly_config_path": "extension/server/config_win", }, }, "intellicode": { "platform-agnostic": { "url": "https://VisualStudioExptTeam.gallery.vsassets.io/_apis/public/gallery/publisher/VisualStudioExptTeam/extension/vscodeintellicode/1.2.30/assetbyname/Microsoft.VisualStudio.Services.VSIXPackage", "alternate_url": "https://marketplace.visualstudio.com/_apis/public/gallery/publishers/VisualStudioExptTeam/vsextensions/vscodeintellicode/1.2.30/vspackage", "archiveType": "zip", "relative_extraction_path": "intellicode", "intellicode_jar_path": "extension/dist/com.microsoft.jdtls.intellicode.core-0.7.0.jar", "intellisense_members_path": "extension/dist/bundledModels/java_intellisense-members", } }, } gradle_path = str( PurePath( ls_resources_dir, "gradle-8.14.2", ) ) if not os.path.exists(gradle_path): FileUtils.download_and_extract_archive( runtime_dependencies["gradle"]["platform-agnostic"]["url"], str(PurePath(gradle_path).parent), runtime_dependencies["gradle"]["platform-agnostic"]["archiveType"], ) assert os.path.exists(gradle_path) dependency = runtime_dependencies["vscode-java"][platformId.value] vscode_java_path = str(PurePath(ls_resources_dir, dependency["relative_extraction_path"])) os.makedirs(vscode_java_path, exist_ok=True) jre_home_path = str(PurePath(vscode_java_path, dependency["jre_home_path"])) jre_path = str(PurePath(vscode_java_path, dependency["jre_path"])) lombok_jar_path = str(PurePath(vscode_java_path, dependency["lombok_jar_path"])) jdtls_launcher_jar_path = str(PurePath(vscode_java_path, dependency["jdtls_launcher_jar_path"])) jdtls_readonly_config_path = str(PurePath(vscode_java_path, dependency["jdtls_readonly_config_path"])) if not all( [ os.path.exists(vscode_java_path), os.path.exists(jre_home_path), os.path.exists(jre_path), os.path.exists(lombok_jar_path), os.path.exists(jdtls_launcher_jar_path), os.path.exists(jdtls_readonly_config_path), ] ): FileUtils.download_and_extract_archive(dependency["url"], vscode_java_path, dependency["archiveType"]) os.chmod(jre_path, 0o755) assert os.path.exists(vscode_java_path) assert os.path.exists(jre_home_path) assert os.path.exists(jre_path) assert os.path.exists(lombok_jar_path) assert os.path.exists(jdtls_launcher_jar_path) assert os.path.exists(jdtls_readonly_config_path) dependency = runtime_dependencies["intellicode"]["platform-agnostic"] intellicode_directory_path = str(PurePath(ls_resources_dir, dependency["relative_extraction_path"])) os.makedirs(intellicode_directory_path, exist_ok=True) intellicode_jar_path = str(PurePath(intellicode_directory_path, dependency["intellicode_jar_path"])) intellisense_members_path = str(PurePath(intellicode_directory_path, dependency["intellisense_members_path"])) if not all( [ os.path.exists(intellicode_directory_path), os.path.exists(intellicode_jar_path), os.path.exists(intellisense_members_path), ] ): FileUtils.download_and_extract_archive(dependency["url"], intellicode_directory_path, dependency["archiveType"]) assert os.path.exists(intellicode_directory_path) assert os.path.exists(intellicode_jar_path) assert os.path.exists(intellisense_members_path) return RuntimeDependencyPaths( gradle_path=gradle_path, lombok_jar_path=lombok_jar_path, jre_path=jre_path, jre_home_path=jre_home_path, jdtls_launcher_jar_path=jdtls_launcher_jar_path, jdtls_readonly_config_path=jdtls_readonly_config_path, intellicode_jar_path=intellicode_jar_path, intellisense_members_path=intellisense_members_path, ) def create_launch_command(self) -> list[str]: # ws_dir is the workspace directory for the EclipseJDTLS server ws_dir = str( PurePath( self._solidlsp_settings.ls_resources_dir, "EclipseJDTLS", "workspaces", uuid.uuid4().hex, ) ) # shared_cache_location is the global cache used by Eclipse JDTLS across all workspaces shared_cache_location = str(PurePath(self._solidlsp_settings.ls_resources_dir, "lsp", "EclipseJDTLS", "sharedIndex")) os.makedirs(shared_cache_location, exist_ok=True) os.makedirs(ws_dir, exist_ok=True) jre_path = self.runtime_dependency_paths.jre_path lombok_jar_path = self.runtime_dependency_paths.lombok_jar_path jdtls_launcher_jar = self.runtime_dependency_paths.jdtls_launcher_jar_path data_dir = str(PurePath(ws_dir, "data_dir")) jdtls_config_path = str(PurePath(ws_dir, "config_path")) jdtls_readonly_config_path = self.runtime_dependency_paths.jdtls_readonly_config_path if not os.path.exists(jdtls_config_path): shutil.copytree(jdtls_readonly_config_path, jdtls_config_path) for static_path in [ jre_path, lombok_jar_path, jdtls_launcher_jar, jdtls_config_path, jdtls_readonly_config_path, ]: assert os.path.exists(static_path), static_path cmd = [ jre_path, "--add-modules=ALL-SYSTEM", "--add-opens", "java.base/java.util=ALL-UNNAMED", "--add-opens", "java.base/java.lang=ALL-UNNAMED", "--add-opens", "java.base/sun.nio.fs=ALL-UNNAMED", "-Declipse.application=org.eclipse.jdt.ls.core.id1", "-Dosgi.bundles.defaultStartLevel=4", "-Declipse.product=org.eclipse.jdt.ls.core.product", "-Djava.import.generatesMetadataFilesAtProjectRoot=false", "-Dfile.encoding=utf8", "-noverify", "-XX:+UseParallelGC", "-XX:GCTimeRatio=4", "-XX:AdaptiveSizePolicyWeight=90", "-Dsun.zip.disableMemoryMapping=true", "-Djava.lsp.joinOnCompletion=true", "-Xmx3G", "-Xms100m", "-Xlog:disable", "-Dlog.level=ALL", f"-javaagent:{lombok_jar_path}", f"-Djdt.core.sharedIndexLocation={shared_cache_location}", "-jar", f"{jdtls_launcher_jar}", "-configuration", f"{jdtls_config_path}", "-data", f"{data_dir}", ] return cmd def create_launch_command_env(self) -> dict[str, str]: use_system_java_home = self._custom_settings.get("use_system_java_home", False) if use_system_java_home: system_java_home = os.environ.get("JAVA_HOME") if system_java_home: log.info(f"Using system JAVA_HOME for JDTLS: {system_java_home}") return {"syntaxserver": "false", "JAVA_HOME": system_java_home} else: log.warning("use_system_java_home is set but JAVA_HOME is not set in environment, falling back to bundled JRE") java_home = self.runtime_dependency_paths.jre_home_path log.info(f"Using bundled JRE for JDTLS: {java_home}") return {"syntaxserver": "false", "JAVA_HOME": java_home} def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: """ Returns the initialize parameters for the EclipseJDTLS server. """ # Look into https://github.com/eclipse/eclipse.jdt.ls/blob/master/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/preferences/Preferences.java to understand all the options available if not os.path.isabs(repository_absolute_path): repository_absolute_path = os.path.abspath(repository_absolute_path) repo_uri = pathlib.Path(repository_absolute_path).as_uri() # Load user's Maven and Gradle configuration paths from ls_specific_settings["java"] # Maven settings: default to ~/.m2/settings.xml default_maven_settings_path = os.path.join(os.path.expanduser("~"), ".m2", "settings.xml") custom_maven_settings_path = self._custom_settings.get("maven_user_settings") if custom_maven_settings_path is not None: # User explicitly provided a path if not os.path.exists(custom_maven_settings_path): error_msg = ( f"Provided maven settings file not found: {custom_maven_settings_path}. " f"Fix: create the file, update path in ~/.serena/serena_config.yml (ls_specific_settings -> java -> maven_user_settings), " f"or remove the setting to use default ({default_maven_settings_path})" ) log.error(error_msg) raise FileNotFoundError(error_msg) maven_settings_path = custom_maven_settings_path log.info(f"Using Maven settings from custom location: {maven_settings_path}") elif os.path.exists(default_maven_settings_path): maven_settings_path = default_maven_settings_path log.info(f"Using Maven settings from default location: {maven_settings_path}") else: maven_settings_path = None log.info(f"Maven settings not found at default location ({default_maven_settings_path}), will use JDTLS defaults") # Gradle user home: default to ~/.gradle default_gradle_home = os.path.join(os.path.expanduser("~"), ".gradle") custom_gradle_home = self._custom_settings.get("gradle_user_home") if custom_gradle_home is not None: # User explicitly provided a path if not os.path.exists(custom_gradle_home): error_msg = ( f"Gradle user home directory not found: {custom_gradle_home}. " f"Fix: create the directory, update path in ~/.serena/serena_config.yml (ls_specific_settings -> java -> gradle_user_home), " f"or remove the setting to use default (~/.gradle)" ) log.error(error_msg) raise FileNotFoundError(error_msg) gradle_user_home = custom_gradle_home log.info(f"Using Gradle user home from custom location: {gradle_user_home}") elif os.path.exists(default_gradle_home): gradle_user_home = default_gradle_home log.info(f"Using Gradle user home from default location: {gradle_user_home}") else: gradle_user_home = None log.info(f"Gradle user home not found at default location ({default_gradle_home}), will use JDTLS defaults") # Gradle wrapper: default to False to preserve existing behaviour gradle_wrapper_enabled = self._custom_settings.get("gradle_wrapper_enabled", False) log.info( f"Gradle wrapper {'enabled' if gradle_wrapper_enabled else 'disabled'} (configurable via ls_specific_settings -> java -> gradle_wrapper_enabled)" ) # Gradle Java home: default to None, which means the bundled JRE is used gradle_java_home = self._custom_settings.get("gradle_java_home") if gradle_java_home is not None: if not os.path.exists(gradle_java_home): error_msg = ( f"Gradle Java home not found: {gradle_java_home}. " f"Fix: update path in ~/.serena/serena_config.yml (ls_specific_settings -> java -> gradle_java_home), " f"or remove the setting to use the bundled JRE" ) log.error(error_msg) raise FileNotFoundError(error_msg) log.info(f"Using Gradle Java home from custom location: {gradle_java_home}") else: log.info(f"Using bundled JRE for Gradle: {self.runtime_dependency_paths.jre_path}") initialize_params = { "locale": "en", "rootPath": repository_absolute_path, "rootUri": pathlib.Path(repository_absolute_path).as_uri(), "capabilities": { "workspace": { "applyEdit": True, "workspaceEdit": { "documentChanges": True, "resourceOperations": ["create", "rename", "delete"], "failureHandling": "textOnlyTransactional", "normalizesLineEndings": True, "changeAnnotationSupport": {"groupsOnLabel": True}, }, "didChangeConfiguration": {"dynamicRegistration": True}, "didChangeWatchedFiles": {"dynamicRegistration": True, "relativePatternSupport": True}, "symbol": { "dynamicRegistration": True, "symbolKind": {"valueSet": list(range(1, 27))}, "tagSupport": {"valueSet": [1]}, "resolveSupport": {"properties": ["location.range"]}, }, "codeLens": {"refreshSupport": True}, "executeCommand": {"dynamicRegistration": True}, "configuration": True, "workspaceFolders": True, "semanticTokens": {"refreshSupport": True}, "fileOperations": { "dynamicRegistration": True, "didCreate": True, "didRename": True, "didDelete": True, "willCreate": True, "willRename": True, "willDelete": True, }, "inlineValue": {"refreshSupport": True}, "inlayHint": {"refreshSupport": True}, "diagnostics": {"refreshSupport": True}, }, "textDocument": { "publishDiagnostics": { "relatedInformation": True, "versionSupport": False, "tagSupport": {"valueSet": [1, 2]}, "codeDescriptionSupport": True, "dataSupport": True, }, "synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True}, # TODO: we have an assert that completion provider is not included in the capabilities at server startup # Removing this will cause the assert to fail. Investigate why this is the case, simplify config "completion": { "dynamicRegistration": True, "contextSupport": True, "completionItem": { "snippetSupport": False, "commitCharactersSupport": True, "documentationFormat": ["markdown", "plaintext"], "deprecatedSupport": True, "preselectSupport": True, "tagSupport": {"valueSet": [1]}, "insertReplaceSupport": False, "resolveSupport": {"properties": ["documentation", "detail", "additionalTextEdits"]}, "insertTextModeSupport": {"valueSet": [1, 2]}, "labelDetailsSupport": True, }, "insertTextMode": 2, "completionItemKind": { "valueSet": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25] }, "completionList": {"itemDefaults": ["commitCharacters", "editRange", "insertTextFormat", "insertTextMode"]}, }, "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, "signatureHelp": { "dynamicRegistration": True, "signatureInformation": { "documentationFormat": ["markdown", "plaintext"], "parameterInformation": {"labelOffsetSupport": True}, "activeParameterSupport": True, }, }, "definition": {"dynamicRegistration": True, "linkSupport": True}, "references": {"dynamicRegistration": True}, "documentSymbol": { "dynamicRegistration": True, "symbolKind": {"valueSet": list(range(1, 27))}, "hierarchicalDocumentSymbolSupport": True, "tagSupport": {"valueSet": [1]}, "labelSupport": True, }, "rename": { "dynamicRegistration": True, "prepareSupport": True, "prepareSupportDefaultBehavior": 1, "honorsChangeAnnotations": True, }, "documentLink": {"dynamicRegistration": True, "tooltipSupport": True}, "typeDefinition": {"dynamicRegistration": True, "linkSupport": True}, "implementation": {"dynamicRegistration": True, "linkSupport": True}, "colorProvider": {"dynamicRegistration": True}, "declaration": {"dynamicRegistration": True, "linkSupport": True}, "selectionRange": {"dynamicRegistration": True}, "callHierarchy": {"dynamicRegistration": True}, "semanticTokens": { "dynamicRegistration": True, "tokenTypes": [ "namespace", "type", "class", "enum", "interface", "struct", "typeParameter", "parameter", "variable", "property", "enumMember", "event", "function", "method", "macro", "keyword", "modifier", "comment", "string", "number", "regexp", "operator", "decorator", ], "tokenModifiers": [ "declaration", "definition", "readonly", "static", "deprecated", "abstract", "async", "modification", "documentation", "defaultLibrary", ], "formats": ["relative"], "requests": {"range": True, "full": {"delta": True}}, "multilineTokenSupport": False, "overlappingTokenSupport": False, "serverCancelSupport": True, "augmentsSyntaxTokens": True, }, "typeHierarchy": {"dynamicRegistration": True}, "inlineValue": {"dynamicRegistration": True}, "diagnostic": {"dynamicRegistration": True, "relatedDocumentSupport": False}, }, "general": { "staleRequestSupport": { "cancel": True, "retryOnContentModified": [ "textDocument/semanticTokens/full", "textDocument/semanticTokens/range", "textDocument/semanticTokens/full/delta", ], }, "regularExpressions": {"engine": "ECMAScript", "version": "ES2020"}, "positionEncodings": ["utf-16"], }, "notebookDocument": {"synchronization": {"dynamicRegistration": True, "executionSummarySupport": True}}, }, "initializationOptions": { "bundles": ["intellicode-core.jar"], "settings": { "java": { "home": None, "jdt": { "ls": { "java": {"home": None}, "vmargs": "-XX:+UseParallelGC -XX:GCTimeRatio=4 -XX:AdaptiveSizePolicyWeight=90 -Dsun.zip.disableMemoryMapping=true -Xmx1G -Xms100m -Xlog:disable", "lombokSupport": {"enabled": True}, "protobufSupport": {"enabled": True}, "androidSupport": {"enabled": True}, } }, "errors": {"incompleteClasspath": {"severity": "error"}}, "configuration": { "checkProjectSettingsExclusions": False, "updateBuildConfiguration": "interactive", "maven": { "userSettings": maven_settings_path, "globalSettings": None, "notCoveredPluginExecutionSeverity": "warning", "defaultMojoExecutionAction": "ignore", }, "workspaceCacheLimit": 90, "runtimes": [ {"name": "JavaSE-21", "path": "static/vscode-java/extension/jre/21.0.7-linux-x86_64", "default": True} ], }, "trace": {"server": "verbose"}, "import": { "maven": { "enabled": True, "offline": {"enabled": False}, "disableTestClasspathFlag": False, }, "gradle": { "enabled": True, "wrapper": {"enabled": gradle_wrapper_enabled}, "version": None, "home": "abs(static/gradle-7.3.3)", "offline": {"enabled": False}, "arguments": None, "jvmArguments": None, "user": {"home": gradle_user_home}, "annotationProcessing": {"enabled": True}, }, "exclusions": [ "**/node_modules/**", "**/.metadata/**", "**/archetype-resources/**", "**/META-INF/maven/**", ], "generatesMetadataFilesAtProjectRoot": False, }, # Set updateSnapshots to False to improve performance and avoid unnecessary network calls # Snapshots will only be updated when explicitly requested by the user "maven": {"downloadSources": True, "updateSnapshots": False}, "eclipse": {"downloadSources": True}, "signatureHelp": {"enabled": True, "description": {"enabled": True}}, "hover": {"javadoc": {"enabled": True}}, "implementationsCodeLens": {"enabled": True}, "format": { "enabled": True, "settings": {"url": None, "profile": None}, "comments": {"enabled": True}, "onType": {"enabled": True}, "insertSpaces": True, "tabSize": 4, }, "saveActions": {"organizeImports": False}, "project": { "referencedLibraries": ["lib/**/*.jar"], "importOnFirstTimeStartup": "automatic", "importHint": True, "resourceFilters": ["node_modules", "\\.git"], "encoding": "ignore", "exportJar": {"targetPath": "${workspaceFolder}/${workspaceFolderBasename}.jar"}, }, "contentProvider": {"preferred": None}, "autobuild": {"enabled": True}, "maxConcurrentBuilds": 1, "selectionRange": {"enabled": True}, "showBuildStatusOnStart": {"enabled": "notification"}, "server": {"launchMode": "Standard"}, "sources": {"organizeImports": {"starThreshold": 99, "staticStarThreshold": 99}}, "imports": {"gradle": {"wrapper": {"checksums": []}}}, "templates": {"fileHeader": [], "typeComment": []}, "references": {"includeAccessors": True, "includeDecompiledSources": True}, "typeHierarchy": {"lazyLoad": False}, "settings": {"url": None}, "symbols": {"includeSourceMethodDeclarations": False}, "inlayHints": {"parameterNames": {"enabled": "literals", "exclusions": []}}, "codeAction": {"sortMembers": {"avoidVolatileChanges": True}}, "compile": { "nullAnalysis": { "nonnull": [ "javax.annotation.Nonnull", "org.eclipse.jdt.annotation.NonNull", "org.springframework.lang.NonNull", ], "nullable": [ "javax.annotation.Nullable", "org.eclipse.jdt.annotation.Nullable", "org.springframework.lang.Nullable", ], "mode": "automatic", } }, "sharedIndexes": {"enabled": "auto", "location": ""}, "silentNotification": False, "dependency": { "showMembers": False, "syncWithFolderExplorer": True, "autoRefresh": True, "refreshDelay": 2000, "packagePresentation": "flat", }, "help": {"firstView": "auto", "showReleaseNotes": True, "collectErrorLog": False}, "test": {"defaultConfig": "", "config": {}}, } }, }, "trace": "verbose", "processId": os.getpid(), "workspaceFolders": [ { "uri": repo_uri, "name": os.path.basename(repository_absolute_path), } ], } initialize_params["initializationOptions"]["workspaceFolders"] = [repo_uri] # type: ignore bundles = [self.runtime_dependency_paths.intellicode_jar_path] initialize_params["initializationOptions"]["bundles"] = bundles # type: ignore initialize_params["initializationOptions"]["settings"]["java"]["configuration"]["runtimes"] = [ # type: ignore {"name": "JavaSE-21", "path": self.runtime_dependency_paths.jre_home_path, "default": True} ] for runtime in initialize_params["initializationOptions"]["settings"]["java"]["configuration"]["runtimes"]: # type: ignore assert "name" in runtime assert "path" in runtime assert os.path.exists(runtime["path"]), f"Runtime required for eclipse_jdtls at path {runtime['path']} does not exist" gradle_settings = initialize_params["initializationOptions"]["settings"]["java"]["import"]["gradle"] # type: ignore gradle_settings["home"] = self.runtime_dependency_paths.gradle_path gradle_settings["java"] = {"home": gradle_java_home if gradle_java_home is not None else self.runtime_dependency_paths.jre_path} return cast(InitializeParams, initialize_params) def _start_server(self) -> None: """ Starts the Eclipse JDTLS Language Server """ def register_capability_handler(params: dict) -> None: assert "registrations" in params for registration in params["registrations"]: if registration["method"] == "textDocument/completion": assert registration["registerOptions"]["resolveProvider"] == True assert registration["registerOptions"]["triggerCharacters"] == [ ".", "@", "#", "*", " ", ] if registration["method"] == "workspace/executeCommand": if "java.intellicode.enable" in registration["registerOptions"]["commands"]: self._intellicode_enable_command_available.set() return def lang_status_handler(params: dict) -> None: log.info("Language status update: %s", params) if params["type"] == "ServiceReady" and params["message"] == "ServiceReady": self._service_ready_event.set() if params["type"] == "ProjectStatus": if params["message"] == "OK": self._project_ready_event.set() def execute_client_command_handler(params: dict) -> list: assert params["command"] == "_java.reloadBundles.command" assert params["arguments"] == [] return [] def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") def do_nothing(params: dict) -> None: return self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_notification("language/status", lang_status_handler) self.server.on_notification("window/logMessage", window_log_message) self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) self.server.on_notification("language/actionableNotification", do_nothing) log.info("Starting EclipseJDTLS server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) assert init_response["capabilities"]["textDocumentSync"]["change"] == 2 # type: ignore assert "completionProvider" not in init_response["capabilities"] assert "executeCommandProvider" not in init_response["capabilities"] self.server.notify.initialized({}) self.server.notify.workspace_did_change_configuration({"settings": initialize_params["initializationOptions"]["settings"]}) # type: ignore self._intellicode_enable_command_available.wait() java_intellisense_members_path = self.runtime_dependency_paths.intellisense_members_path assert os.path.exists(java_intellisense_members_path) intellicode_enable_result = self.server.send.execute_command( { "command": "java.intellicode.enable", "arguments": [True, java_intellisense_members_path], } ) assert intellicode_enable_result if not self._service_ready_event.is_set(): log.info("Waiting for service to be ready ...") self._service_ready_event.wait() log.info("Service is ready") if not self._project_ready_event.is_set(): log.info("Waiting for project to be ready ...") project_ready_timeout = 20 # Hotfix: Using timeout until we figure out why sometimes we don't get the project ready event if self._project_ready_event.wait(timeout=project_ready_timeout): log.info("Project is ready") else: log.warning("Did not receive project ready status within %d seconds; proceeding anyway", project_ready_timeout) else: log.info("Project is ready") log.info("Startup complete") @override def _request_hover(self, file_buffer: LSPFileBuffer, line: int, column: int) -> ls_types.Hover | None: # Eclipse JDTLS lazily loads javadocs on first hover request, then caches them. # This means the first request often returns incomplete info (just the signature), # while subsequent requests return the full javadoc. # # The response format also differs based on javadoc presence: # - contents: list[...] when javadoc IS present (preferred, richer format) # - contents: {value: info} when javadoc is NOT present # # There's no LSP signal for "javadoc fully loaded" and no way to request # hover with "wait for complete info". The retry approach is the only viable # workaround - we keep requesting until we get the richer list format or # the content stops growing. # # The file is kept open by the caller (request_hover), so retries are cheap # and don't cause repeated didOpen/didClose cycles. def content_score(result: ls_types.Hover | None) -> tuple[int, int]: """Return (format_priority, length) for comparison. Higher is better.""" if result is None: return (0, 0) contents = result["contents"] if isinstance(contents, list): return (2, len(contents)) # List format (has javadoc) is best elif isinstance(contents, dict): return (1, len(contents.get("value", ""))) else: return (1, len(contents)) max_retries = 5 best_result = super()._request_hover(file_buffer, line, column) best_score = content_score(best_result) for _ in range(max_retries): sleep(0.05) new_result = super()._request_hover(file_buffer, line, column) new_score = content_score(new_result) if new_score > best_score: best_result = new_result best_score = new_score return best_result def _request_document_symbols( self, relative_file_path: str, file_data: LSPFileBuffer | None ) -> list[SymbolInformation] | list[DocumentSymbol] | None: result = super()._request_document_symbols(relative_file_path, file_data=file_data) if result is None: return None # JDTLS sometimes returns symbol names with type information to handle overloads, # e.g. "myMethod(int) ", but we want overloads to be handled via overload_idx, # which requires the name to be just "myMethod". def fix_name(symbol: SymbolInformation | DocumentSymbol | UnifiedSymbolInformation) -> None: if "(" in symbol["name"]: symbol["name"] = symbol["name"][: symbol["name"].index("(")] children = symbol.get("children") if children: for child in children: # type: ignore fix_name(child) for root_symbol in result: fix_name(root_symbol) return result ================================================ FILE: src/solidlsp/language_servers/elixir_tools/README.md ================================================ # Elixir Language Server Integration This directory contains the integration for Elixir language support using [Expert](https://github.com/elixir-lang/expert), the official Elixir language server. ## Prerequisites Before using the Elixir language server integration, you need to have: 1. **Elixir** installed and available in your PATH - Install from: https://elixir-lang.org/install.html - Verify with: `elixir --version` 2. **Expert** (optional - will be downloaded automatically if not found) - Expert binaries are automatically downloaded from GitHub releases - Manual installation: https://github.com/elixir-lang/expert#installation - If installed manually, ensure `expert` is in your PATH ## Features The Elixir integration provides: - **Language Server Protocol (LSP) support** via Next LS - **File extension recognition** for `.ex` and `.exs` files - **Project structure awareness** with proper handling of Elixir-specific directories: - `_build/` - Compiled artifacts (ignored) - `deps/` - Dependencies (ignored) - `.elixir_ls/` - ElixirLS artifacts (ignored) - `cover/` - Coverage reports (ignored) - `lib/` - Source code (not ignored) - `test/` - Test files (not ignored) ## Configuration The integration uses the default Expert configuration with: - **MIX_ENV**: `dev` - **MIX_TARGET**: `host` - **Experimental completions**: Disabled by default - **Credo extension**: Enabled by default ### Version Management (asdf) Expert automatically respects project-specific Elixir versions when using asdf: - If a `.tool-versions` file exists in the project root, Expert will use the specified Elixir version - Expert is launched from the project directory, allowing it to pick up project configuration - No additional configuration needed - just ensure asdf is installed and the project has a `.tool-versions` file ## Usage The Elixir language server is automatically selected when working with Elixir projects. It will be used for: - Code completion - Go to definition - Find references - Document symbols - Hover information - Code formatting - Diagnostics (via Credo integration) ### Important: Project Compilation Expert requires your Elixir project to be **compiled** for optimal performance, especially for: - Cross-file reference resolution - Complete symbol information - Accurate go-to-definition **For production use**: Ensure your project is compiled with `mix compile` before using the language server. **For testing**: The test suite automatically compiles the test repositories before running tests to ensure optimal Expert performance. ## Testing Run the Elixir-specific tests with: ```bash pytest test/solidlsp/elixir/ -m elixir ``` ## Implementation Details - **Main class**: `ElixirTools` in `elixir_tools.py` - **Language identifier**: `"elixir"` - **Command**: `expert --stdio` - **Supported platforms**: Linux (x64, arm64), macOS (x64, arm64), Windows (x64, arm64) - **Binary distribution**: Downloaded from [GitHub releases](https://github.com/elixir-lang/expert/releases) The implementation follows the same patterns as other language servers in this project, inheriting from `SolidLanguageServer` and providing Elixir-specific configuration and behavior. ================================================ FILE: src/solidlsp/language_servers/elixir_tools/__init__.py ================================================ ================================================ FILE: src/solidlsp/language_servers/elixir_tools/elixir_tools.py ================================================ import logging import os import pathlib import stat import subprocess import threading from typing import Any, cast from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_utils import FileUtils, PlatformId, PlatformUtils from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings from ..common import RuntimeDependency log = logging.getLogger(__name__) class ElixirTools(SolidLanguageServer): """ Provides Elixir specific instantiation of the LanguageServer class using Expert, the official Elixir language server. """ @override def _get_wait_time_for_cross_file_referencing(self) -> float: return 10.0 # Elixir projects need time to compile and index before cross-file references work @override def is_ignored_dirname(self, dirname: str) -> bool: # For Elixir projects, we should ignore: # - _build: compiled artifacts # - deps: dependencies # - node_modules: if the project has JavaScript components # - .elixir_ls: ElixirLS artifacts (in case both are present) # - cover: coverage reports # - .expert: Expert artifacts return super().is_ignored_dirname(dirname) or dirname in ["_build", "deps", "node_modules", ".elixir_ls", ".expert", "cover"] @override def is_ignored_path(self, relative_path: str, ignore_unsupported_files: bool = True) -> bool: """Check if a path should be ignored for symbol indexing.""" if relative_path.endswith("mix.exs"): # These are project configuration files, not source code with symbols to index return True return super().is_ignored_path(relative_path, ignore_unsupported_files) @classmethod def _get_elixir_version(cls) -> str | None: """Get the installed Elixir version or None if not found.""" try: result = subprocess.run(["elixir", "--version"], capture_output=True, text=True, check=False) if result.returncode == 0: return result.stdout.strip() except FileNotFoundError: return None return None @classmethod def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> str: """ Setup runtime dependencies for Expert. Downloads the Expert binary for the current platform and returns the path to the executable. """ # Check if Elixir is available first elixir_version = cls._get_elixir_version() if not elixir_version: raise RuntimeError( "Elixir is not installed. Please install Elixir from https://elixir-lang.org/install.html and make sure it is added to your PATH." ) log.info(f"Found Elixir: {elixir_version}") # First, check if expert is already in PATH (user may have installed it manually) import shutil expert_in_path = shutil.which("expert") if expert_in_path: log.info(f"Found Expert in PATH: {expert_in_path}") return expert_in_path platform_id = PlatformUtils.get_platform_id() valid_platforms = [ PlatformId.LINUX_x64, PlatformId.LINUX_arm64, PlatformId.OSX_x64, PlatformId.OSX_arm64, PlatformId.WIN_x64, PlatformId.WIN_arm64, ] assert platform_id in valid_platforms, f"Platform {platform_id} is not supported for Expert at the moment" expert_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "expert") EXPERT_VERSION = "nightly" # Define runtime dependencies inline runtime_deps = { PlatformId.LINUX_x64: RuntimeDependency( id="expert_linux_amd64", platform_id="linux-x64", url=f"https://github.com/elixir-lang/expert/releases/download/{EXPERT_VERSION}/expert_linux_amd64", archive_type="binary", binary_name="expert_linux_amd64", extract_path="expert", ), PlatformId.LINUX_arm64: RuntimeDependency( id="expert_linux_arm64", platform_id="linux-arm64", url=f"https://github.com/elixir-lang/expert/releases/download/{EXPERT_VERSION}/expert_linux_arm64", archive_type="binary", binary_name="expert_linux_arm64", extract_path="expert", ), PlatformId.OSX_x64: RuntimeDependency( id="expert_darwin_amd64", platform_id="osx-x64", url=f"https://github.com/elixir-lang/expert/releases/download/{EXPERT_VERSION}/expert_darwin_amd64", archive_type="binary", binary_name="expert_darwin_amd64", extract_path="expert", ), PlatformId.OSX_arm64: RuntimeDependency( id="expert_darwin_arm64", platform_id="osx-arm64", url=f"https://github.com/elixir-lang/expert/releases/download/{EXPERT_VERSION}/expert_darwin_arm64", archive_type="binary", binary_name="expert_darwin_arm64", extract_path="expert", ), PlatformId.WIN_x64: RuntimeDependency( id="expert_windows_amd64", platform_id="win-x64", url=f"https://github.com/elixir-lang/expert/releases/download/{EXPERT_VERSION}/expert_windows_amd64.exe", archive_type="binary", binary_name="expert_windows_amd64.exe", extract_path="expert.exe", ), PlatformId.WIN_arm64: RuntimeDependency( id="expert_windows_arm64", platform_id="win-arm64", url=f"https://github.com/elixir-lang/expert/releases/download/{EXPERT_VERSION}/expert_windows_arm64.exe", archive_type="binary", binary_name="expert_windows_arm64.exe", extract_path="expert.exe", ), } dependency = runtime_deps[platform_id] # On Windows, use .exe extension executable_name = "expert.exe" if platform_id.value.startswith("win") else "expert" executable_path = os.path.join(expert_dir, executable_name) assert dependency.binary_name is not None binary_path = os.path.join(expert_dir, dependency.binary_name) if not os.path.exists(executable_path): log.info(f"Downloading Expert binary from {dependency.url}") assert dependency.url is not None FileUtils.download_file(dependency.url, binary_path) # Make the binary executable on Unix-like systems if not platform_id.value.startswith("win"): os.chmod(binary_path, stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH) # Create a symlink with the expected name on Unix-like systems if binary_path != executable_path and not platform_id.value.startswith("win"): if os.path.exists(executable_path): os.remove(executable_path) os.symlink(os.path.basename(binary_path), executable_path) assert os.path.exists(executable_path), f"Expert executable not found at {executable_path}" log.info(f"Expert binary ready at: {executable_path}") return executable_path def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): expert_executable_path = self._setup_runtime_dependencies(config, solidlsp_settings) super().__init__( config, repository_root_path, ProcessLaunchInfo(cmd=f"{expert_executable_path} --stdio", cwd=repository_root_path), "elixir", solidlsp_settings, ) self.server_ready = threading.Event() self.request_id = 0 # Set generous timeout for Expert which can be slow to initialize and respond self.set_request_timeout(180.0) @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the Expert Language Server. """ # Ensure the path is absolute abs_path = os.path.abspath(repository_absolute_path) root_uri = pathlib.Path(abs_path).as_uri() initialize_params = { "processId": os.getpid(), "locale": "en", "rootPath": abs_path, "rootUri": root_uri, "initializationOptions": { "mix_env": "dev", "mix_target": "host", "experimental": {"completions": {"enable": False}}, "extensions": {"credo": {"enable": True, "cli_options": []}}, }, "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, "completion": { "dynamicRegistration": True, "completionItem": {"snippetSupport": True, "documentationFormat": ["markdown", "plaintext"]}, }, "definition": {"dynamicRegistration": True}, "references": {"dynamicRegistration": True}, "documentSymbol": { "dynamicRegistration": True, "hierarchicalDocumentSymbolSupport": True, "symbolKind": {"valueSet": list(range(1, 27))}, }, "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, "formatting": {"dynamicRegistration": True}, "codeAction": { "dynamicRegistration": True, "codeActionLiteralSupport": { "codeActionKind": { "valueSet": [ "quickfix", "refactor", "refactor.extract", "refactor.inline", "refactor.rewrite", "source", "source.organizeImports", ] } }, }, }, "workspace": { "workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}, "executeCommand": {"dynamicRegistration": True}, }, "window": { "showMessage": {"messageActionItem": {"additionalPropertiesSupport": True}}, "showDocument": {"support": True}, "workDoneProgress": True, }, }, "workspaceFolders": [{"uri": root_uri, "name": os.path.basename(repository_absolute_path)}], } return cast(InitializeParams, initialize_params) def _start_server(self) -> None: """Start Expert server process""" def register_capability_handler(params: Any) -> None: log.debug(f"LSP: client/registerCapability: {params}") return def window_log_message(msg: Any) -> None: """Handle window/logMessage notifications from Expert""" message_type = msg.get("type", 4) # 1=Error, 2=Warning, 3=Info, 4=Log message_text = msg.get("message", "") # Log at appropriate level based on message type if message_type == 1: log.error(f"Expert: {message_text}") elif message_type == 2: log.warning(f"Expert: {message_text}") else: log.debug(f"Expert: {message_text}") def check_server_ready(params: Any) -> None: """ Handle $/progress notifications from Expert. Expert sends progress updates during compilation and indexing. The server is considered ready when project build completes. """ value = params.get("value", {}) kind = value.get("kind", "") title = value.get("title", "") if kind == "begin": # Track when building the project starts (not "Building engine") if title.startswith("Building ") and not title.startswith("Building engine"): self._building_project = True elif kind == "end": # Project build completion is the main readiness signal if getattr(self, "_building_project", False): log.debug("Expert project build completed - server is ready") self._building_project = False self.server_ready.set() def work_done_progress_create(params: Any) -> None: """Handle window/workDoneProgress/create requests from Expert.""" return def publish_diagnostics(params: Any) -> None: """Handle textDocument/publishDiagnostics notifications.""" return self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_notification("window/logMessage", window_log_message) self.server.on_notification("$/progress", check_server_ready) self.server.on_request("window/workDoneProgress/create", work_done_progress_create) self.server.on_notification("textDocument/publishDiagnostics", publish_diagnostics) log.debug("Starting Expert server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.debug("Sending initialize request to Expert") init_response = self.server.send.initialize(initialize_params) # Verify basic server capabilities assert "textDocumentSync" in init_response["capabilities"], f"Missing textDocumentSync in {init_response['capabilities']}" self.server.notify.initialized({}) # Expert needs time to compile the project and build indexes on first run. # This can take 2-3+ minutes for mid-sized codebases. # After the first run, subsequent startups are much faster. ready_timeout = 300.0 # 5 minutes log.debug(f"Waiting up to {ready_timeout}s for Expert to compile and index...") if self.server_ready.wait(timeout=ready_timeout): log.debug("Expert is ready for requests") else: log.warning(f"Expert did not signal readiness within {ready_timeout}s. Proceeding with requests anyway.") self.server_ready.set() # Mark as ready anyway to allow requests ================================================ FILE: src/solidlsp/language_servers/elm_language_server.py ================================================ """ Provides Elm specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Elm. """ import logging import os import pathlib import shutil import threading from overrides import override from sensai.util.logging import LogTime from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings from .common import RuntimeDependency, RuntimeDependencyCollection log = logging.getLogger(__name__) class ElmLanguageServer(SolidLanguageServer): """ Provides Elm specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Elm. """ def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates an ElmLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ elm_lsp_executable_path = self._setup_runtime_dependencies(config, solidlsp_settings) # Resolve ELM_HOME to absolute path if it's set to a relative path env = {} elm_home = os.environ.get("ELM_HOME") if elm_home: if not os.path.isabs(elm_home): # Convert relative ELM_HOME to absolute based on repository root elm_home = os.path.abspath(os.path.join(repository_root_path, elm_home)) env["ELM_HOME"] = elm_home log.info(f"Using ELM_HOME: {elm_home}") super().__init__( config, repository_root_path, ProcessLaunchInfo(cmd=elm_lsp_executable_path, cwd=repository_root_path, env=env), "elm", solidlsp_settings, ) @override def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in [ "elm-stuff", "node_modules", "dist", "build", ] @classmethod def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> list[str]: """ Setup runtime dependencies for Elm Language Server and return the command to start the server. """ # Check if elm-language-server is already installed globally system_elm_ls = shutil.which("elm-language-server") if system_elm_ls: log.info(f"Found system-installed elm-language-server at {system_elm_ls}") return [system_elm_ls, "--stdio"] # Verify node and npm are installed is_node_installed = shutil.which("node") is not None assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." is_npm_installed = shutil.which("npm") is not None assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." deps = RuntimeDependencyCollection( [ RuntimeDependency( id="elm-language-server", description="@elm-tooling/elm-language-server package", command=["npm", "install", "--prefix", "./", "@elm-tooling/elm-language-server@2.8.0"], platform_id="any", ), ] ) # Install elm-language-server if not already installed elm_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "elm-lsp") elm_ls_executable_path = os.path.join(elm_ls_dir, "node_modules", ".bin", "elm-language-server") if not os.path.exists(elm_ls_executable_path): log.info(f"Elm Language Server executable not found at {elm_ls_executable_path}. Installing...") with LogTime("Installation of Elm language server dependencies", logger=log): deps.install(elm_ls_dir) if not os.path.exists(elm_ls_executable_path): raise FileNotFoundError( f"elm-language-server executable not found at {elm_ls_executable_path}, something went wrong with the installation." ) return [elm_ls_executable_path, "--stdio"] @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the Elm Language Server. """ root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, "definition": {"dynamicRegistration": True}, "references": {"dynamicRegistration": True}, "documentSymbol": { "dynamicRegistration": True, "hierarchicalDocumentSymbolSupport": True, "symbolKind": {"valueSet": list(range(1, 27))}, }, "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, "codeAction": {"dynamicRegistration": True}, "rename": {"dynamicRegistration": True}, }, "workspace": { "workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}, "symbol": {"dynamicRegistration": True}, }, }, "initializationOptions": { "elmPath": shutil.which("elm") or "elm", "elmFormatPath": shutil.which("elm-format") or "elm-format", "elmTestPath": shutil.which("elm-test") or "elm-test", "skipInstallPackageConfirmation": True, "onlyUpdateDiagnosticsOnSave": False, }, "processId": os.getpid(), "rootPath": repository_absolute_path, "rootUri": root_uri, "workspaceFolders": [ { "uri": root_uri, "name": os.path.basename(repository_absolute_path), } ], } return initialize_params # type: ignore[return-value] def _start_server(self) -> None: """ Starts the Elm Language Server, waits for the server to be ready and yields the LanguageServer instance. """ workspace_ready = threading.Event() def do_nothing(params: dict) -> None: return def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") def on_diagnostics(params: dict) -> None: # Receiving diagnostics indicates the workspace has been scanned log.info("LSP: Received diagnostics notification, workspace is ready") workspace_ready.set() self.server.on_notification("window/logMessage", window_log_message) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", on_diagnostics) log.info("Starting Elm server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) # Elm-specific capability checks assert "textDocumentSync" in init_response["capabilities"] assert "completionProvider" in init_response["capabilities"] assert "definitionProvider" in init_response["capabilities"] assert "referencesProvider" in init_response["capabilities"] assert "documentSymbolProvider" in init_response["capabilities"] self.server.notify.initialized({}) log.info("Elm server initialized, waiting for workspace scan...") # Wait for workspace to be scanned (indicated by receiving diagnostics) if workspace_ready.wait(timeout=30.0): log.info("Elm server workspace scan completed") else: log.warning("Timeout waiting for Elm workspace scan, proceeding anyway") log.info("Elm server ready") @override def _get_wait_time_for_cross_file_referencing(self) -> float: return 1.0 ================================================ FILE: src/solidlsp/language_servers/erlang_language_server.py ================================================ """Erlang Language Server implementation using Erlang LS.""" import logging import os import shutil import subprocess import threading import time from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) class ErlangLanguageServer(SolidLanguageServer): """Language server for Erlang using Erlang LS.""" def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates an ErlangLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ self.erlang_ls_path = shutil.which("erlang_ls") if not self.erlang_ls_path: raise RuntimeError("Erlang LS not found. Install from: https://github.com/erlang-ls/erlang_ls") if not self._check_erlang_installation(): raise RuntimeError("Erlang/OTP not found. Install from: https://www.erlang.org/downloads") super().__init__( config, repository_root_path, ProcessLaunchInfo(cmd=[self.erlang_ls_path, "--transport", "stdio"], cwd=repository_root_path), "erlang", solidlsp_settings, ) # Add server readiness tracking like Elixir self.server_ready = threading.Event() # Set generous timeout for Erlang LS initialization self.set_request_timeout(120.0) def _check_erlang_installation(self) -> bool: """Check if Erlang/OTP is available.""" try: result = subprocess.run(["erl", "-version"], check=False, capture_output=True, text=True, timeout=10) return result.returncode == 0 except (subprocess.SubprocessError, FileNotFoundError): return False @classmethod def _get_erlang_version(cls) -> str | None: """Get the installed Erlang/OTP version or None if not found.""" try: result = subprocess.run(["erl", "-version"], check=False, capture_output=True, text=True, timeout=10) if result.returncode == 0: return result.stderr.strip() # erl -version outputs to stderr except (subprocess.SubprocessError, FileNotFoundError): return None return None @classmethod def _check_rebar3_available(cls) -> bool: """Check if rebar3 build tool is available.""" try: result = subprocess.run(["rebar3", "version"], check=False, capture_output=True, text=True, timeout=10) return result.returncode == 0 except (subprocess.SubprocessError, FileNotFoundError): return False def _start_server(self) -> None: """Start Erlang LS server process with proper initialization waiting.""" def register_capability_handler(params: dict) -> None: return def window_log_message(msg: dict) -> None: """Handle window/logMessage notifications from Erlang LS""" message_text = msg.get("message", "") log.info(f"LSP: window/logMessage: {message_text}") # Look for Erlang LS readiness signals # Common patterns: "Started Erlang LS", "initialized", "ready" readiness_signals = [ "Started Erlang LS", "server started", "initialized", "ready to serve requests", "compilation finished", "indexing complete", ] message_lower = message_text.lower() for signal in readiness_signals: if signal.lower() in message_lower: log.info(f"Erlang LS readiness signal detected: {message_text}") self.server_ready.set() break def do_nothing(params: dict) -> None: return def check_server_ready(params: dict) -> None: """Handle $/progress notifications from Erlang LS as fallback.""" value = params.get("value", {}) # Check for initialization completion progress if value.get("kind") == "end": message = value.get("message", "") if any(word in message.lower() for word in ["initialized", "ready", "complete"]): log.info("Erlang LS initialization progress completed") # Set as fallback if no window/logMessage was received if not self.server_ready.is_set(): self.server_ready.set() # Set up notification handlers self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_notification("window/logMessage", window_log_message) self.server.on_notification("$/progress", check_server_ready) self.server.on_notification("window/workDoneProgress/create", do_nothing) self.server.on_notification("$/workDoneProgress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) log.info("Starting Erlang LS server process") self.server.start() # Send initialize request initialize_params = { "processId": None, "rootPath": self.repository_root_path, "rootUri": f"file://{self.repository_root_path}", "capabilities": { "textDocument": { "synchronization": {"didSave": True}, "completion": {"dynamicRegistration": True}, "definition": {"dynamicRegistration": True}, "references": {"dynamicRegistration": True}, "documentSymbol": {"dynamicRegistration": True}, "hover": {"dynamicRegistration": True}, } }, } log.info("Sending initialize request to Erlang LS") init_response = self.server.send.initialize(initialize_params) # type: ignore[arg-type] # Verify server capabilities if "capabilities" in init_response: log.info(f"Erlang LS capabilities: {list(init_response['capabilities'].keys())}") self.server.notify.initialized({}) # Wait for Erlang LS to be ready - adjust timeout based on environment is_ci = os.getenv("CI") == "true" or os.getenv("GITHUB_ACTIONS") == "true" is_macos = os.uname().sysname == "Darwin" if hasattr(os, "uname") else False # macOS in CI can be particularly slow for language server startup if is_ci and is_macos: ready_timeout = 240.0 # 4 minutes for macOS CI env_desc = "macOS CI" elif is_ci: ready_timeout = 180.0 # 3 minutes for other CI env_desc = "CI" else: ready_timeout = 60.0 # 1 minute for local env_desc = "local" log.info(f"Waiting up to {ready_timeout} seconds for Erlang LS readiness ({env_desc} environment)...") if self.server_ready.wait(timeout=ready_timeout): log.info("Erlang LS is ready and available for requests") # Add settling period for indexing - adjust based on environment settling_time = 15.0 if is_ci else 5.0 log.info(f"Allowing {settling_time} seconds for Erlang LS indexing to complete...") time.sleep(settling_time) log.info("Erlang LS settling period complete") else: # Set ready anyway and continue - Erlang LS might not send explicit ready messages log.warning(f"Erlang LS readiness timeout reached after {ready_timeout}s, proceeding anyway (common in CI)") self.server_ready.set() # Still give some time for basic initialization even without explicit readiness signal basic_settling_time = 20.0 if is_ci else 10.0 log.info(f"Allowing {basic_settling_time} seconds for basic Erlang LS initialization...") time.sleep(basic_settling_time) log.info("Basic Erlang LS initialization period complete") @override def is_ignored_dirname(self, dirname: str) -> bool: # For Erlang projects, we should ignore: # - _build: rebar3 build artifacts # - deps: dependencies # - ebin: compiled beam files # - .rebar3: rebar3 cache # - logs: log files # - node_modules: if the project has JavaScript components return super().is_ignored_dirname(dirname) or dirname in [ "_build", "deps", "ebin", ".rebar3", "logs", "node_modules", "_checkouts", "cover", ] def is_ignored_filename(self, filename: str) -> bool: """Check if a filename should be ignored.""" # Ignore compiled BEAM files if filename.endswith(".beam"): return True # Don't ignore Erlang source files, header files, or configuration files return False ================================================ FILE: src/solidlsp/language_servers/fortran_language_server.py ================================================ """ Fortran Language Server implementation using fortls. """ import logging import os import pathlib import re import shutil from overrides import override from solidlsp import ls_types from solidlsp.ls import DocumentSymbols, LSPFileBuffer, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) class FortranLanguageServer(SolidLanguageServer): """Fortran Language Server implementation using fortls.""" @override def _get_wait_time_for_cross_file_referencing(self) -> float: return 3.0 # fortls needs time for workspace indexing @override def is_ignored_dirname(self, dirname: str) -> bool: # For Fortran projects, ignore common build directories return super().is_ignored_dirname(dirname) or dirname in [ "build", "Build", "BUILD", "bin", "lib", "mod", # Module files directory "obj", # Object files directory ".cmake", "CMakeFiles", ] def _fix_fortls_selection_range( self, symbol: ls_types.UnifiedSymbolInformation, file_content: str ) -> ls_types.UnifiedSymbolInformation: """ Fix fortls's incorrect selectionRange that points to line start instead of identifier name. fortls bug: selectionRange.start.character is 0 (line start) but should point to the function/subroutine/module/program name position. This breaks MCP server features that rely on the exact identifier position for finding references. Args: symbol: The symbol with potentially incorrect selectionRange file_content: Full file content to parse the line Returns: Symbol with corrected selectionRange pointing to the identifier name """ if "selectionRange" not in symbol: return symbol sel_range = symbol["selectionRange"] start_line = sel_range["start"]["line"] start_char = sel_range["start"]["character"] # Split file content into lines lines = file_content.split("\n") if start_line >= len(lines): return symbol line = lines[start_line] # Fortran keywords that define named constructs # Match patterns: # Standard keywords: # " function add_numbers(a, b) result(sum)" -> keyword="function", name="add_numbers" # "subroutine print_result(value)" -> keyword="subroutine", name="print_result" # "module math_utils" -> keyword="module", name="math_utils" # "program test_program" -> keyword="program", name="test_program" # "interface distance" -> keyword="interface", name="distance" # # Type definitions (can have :: syntax): # "type point" -> keyword="type", name="point" # "type :: point" -> keyword="type", name="point" # "type, extends(base) :: derived" -> keyword="type", name="derived" # # Submodules (have parent module in parentheses): # "submodule (parent_mod) child_mod" -> keyword="submodule", name="child_mod" # Try type pattern first (has complex syntax with optional comma and ::) type_pattern = r"^\s*type\s*(?:,.*?)?\s*(?:::)?\s*([a-zA-Z_]\w*)" match = re.match(type_pattern, line, re.IGNORECASE) if match: # For type pattern, identifier is in group 1 identifier_name = match.group(1) identifier_start = match.start(1) else: # Try standard keywords pattern standard_pattern = r"^\s*(function|subroutine|module|program|interface)\s+([a-zA-Z_]\w*)" match = re.match(standard_pattern, line, re.IGNORECASE) if not match: # Try submodule pattern submodule_pattern = r"^\s*submodule\s*\([^)]+\)\s+([a-zA-Z_]\w*)" match = re.match(submodule_pattern, line, re.IGNORECASE) if match: identifier_name = match.group(1) identifier_start = match.start(1) else: identifier_name = match.group(2) identifier_start = match.start(2) if match: # Create corrected selectionRange new_sel_range = { "start": {"line": start_line, "character": identifier_start}, "end": {"line": start_line, "character": identifier_start + len(identifier_name)}, } # Create modified symbol with corrected selectionRange corrected_symbol = symbol.copy() corrected_symbol["selectionRange"] = new_sel_range # type: ignore[typeddict-item] log.debug(f"Fixed fortls selectionRange for {identifier_name}: char {start_char} -> {identifier_start}") return corrected_symbol # If no match, return symbol unchanged (e.g., for variables, which don't have this pattern) return symbol @override def request_document_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None) -> DocumentSymbols: # Override to fix fortls's incorrect selectionRange bug. # # fortls returns selectionRange pointing to line start (character 0) instead of the # identifier name position. This breaks MCP server features that rely on exact positions. # # This override: # 1. Gets symbols from fortls via parent implementation # 2. Parses each symbol's line to find the correct identifier position # 3. Fixes selectionRange for all symbols recursively # 4. Returns corrected symbols # Get symbols from fortls (with incorrect selectionRange) document_symbols = super().request_document_symbols(relative_file_path, file_buffer=file_buffer) # Get file content for parsing with self.open_file(relative_file_path) as file_data: file_content = file_data.contents # Fix selectionRange recursively for all symbols def fix_symbol_and_children(symbol: ls_types.UnifiedSymbolInformation) -> ls_types.UnifiedSymbolInformation: # Fix this symbol's selectionRange fixed = self._fix_fortls_selection_range(symbol, file_content) # Fix children recursively if fixed.get("children"): fixed["children"] = [fix_symbol_and_children(child) for child in fixed["children"]] return fixed # Apply fix to all symbols fixed_root_symbols = [fix_symbol_and_children(sym) for sym in document_symbols.root_symbols] return DocumentSymbols(fixed_root_symbols) @staticmethod def _check_fortls_installation() -> str: """Check if fortls is available.""" fortls_path = shutil.which("fortls") if fortls_path is None: raise RuntimeError("fortls is not installed or not in PATH.\nInstall it with: pip install fortls") return fortls_path def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): # Check fortls installation fortls_path = self._check_fortls_installation() # Command to start fortls language server # fortls uses stdio for LSP communication by default fortls_cmd = f"{fortls_path}" super().__init__( config, repository_root_path, ProcessLaunchInfo(cmd=fortls_cmd, cwd=repository_root_path), "fortran", solidlsp_settings ) @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """Initialize params for Fortran Language Server.""" root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, "completion": { "dynamicRegistration": True, "completionItem": { "snippetSupport": True, "commitCharactersSupport": True, "documentationFormat": ["markdown", "plaintext"], "deprecatedSupport": True, "preselectSupport": True, }, }, "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, "definition": {"dynamicRegistration": True}, "references": {"dynamicRegistration": True}, "documentSymbol": { "dynamicRegistration": True, "hierarchicalDocumentSymbolSupport": True, "symbolKind": {"valueSet": list(range(1, 27))}, }, "formatting": {"dynamicRegistration": True}, "rangeFormatting": {"dynamicRegistration": True}, "codeAction": {"dynamicRegistration": True}, }, "workspace": { "workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}, "symbol": { "dynamicRegistration": True, "symbolKind": {"valueSet": list(range(1, 27))}, }, }, }, "processId": os.getpid(), "rootPath": repository_absolute_path, "rootUri": root_uri, "workspaceFolders": [ { "uri": root_uri, "name": os.path.basename(repository_absolute_path), } ], } return initialize_params # type: ignore[return-value] def _start_server(self) -> None: """Start Fortran Language Server process.""" def window_log_message(msg: dict) -> None: log.info(f"Fortran LSP: window/logMessage: {msg}") def do_nothing(params: dict) -> None: return def register_capability_handler(params: dict) -> None: return # Register LSP message handlers self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_notification("window/logMessage", window_log_message) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) log.info("Starting Fortran Language Server (fortls) process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.info("Sending initialize request to Fortran Language Server") init_response = self.server.send.initialize(initialize_params) # Verify server capabilities capabilities = init_response.get("capabilities", {}) assert "textDocumentSync" in capabilities if "completionProvider" in capabilities: log.info("Fortran LSP completion provider available") if "definitionProvider" in capabilities: log.info("Fortran LSP definition provider available") if "referencesProvider" in capabilities: log.info("Fortran LSP references provider available") if "documentSymbolProvider" in capabilities: log.info("Fortran LSP document symbol provider available") self.server.notify.initialized({}) # Fortran Language Server is ready after initialization log.info("Fortran Language Server initialization complete") ================================================ FILE: src/solidlsp/language_servers/fsharp_language_server.py ================================================ """ Provides F# specific instantiation of the LanguageServer class. """ import logging import os import pathlib import shutil import threading from pathlib import Path from overrides import override from serena.util.dotnet import DotNETUtil from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_exceptions import SolidLSPException from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) class FSharpLanguageServer(SolidLanguageServer): """ Provides F# specific instantiation of the LanguageServer class using Ionide LSP (FsAutoComplete). Contains various configurations and settings specific to F# development. """ def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates an FSharpLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ fsharp_lsp_executable_path = self._setup_runtime_dependencies(config, solidlsp_settings) super().__init__( config, repository_root_path, ProcessLaunchInfo(cmd=fsharp_lsp_executable_path, cwd=repository_root_path), "fsharp", solidlsp_settings, ) self.server_ready = threading.Event() self.initialize_searcher_command_available = threading.Event() @override def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in [ "bin", "obj", "packages", ".paket", "paket-files", ".fake", ".ionide", ] @classmethod def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> str: """ Setup runtime dependencies for F# Language Server and return the command to start the server. """ dotnet_exe = DotNETUtil("8.0", allow_higher_version=True).get_dotnet_path_or_raise() RuntimeDependencyCollection( [ RuntimeDependency( id="fsautocomplete", description="FsAutoComplete (Ionide F# Language Server)", command="dotnet tool install --tool-path ./ fsautocomplete", platform_id="any", ), ] ) # Install FsAutoComplete if not already installed fsharp_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "fsharp-lsp") fsautocomplete_path = os.path.join(fsharp_ls_dir, "fsautocomplete") # Handle Windows executable extension if os.name == "nt": fsautocomplete_path += ".exe" if not os.path.exists(fsautocomplete_path): log.info(f"FsAutoComplete executable not found at {fsautocomplete_path}. Installing...") # Ensure the directory exists os.makedirs(fsharp_ls_dir, exist_ok=True) # Install FsAutoComplete using dotnet tool install try: import subprocess result = subprocess.run( [dotnet_exe, "tool", "install", "--tool-path", fsharp_ls_dir, "fsautocomplete"], cwd=fsharp_ls_dir, capture_output=True, text=True, check=True, ) log.info("FsAutoComplete installed successfully") log.debug(f"Installation output: {result.stdout}") except subprocess.CalledProcessError as e: log.error(f"Failed to install FsAutoComplete: {e.stderr}") raise RuntimeError(f"Failed to install FsAutoComplete: {e.stderr}") if not os.path.exists(fsautocomplete_path): raise FileNotFoundError( f"FsAutoComplete executable not found at {fsautocomplete_path}, something went wrong with the installation." ) # FsAutoComplete uses --lsp flag for LSP mode return f"{fsautocomplete_path} --adaptive-lsp-server-enabled --project-graph-enabled --use-fcs-transparent-compiler" def _get_initialize_params(self) -> InitializeParams: """ Returns the initialize params for the F# Language Server. """ root_uri = pathlib.Path(self.repository_root_path).as_uri() initialize_params = { "processId": os.getpid(), "rootPath": self.repository_root_path, "rootUri": root_uri, "workspaceFolders": [{"name": "workspace", "uri": root_uri}], "capabilities": { "workspace": { "applyEdit": True, "workspaceEdit": {"documentChanges": True}, "didChangeConfiguration": {"dynamicRegistration": True}, "didChangeWatchedFiles": {"dynamicRegistration": True}, "symbol": {"dynamicRegistration": True}, "executeCommand": {"dynamicRegistration": True}, "configuration": True, "workspaceFolders": True, }, "textDocument": { "synchronization": { "dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True, }, "completion": { "dynamicRegistration": True, "contextSupport": True, "completionItem": { "snippetSupport": True, "commitCharactersSupport": True, "documentationFormat": ["markdown", "plaintext"], "deprecatedSupport": True, }, }, "hover": { "dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"], }, "signatureHelp": { "dynamicRegistration": True, "signatureInformation": {"documentationFormat": ["markdown", "plaintext"]}, }, "definition": {"dynamicRegistration": True}, "references": {"dynamicRegistration": True}, "documentHighlight": {"dynamicRegistration": True}, "documentSymbol": { "dynamicRegistration": True, "symbolKind": {"valueSet": list(range(1, 26))}, # All SymbolKind values "hierarchicalDocumentSymbolSupport": True, }, "codeAction": { "dynamicRegistration": True, "codeActionLiteralSupport": { "codeActionKind": { "valueSet": [ "", "quickfix", "refactor", "refactor.extract", "refactor.inline", "refactor.rewrite", "source", "source.organizeImports", ] } }, }, "codeLens": {"dynamicRegistration": True}, "formatting": {"dynamicRegistration": True}, "rangeFormatting": {"dynamicRegistration": True}, "onTypeFormatting": {"dynamicRegistration": True}, "rename": {"dynamicRegistration": True}, "documentLink": {"dynamicRegistration": True}, "publishDiagnostics": { "relatedInformation": True, "versionSupport": False, "tagSupport": {"valueSet": [1, 2]}, }, "implementation": {"dynamicRegistration": True}, "typeDefinition": {"dynamicRegistration": True}, "colorProvider": {"dynamicRegistration": True}, "foldingRange": { "dynamicRegistration": True, "rangeLimit": 5000, "lineFoldingOnly": True, }, "declaration": {"dynamicRegistration": True}, "selectionRange": {"dynamicRegistration": True}, }, "window": { "workDoneProgress": True, }, }, "initializationOptions": { # F# specific initialization options "automaticWorkspaceInit": True, "abstractClassStubGeneration": True, "abstractClassStubGenerationObjectIdentifier": "this", "abstractClassStubGenerationMethodBody": 'failwith "Not Implemented"', "addFsiWatcher": False, "codeLenses": {"signature": {"enabled": True}, "references": {"enabled": True}}, "disableInMemoryProjectReferences": False, "dotNetRoot": self._get_dotnet_root(), "enableMSBuildProjectGraph": False, "excludeProjectDirectories": ["paket-files"], "externalAutocomplete": False, "fsac": {"attachDebugger": False, "silencedLogs": [], "conserveMemory": False, "netCoreDllPath": ""}, "fsiExtraParameters": [], "generateBinlog": False, "interfaceStubGeneration": True, "interfaceStubGenerationObjectIdentifier": "this", "interfaceStubGenerationMethodBody": 'failwith "Not Implemented"', "keywordsAutocomplete": True, "linter": True, "pipelineHints": {"enabled": True}, "recordStubGeneration": True, "recordStubGenerationBody": 'failwith "Not Implemented"', "resolveNamespaces": True, "saveOnlyOpenFiles": False, "showProjectExplorerIn": ["ionide", "solution"], "simplifyNameAnalyzer": True, "smartIndent": False, "suggestGitignore": True, "suggestSdkScripts": True, "unionCaseStubGeneration": True, "unionCaseStubGenerationBody": 'failwith "Not Implemented"', "unusedDeclarationsAnalyzer": True, "unusedOpensAnalyzer": True, "verboseLogging": False, "workspaceModePeekDeepLevel": 2, "workspacePath": self.repository_root_path, }, "trace": "off", } return initialize_params # type: ignore def _get_dotnet_root(self) -> str: """ Get the .NET root directory. """ dotnet_exe = shutil.which("dotnet") if dotnet_exe: # Try to get the installation path try: import subprocess result = subprocess.run([dotnet_exe, "--info"], capture_output=True, text=True, check=True) lines = result.stdout.split("\n") for line in lines: if "Base Path:" in line or "Base path:" in line: base_path = line.split(":", 1)[1].strip() # Get the parent directory (remove 'sdk/version' part) return str(Path(base_path).parent.parent) except (subprocess.CalledProcessError, Exception): pass # Fallback: use the directory containing dotnet executable if dotnet_exe: return str(Path(dotnet_exe).parent) return "" def _start_server(self) -> None: """ Start the F# Language Server with custom handlers. """ def handle_window_log_message(params: dict) -> None: """Handle window/logMessage from the LSP server.""" message = params.get("message", "") message_type = params.get("type", 1) # Map LSP log levels to Python logging levels level_map = {1: logging.ERROR, 2: logging.WARNING, 3: logging.INFO, 4: logging.DEBUG} level = level_map.get(message_type, logging.INFO) log.log(level, f"FsAutoComplete: {message}") def handle_window_show_message(params: dict) -> None: """Handle window/showMessage from the LSP server.""" message = params.get("message", "") message_type = params.get("type", 1) # Map LSP message types to Python logging levels level_map = {1: logging.ERROR, 2: logging.WARNING, 3: logging.INFO, 4: logging.DEBUG} level = level_map.get(message_type, logging.INFO) log.log(level, f"FsAutoComplete Message: {message}") def handle_workspace_configuration(params: dict) -> list: """Handle workspace/configuration requests from the LSP server.""" # Return empty configuration for now items = params.get("items", []) return [None] * len(items) def handle_client_register_capability(params: dict) -> None: """Handle client/registerCapability requests from the LSP server.""" # For now, just acknowledge the registration return def handle_client_unregister_capability(params: dict) -> None: """Handle client/unregisterCapability requests from the LSP server.""" # For now, just acknowledge the unregistration return def handle_work_done_progress_create(params: dict) -> None: """Handle window/workDoneProgress/create requests from the LSP server.""" # Just acknowledge the request - we don't need to track progress for now return # Register custom handlers self.server.on_notification("window/logMessage", handle_window_log_message) self.server.on_notification("window/showMessage", handle_window_show_message) self.server.on_request("workspace/configuration", handle_workspace_configuration) self.server.on_request("client/registerCapability", handle_client_register_capability) self.server.on_request("client/unregisterCapability", handle_client_unregister_capability) self.server.on_request("window/workDoneProgress/create", handle_work_done_progress_create) log.info("Starting FsAutoComplete F# language server process") try: self.server.start() except Exception as e: log.error(f"Failed to start F# language server process: {e}") raise SolidLSPException(f"Failed to start F# language server: {e}") # Send initialization initialize_params = self._get_initialize_params() log.info("Sending initialize request to F# language server") try: self.server.send.initialize(initialize_params) log.debug("Received initialize response from F# language server") except Exception as e: raise SolidLSPException(f"Failed to initialize F# language server for {self.repository_root_path}: {e}") from e # Complete initialization self.server.notify.initialized({}) log.info("F# language server initialized successfully") @override def _get_wait_time_for_cross_file_referencing(self) -> float: """ F# projects can be large and may need more time for cross-file analysis. """ return 15.0 # 15 seconds should be sufficient for most F# projects ================================================ FILE: src/solidlsp/language_servers/gopls.py ================================================ import logging import os import pathlib import subprocess from typing import Any, cast from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) class Gopls(SolidLanguageServer): """ Provides Go specific instantiation of the LanguageServer class using gopls. """ @override def is_ignored_dirname(self, dirname: str) -> bool: # For Go projects, we should ignore: # - vendor: third-party dependencies vendored into the project # - node_modules: if the project has JavaScript components # - dist/build: common output directories return super().is_ignored_dirname(dirname) or dirname in ["vendor", "node_modules", "dist", "build"] @staticmethod def _determine_log_level(line: str) -> int: """Classify gopls stderr output to avoid false-positive errors.""" line_lower = line.lower() # File discovery messages that are not actual errors if any( [ "discover.go:" in line_lower, "walker.go:" in line_lower, "walking of {file://" in line_lower, "bus: -> discover" in line_lower, ] ): return logging.DEBUG return SolidLanguageServer._determine_log_level(line) @staticmethod def _get_go_version() -> str | None: """Get the installed Go version or None if not found.""" try: result = subprocess.run(["go", "version"], capture_output=True, text=True, check=False) if result.returncode == 0: return result.stdout.strip() except FileNotFoundError: return None return None @staticmethod def _get_gopls_version() -> str | None: """Get the installed gopls version or None if not found.""" try: result = subprocess.run(["gopls", "version"], capture_output=True, text=True, check=False) if result.returncode == 0: return result.stdout.strip() except FileNotFoundError: return None return None @staticmethod def _setup_runtime_dependency() -> bool: """ Check if required Go runtime dependencies are available. Raises RuntimeError with helpful message if dependencies are missing. """ go_version = Gopls._get_go_version() if not go_version: raise RuntimeError( "Go is not installed. Please install Go from https://golang.org/doc/install and make sure it is added to your PATH." ) gopls_version = Gopls._get_gopls_version() if not gopls_version: raise RuntimeError( "Found a Go version but gopls is not installed.\n" "Please install gopls as described in https://pkg.go.dev/golang.org/x/tools/gopls#section-readme\n\n" "After installation, make sure it is added to your PATH (it might be installed in a different location than Go)." ) return True def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): self._setup_runtime_dependency() super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd="gopls", cwd=repository_root_path), "go", solidlsp_settings) self.request_id = 0 def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the Go Language Server. """ root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params: dict = { "locale": "en", "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, "definition": {"dynamicRegistration": True}, "documentSymbol": { "dynamicRegistration": True, "hierarchicalDocumentSymbolSupport": True, "symbolKind": {"valueSet": list(range(1, 27))}, }, }, "workspace": {"workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}}, }, "processId": os.getpid(), "rootPath": repository_absolute_path, "rootUri": root_uri, "workspaceFolders": [ { "uri": root_uri, "name": os.path.basename(repository_absolute_path), } ], } # Apply gopls-specific settings via initializationOptions # Serena applies gopls settings at initialization time via initializationOptions # (Access settings directly to avoid extra INFO logging from CustomLSSettings.get.) gopls_settings = self._custom_settings.settings.get("gopls_settings") if gopls_settings: gopls_settings = self._validate_gopls_settings_dict(gopls_settings) # Validate JSON-serializability early: initializationOptions is sent over JSON-RPC. import json self._canonical_json_or_raise(json, gopls_settings) # Log keys only (and at DEBUG) to avoid leaking sensitive values and to reduce startup noise. log.debug("Applying gopls settings via initializationOptions: keys=%s", list(gopls_settings.keys())) initialize_params["initializationOptions"] = gopls_settings return cast(InitializeParams, initialize_params) def _validate_gopls_settings_dict(self, gopls_settings: object) -> dict: if not isinstance(gopls_settings, dict): raise TypeError( f"gopls_settings must be a dict, got {type(gopls_settings).__name__}. " "Expected structure: {'buildFlags': ['-tags=foo'], 'env': {...}, ...}" ) return gopls_settings def _canonical_json_or_raise(self, json_module: Any, data: object) -> str: try: return json_module.dumps(data, sort_keys=True, separators=(",", ":")) except (TypeError, ValueError) as exc: raise TypeError( "gopls_settings must be JSON-serializable (json.dumps). Use JSON-compatible values (dict/list/str/int/float/bool/null) and prefer string keys." ) from exc # Environment variables that influence Go build context and affect cached symbols. _CACHE_CONTEXT_ENV_KEYS = ("GOFLAGS", "GOOS", "GOARCH", "CGO_ENABLED") @override def _document_symbols_cache_fingerprint(self) -> str | None: """ Compute a deterministic fingerprint of the Go build context. The fingerprint includes gopls_settings and selected env vars that affect symbol discovery. """ import hashlib import json gopls_settings_raw = self._custom_settings.settings.get("gopls_settings") gopls_settings: dict | None if gopls_settings_raw is None: gopls_settings = None else: # Treat an explicitly empty dict the same as not providing settings at all. gopls_settings = self._validate_gopls_settings_dict(gopls_settings_raw) or None # Only include env vars that are set to a non-empty value. env_subset: dict[str, str] = {} for key in self._CACHE_CONTEXT_ENV_KEYS: value = os.environ.get(key) if value: env_subset[key] = value # Return None only when BOTH settings and env subset are effectively empty. if gopls_settings is None and not env_subset: return None fingerprint_data: dict[str, object] = {"env": env_subset} if gopls_settings is not None: fingerprint_data["gopls_settings"] = gopls_settings canonical_json = self._canonical_json_or_raise(json, fingerprint_data) return hashlib.sha256(canonical_json.encode("utf-8")).hexdigest()[:16] def _start_server(self) -> None: """Start gopls server process""" def register_capability_handler(params: dict) -> None: return def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") def do_nothing(params: dict) -> None: return self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_notification("window/logMessage", window_log_message) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) log.info("Starting gopls server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) # Verify server capabilities assert "textDocumentSync" in init_response["capabilities"] assert "completionProvider" in init_response["capabilities"] assert "definitionProvider" in init_response["capabilities"] self.server.notify.initialized({}) # gopls server is typically ready immediately after initialization # (no need to wait for events) ================================================ FILE: src/solidlsp/language_servers/groovy_language_server.py ================================================ """ Provides Groovy specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Groovy. """ import dataclasses import logging import os import pathlib import shlex from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.ls_utils import FileUtils, PlatformUtils from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @dataclasses.dataclass class GroovyRuntimeDependencyPaths: """ Stores the paths to the runtime dependencies of Groovy Language Server """ java_path: str java_home_path: str ls_jar_path: str groovy_home_path: str | None = None class GroovyLanguageServer(SolidLanguageServer): """ Provides Groovy specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Groovy. """ def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a Groovy Language Server instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ runtime_dependency_paths = self._setup_runtime_dependencies(solidlsp_settings) self.runtime_dependency_paths = runtime_dependency_paths # Get jar options from configuration ls_jar_options = [] if solidlsp_settings.ls_specific_settings: groovy_settings = solidlsp_settings.get_ls_specific_settings(Language.GROOVY) jar_options_str = groovy_settings.get("ls_jar_options", "") if jar_options_str: ls_jar_options = shlex.split(jar_options_str) log.info(f"Using Groovy LS JAR options from configuration: {jar_options_str}") # Create command to execute the Groovy Language Server cmd = [self.runtime_dependency_paths.java_path, "-jar", self.runtime_dependency_paths.ls_jar_path] cmd.extend(ls_jar_options) # Set environment variables including JAVA_HOME proc_env = {"JAVA_HOME": self.runtime_dependency_paths.java_home_path} super().__init__( config, repository_root_path, ProcessLaunchInfo(cmd=cmd, env=proc_env, cwd=repository_root_path), "groovy", solidlsp_settings, ) log.info(f"Starting Groovy Language Server with jar options: {ls_jar_options}") @classmethod def _setup_runtime_dependencies(cls, solidlsp_settings: SolidLSPSettings) -> GroovyRuntimeDependencyPaths: """ Setup runtime dependencies for Groovy Language Server and return paths. """ platform_id = PlatformUtils.get_platform_id() # Verify platform support assert ( platform_id.value.startswith("win-") or platform_id.value.startswith("linux-") or platform_id.value.startswith("osx-") ), "Only Windows, Linux and macOS platforms are supported for Groovy in multilspy at the moment" # Check if user specified custom Java home path java_home_path = None java_path = None if solidlsp_settings and solidlsp_settings.ls_specific_settings: groovy_settings = solidlsp_settings.get_ls_specific_settings(Language.GROOVY) custom_java_home = groovy_settings.get("ls_java_home_path") if custom_java_home: log.info(f"Using custom Java home path from configuration: {custom_java_home}") java_home_path = custom_java_home # Determine java executable path based on platform if platform_id.value.startswith("win-"): java_path = os.path.join(java_home_path, "bin", "java.exe") else: java_path = os.path.join(java_home_path, "bin", "java") # If no custom Java home path, download and use bundled Java if java_home_path is None: # Runtime dependency information runtime_dependencies = { "java": { "win-x64": { "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-win32-x64-1.42.0-561.vsix", "archiveType": "zip", "java_home_path": "extension/jre/21.0.7-win32-x86_64", "java_path": "extension/jre/21.0.7-win32-x86_64/bin/java.exe", }, "linux-x64": { "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-linux-x64-1.42.0-561.vsix", "archiveType": "zip", "java_home_path": "extension/jre/21.0.7-linux-x86_64", "java_path": "extension/jre/21.0.7-linux-x86_64/bin/java", }, "linux-arm64": { "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-linux-arm64-1.42.0-561.vsix", "archiveType": "zip", "java_home_path": "extension/jre/21.0.7-linux-aarch64", "java_path": "extension/jre/21.0.7-linux-aarch64/bin/java", }, "osx-x64": { "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-darwin-x64-1.42.0-561.vsix", "archiveType": "zip", "java_home_path": "extension/jre/21.0.7-macosx-x86_64", "java_path": "extension/jre/21.0.7-macosx-x86_64/bin/java", }, "osx-arm64": { "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-darwin-arm64-1.42.0-561.vsix", "archiveType": "zip", "java_home_path": "extension/jre/21.0.7-macosx-aarch64", "java_path": "extension/jre/21.0.7-macosx-aarch64/bin/java", }, }, } java_dependency = runtime_dependencies["java"][platform_id.value] static_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "groovy_language_server") os.makedirs(static_dir, exist_ok=True) java_dir = os.path.join(static_dir, "java") os.makedirs(java_dir, exist_ok=True) java_home_path = os.path.join(java_dir, java_dependency["java_home_path"]) java_path = os.path.join(java_dir, java_dependency["java_path"]) if not os.path.exists(java_path): log.info(f"Downloading Java for {platform_id.value}...") FileUtils.download_and_extract_archive(java_dependency["url"], java_dir, java_dependency["archiveType"]) if not platform_id.value.startswith("win-"): os.chmod(java_path, 0o755) assert java_path and os.path.exists(java_path), f"Java executable not found at {java_path}" ls_jar_path = cls._find_groovy_ls_jar(solidlsp_settings) return GroovyRuntimeDependencyPaths(java_path=java_path, java_home_path=java_home_path, ls_jar_path=ls_jar_path) @classmethod def _find_groovy_ls_jar(cls, solidlsp_settings: SolidLSPSettings) -> str: """ Find Groovy Language Server JAR file """ if solidlsp_settings and solidlsp_settings.ls_specific_settings: groovy_settings = solidlsp_settings.get_ls_specific_settings(Language.GROOVY) config_jar_path = groovy_settings.get("ls_jar_path") if config_jar_path and os.path.exists(config_jar_path): log.info(f"Using Groovy LS JAR from configuration: {config_jar_path}") return config_jar_path # if JAR not found raise RuntimeError( "Groovy Language Server JAR not found. To use Groovy language support:\n" "Set 'ls_jar_path' in groovy settings in serena_config.yml:\n" " ls_specific_settings:\n" " groovy:\n" " ls_jar_path: '/path/to/groovy-language-server.jar'\n" " Ensure the JAR file is available at the configured path\n" ) @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the Groovy Language Server. """ if not os.path.isabs(repository_absolute_path): repository_absolute_path = os.path.abspath(repository_absolute_path) root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "clientInfo": {"name": "Serena Groovy Client", "version": "1.0.0"}, "rootPath": repository_absolute_path, "rootUri": root_uri, "capabilities": { "textDocument": { "synchronization": {"dynamicRegistration": True, "didSave": True}, "completion": {"dynamicRegistration": True}, "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, "definition": {"dynamicRegistration": True}, "references": {"dynamicRegistration": True}, "documentSymbol": {"dynamicRegistration": True}, "workspaceSymbol": {"dynamicRegistration": True}, "signatureHelp": {"dynamicRegistration": True}, "rename": {"dynamicRegistration": True}, }, "workspace": { "workspaceFolders": True, }, }, "initializationOptions": { "settings": { "groovy": { "classpath": [], "diagnostics": {"enabled": True}, "completion": {"enabled": True}, } }, }, "processId": os.getpid(), "workspaceFolders": [ { "uri": root_uri, "name": os.path.basename(repository_absolute_path), } ], } return initialize_params # type: ignore def _start_server(self) -> None: """ Starts the Groovy Language Server """ def execute_client_command_handler(params: dict) -> list: return [] def do_nothing(params: dict) -> None: return def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") self.server.on_request("client/registerCapability", do_nothing) self.server.on_notification("language/status", do_nothing) self.server.on_notification("window/logMessage", window_log_message) self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) self.server.on_notification("language/actionableNotification", do_nothing) log.info("Starting Groovy server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) capabilities = init_response["capabilities"] assert "textDocumentSync" in capabilities, "Server must support textDocumentSync" assert "hoverProvider" in capabilities, "Server must support hover" assert "completionProvider" in capabilities, "Server must support code completion" assert "signatureHelpProvider" in capabilities, "Server must support signature help" assert "definitionProvider" in capabilities, "Server must support go to definition" assert "referencesProvider" in capabilities, "Server must support find references" assert "documentSymbolProvider" in capabilities, "Server must support document symbols" assert "workspaceSymbolProvider" in capabilities, "Server must support workspace symbols" self.server.notify.initialized({}) ================================================ FILE: src/solidlsp/language_servers/haskell_language_server.py ================================================ """ Provides Haskell specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Haskell. """ import logging import os import pathlib import shutil import time from typing import Any from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) class HaskellLanguageServer(SolidLanguageServer): """ Provides Haskell specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Haskell. Uses Haskell Language Server (HLS) for LSP functionality. """ @staticmethod def _ensure_hls_installed() -> str: """Ensure haskell-language-server-wrapper is available.""" # Try common locations common_paths = [ shutil.which("haskell-language-server-wrapper"), "/opt/homebrew/bin/haskell-language-server-wrapper", "/usr/local/bin/haskell-language-server-wrapper", os.path.expanduser("~/.ghcup/bin/haskell-language-server-wrapper"), os.path.expanduser("~/.cabal/bin/haskell-language-server-wrapper"), os.path.expanduser("~/.local/bin/haskell-language-server-wrapper"), ] # Check Stack programs directory stack_programs = os.path.expanduser("~/.local/share/stack/programs") if os.path.exists(stack_programs): try: for arch_dir in os.listdir(stack_programs): arch_path = os.path.join(stack_programs, arch_dir) if os.path.isdir(arch_path): try: for ghc_dir in os.listdir(arch_path): hls_path = os.path.join(arch_path, ghc_dir, "bin", "haskell-language-server-wrapper") if os.path.isfile(hls_path) and os.access(hls_path, os.X_OK): common_paths.append(hls_path) except (PermissionError, OSError): # Skip directories we can't read continue except (PermissionError, OSError): # Stack programs directory not accessible pass for path in common_paths: if path and os.path.isfile(path) and os.access(path, os.X_OK): return path raise RuntimeError( "haskell-language-server-wrapper is not installed or not in PATH.\n" "Searched locations:\n" + "\n".join(f" - {p}" for p in common_paths if p) + "\n" "Please install HLS via:\n" " - GHCup: https://www.haskell.org/ghcup/\n" " - Stack: stack install haskell-language-server\n" " - Cabal: cabal install haskell-language-server\n" " - Homebrew (macOS): brew install haskell-language-server" ) def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a HaskellLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ hls_executable_path = self._ensure_hls_installed() log.info(f"Using haskell-language-server at: {hls_executable_path}") # Check if there's a haskell subdirectory with Stack/Cabal project haskell_subdir = os.path.join(repository_root_path, "haskell") if os.path.exists(haskell_subdir) and ( os.path.exists(os.path.join(haskell_subdir, "stack.yaml")) or os.path.exists(os.path.join(haskell_subdir, "cabal.project")) ): working_dir = haskell_subdir log.info(f"Using Haskell project directory: {working_dir}") else: working_dir = repository_root_path # Set up environment with GHCup bin in PATH env = dict(os.environ) ghcup_bin = os.path.expanduser("~/.ghcup/bin") if ghcup_bin not in env.get("PATH", ""): env["PATH"] = f"{ghcup_bin}{os.pathsep}{env.get('PATH', '')}" super().__init__( config, repository_root_path, ProcessLaunchInfo(cmd=[hls_executable_path, "--lsp", "--cwd", working_dir], cwd=working_dir, env=env), "haskell", solidlsp_settings, ) @override def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in ["dist", "dist-newstyle", ".stack-work", ".cabal-sandbox"] @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the Haskell Language Server. """ root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "clientInfo": {"name": "Serena", "version": "0.1.0"}, "locale": "en", "capabilities": { "workspace": { "applyEdit": True, "workspaceEdit": { "documentChanges": True, "resourceOperations": ["create", "rename", "delete"], "failureHandling": "textOnlyTransactional", "normalizesLineEndings": True, "changeAnnotationSupport": {"groupsOnLabel": True}, }, "configuration": True, "didChangeWatchedFiles": {"dynamicRegistration": True, "relativePatternSupport": True}, "symbol": { "dynamicRegistration": True, "symbolKind": {"valueSet": list(range(1, 27))}, "tagSupport": {"valueSet": [1]}, "resolveSupport": {"properties": ["location.range"]}, }, "executeCommand": {"dynamicRegistration": True}, "didChangeConfiguration": {"dynamicRegistration": True}, "workspaceFolders": True, "semanticTokens": {"refreshSupport": True}, }, "textDocument": { "publishDiagnostics": { "relatedInformation": True, "versionSupport": False, "tagSupport": {"valueSet": [1, 2]}, "codeDescriptionSupport": True, "dataSupport": True, }, "synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True}, "completion": { "dynamicRegistration": True, "contextSupport": True, "completionItem": { "snippetSupport": True, "commitCharactersSupport": True, "documentationFormat": ["markdown", "plaintext"], "deprecatedSupport": True, "preselectSupport": True, "tagSupport": {"valueSet": [1]}, "insertReplaceSupport": True, "resolveSupport": {"properties": ["documentation", "detail", "additionalTextEdits"]}, "insertTextModeSupport": {"valueSet": [1, 2]}, "labelDetailsSupport": True, }, "insertTextMode": 2, "completionItemKind": { "valueSet": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25] }, }, "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, "signatureHelp": { "dynamicRegistration": True, "signatureInformation": { "documentationFormat": ["markdown", "plaintext"], "parameterInformation": {"labelOffsetSupport": True}, "activeParameterSupport": True, }, "contextSupport": True, }, "definition": {"dynamicRegistration": True, "linkSupport": True}, "references": {"dynamicRegistration": True}, "documentHighlight": {"dynamicRegistration": True}, "documentSymbol": { "dynamicRegistration": True, "symbolKind": {"valueSet": list(range(1, 27))}, "hierarchicalDocumentSymbolSupport": True, "tagSupport": {"valueSet": [1]}, "labelSupport": True, }, "codeAction": { "dynamicRegistration": True, "isPreferredSupport": True, "disabledSupport": True, "dataSupport": True, "resolveSupport": {"properties": ["edit"]}, "codeActionLiteralSupport": { "codeActionKind": { "valueSet": [ "", "quickfix", "refactor", "refactor.extract", "refactor.inline", "refactor.rewrite", "source", "source.organizeImports", ] } }, "honorsChangeAnnotations": False, }, "formatting": {"dynamicRegistration": True}, "rangeFormatting": {"dynamicRegistration": True}, "onTypeFormatting": {"dynamicRegistration": True}, "rename": { "dynamicRegistration": True, "prepareSupport": True, "prepareSupportDefaultBehavior": 1, "honorsChangeAnnotations": True, }, "documentLink": {"dynamicRegistration": True, "tooltipSupport": True}, "typeDefinition": {"dynamicRegistration": True, "linkSupport": True}, "implementation": {"dynamicRegistration": True, "linkSupport": True}, "colorProvider": {"dynamicRegistration": True}, "foldingRange": { "dynamicRegistration": True, "rangeLimit": 5000, "lineFoldingOnly": True, "foldingRangeKind": {"valueSet": ["comment", "imports", "region"]}, }, "declaration": {"dynamicRegistration": True, "linkSupport": True}, "selectionRange": {"dynamicRegistration": True}, "callHierarchy": {"dynamicRegistration": True}, "semanticTokens": { "dynamicRegistration": True, "tokenTypes": [ "namespace", "type", "class", "enum", "interface", "struct", "typeParameter", "parameter", "variable", "property", "enumMember", "event", "function", "method", "macro", "keyword", "modifier", "comment", "string", "number", "regexp", "operator", ], "tokenModifiers": [ "declaration", "definition", "readonly", "static", "deprecated", "abstract", "async", "modification", "documentation", "defaultLibrary", ], "formats": ["relative"], "requests": {"range": True, "full": {"delta": True}}, "multilineTokenSupport": False, "overlappingTokenSupport": False, }, "linkedEditingRange": {"dynamicRegistration": True}, }, "window": { "showMessage": {"messageActionItem": {"additionalPropertiesSupport": True}}, "showDocument": {"support": True}, "workDoneProgress": True, }, "general": { "staleRequestSupport": {"cancel": True, "retryOnContentModified": []}, "regularExpressions": {"engine": "ECMAScript", "version": "ES2020"}, "markdown": { "parser": "marked", "version": "1.1.0", }, "positionEncodings": ["utf-16"], }, }, "initializationOptions": { "haskell": { "formattingProvider": "ormolu", "checkProject": True, } }, "trace": "verbose", "processId": os.getpid(), "rootPath": repository_absolute_path, "rootUri": root_uri, "workspaceFolders": [ { "uri": root_uri, "name": os.path.basename(repository_absolute_path), } ], } return initialize_params # type: ignore def _start_server(self) -> None: """ Starts the Haskell Language Server """ def do_nothing(params: Any) -> None: return def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") def register_capability_handler(params: dict) -> None: """Handle dynamic capability registration from HLS""" if "registrations" in params: for registration in params.get("registrations", []): method = registration.get("method", "") log.info(f"HLS registered capability: {method}") return def workspace_configuration_handler(params: dict) -> Any: """Handle workspace/configuration requests from HLS""" log.info(f"HLS requesting configuration: {params}") # Configuration matching VS Code settings and initialization options haskell_config = { "formattingProvider": "ormolu", "checkProject": True, "plugin": {"importLens": {"codeActionsOn": False, "codeLensOn": False}, "hlint": {"codeActionsOn": False}}, } # HLS expects array of config items matching requested sections if isinstance(params, dict) and "items" in params: result = [] for item in params["items"]: section = item.get("section", "") if section == "haskell": result.append(haskell_config) else: result.append({}) log.info(f"Returning configuration: {result}") return result # Fallback: return single config return [haskell_config] self.server.on_notification("window/logMessage", window_log_message) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_request("workspace/configuration", workspace_configuration_handler) log.info("Starting Haskell Language Server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) # Log capabilities returned by HLS capabilities = init_response.get("capabilities", {}) log.info(f"HLS capabilities: {list(capabilities.keys())}") self.server.notify.initialized({}) # Give HLS time to index the project # HLS can be slow to index, especially on first run log.info("Waiting for HLS to index project...") time.sleep(5) log.info("Haskell Language Server initialized successfully") ================================================ FILE: src/solidlsp/language_servers/hlsl_language_server.py ================================================ """ Shader language server using shader-language-server (antaalt/shader-sense). Supports HLSL, GLSL, and WGSL shader file formats. """ import logging import os import pathlib import shutil from typing import Any, cast import psutil from overrides import override from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings from .common import RuntimeDependency, RuntimeDependencyCollection log = logging.getLogger(__name__) # GitHub release version to download when not installed locally _DEFAULT_VERSION = "1.3.0" _GITHUB_RELEASE_BASE = "https://github.com/antaalt/shader-sense/releases/download" class HlslLanguageServer(SolidLanguageServer): """ Shader language server using shader-language-server. Supports .hlsl, .hlsli, .fx, .fxh, .cginc, .compute, .shader, .glsl, .vert, .frag, .geom, .tesc, .tese, .comp, .wgsl files. """ def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings) -> None: super().__init__(config, repository_root_path, None, "hlsl", solidlsp_settings) @override def _create_dependency_provider(self) -> LanguageServerDependencyProvider: return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) class DependencyProvider(LanguageServerDependencyProviderSinglePath): def _get_or_install_core_dependency(self) -> str: # 1. Check PATH for system-installed binary system_binary = shutil.which("shader-language-server") if system_binary: log.info(f"Using system-installed shader-language-server at {system_binary}") return system_binary # 2. Try to download pre-built binary from GitHub releases version = self._custom_settings.get("version", _DEFAULT_VERSION) tag = f"v{version}" base_url = f"{_GITHUB_RELEASE_BASE}/{tag}" # macOS has no pre-built binaries; build from source via cargo install cargo_install_cmd = f"cargo install shader_language_server --version {version} --root ." deps = RuntimeDependencyCollection( [ RuntimeDependency( id="shader-language-server", description="shader-language-server for Windows (x64)", url=f"{base_url}/shader-language-server-x86_64-pc-windows-msvc.zip", platform_id="win-x64", archive_type="zip", binary_name="shader-language-server.exe", ), RuntimeDependency( id="shader-language-server", description="shader-language-server for Linux (x64)", url=f"{base_url}/shader-language-server-x86_64-unknown-linux-gnu.zip", platform_id="linux-x64", archive_type="zip", binary_name="shader-language-server", ), RuntimeDependency( id="shader-language-server", description="shader-language-server for Windows (ARM64)", url=f"{base_url}/shader-language-server-aarch64-pc-windows-msvc.zip", platform_id="win-arm64", archive_type="zip", binary_name="shader-language-server.exe", ), RuntimeDependency( id="shader-language-server", description="shader-language-server for macOS (x64) - built from source", command=cargo_install_cmd, platform_id="osx-x64", binary_name="bin/shader-language-server", ), RuntimeDependency( id="shader-language-server", description="shader-language-server for macOS (ARM64) - built from source", command=cargo_install_cmd, platform_id="osx-arm64", binary_name="bin/shader-language-server", ), ] ) try: dep = deps.get_single_dep_for_current_platform() except RuntimeError: dep = None if dep is None: raise FileNotFoundError( "shader-language-server is not installed and no auto-install is available for your platform.\n" "Please install it using one of the following methods:\n" " cargo: cargo install shader_language_server\n" " GitHub: Download from https://github.com/antaalt/shader-sense/releases\n" "On macOS, install the Rust toolchain (https://rustup.rs) and Serena will build from source automatically.\n" "See https://github.com/antaalt/shader-sense for more details." ) install_dir = os.path.join(self._ls_resources_dir, "shader-language-server") executable_path = deps.binary_path(install_dir) if not os.path.exists(executable_path): log.info(f"shader-language-server not found. Downloading from {dep.url}") _ = deps.install(install_dir) if not os.path.exists(executable_path): raise FileNotFoundError(f"shader-language-server not found at {executable_path}") os.chmod(executable_path, 0o755) return executable_path def _create_launch_command(self, core_path: str) -> list[str]: return [core_path, "--stdio"] @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "processId": os.getpid(), "rootPath": repository_absolute_path, "rootUri": root_uri, "locale": "en", "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, "completion": { "dynamicRegistration": True, "completionItem": {"snippetSupport": True}, }, "definition": {"dynamicRegistration": True}, "hover": { "dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"], }, "signatureHelp": { "dynamicRegistration": True, "signatureInformation": { "parameterInformation": {"labelOffsetSupport": True}, }, }, "documentSymbol": { "dynamicRegistration": True, "hierarchicalDocumentSymbolSupport": True, "symbolKind": {"valueSet": list(range(1, 27))}, }, "formatting": {"dynamicRegistration": True}, "publishDiagnostics": {"relatedInformation": True}, }, "workspace": { "workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}, "configuration": True, }, }, "workspaceFolders": [{"uri": root_uri, "name": os.path.basename(repository_absolute_path)}], } return cast(InitializeParams, initialize_params) @override def _start_server(self) -> None: def do_nothing(params: Any) -> None: return def on_log_message(params: Any) -> None: message = params.get("message", "") if isinstance(params, dict) else str(params) log.info(f"shader-language-server: {message}") def on_configuration_request(params: Any) -> list[dict]: """Respond to workspace/configuration requests. shader-language-server requests config with section 'shader-validator'. Return empty config to use defaults. """ items = params.get("items", []) if isinstance(params, dict) else [] return [{}] * len(items) self.server.on_request("client/registerCapability", do_nothing) self.server.on_request("workspace/configuration", on_configuration_request) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) self.server.on_notification("window/logMessage", on_log_message) log.info("Starting shader-language-server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.info("Sending initialize request") init_response = self.server.send.initialize(initialize_params) capabilities = init_response.get("capabilities", {}) log.info(f"Initialize response capabilities: {list(capabilities.keys())}") assert "textDocumentSync" in capabilities, "shader-language-server must support textDocumentSync" if "documentSymbolProvider" not in capabilities: log.warning("shader-language-server does not advertise documentSymbolProvider") if "definitionProvider" not in capabilities: log.warning("shader-language-server does not advertise definitionProvider") self.server.notify.initialized({}) @override def stop(self, shutdown_timeout: float = 2.0) -> None: """Kill the shader-language-server process tree before the standard shutdown. The base _shutdown() calls process.terminate() directly on the subprocess, which on Windows with shell=True only kills the cmd.exe wrapper, leaving the actual shader-language-server binary running as an orphan. We use psutil to terminate the full process tree first. """ process = self.server.process if self.server else None if process and process.pid and process.returncode is None: try: parent = psutil.Process(process.pid) children = parent.children(recursive=True) for child in children: try: child.terminate() except (psutil.NoSuchProcess, psutil.AccessDenied): pass psutil.wait_procs(children, timeout=2) for child in children: try: if child.is_running(): child.kill() except (psutil.NoSuchProcess, psutil.AccessDenied): pass except (psutil.NoSuchProcess, psutil.AccessDenied): pass except Exception as e: log.debug(f"Error cleaning up shader-language-server process tree: {e}") super().stop(shutdown_timeout) @override def is_ignored_dirname(self, dirname: str) -> bool: """Ignore Unity-specific directories that contain no user-authored shaders.""" return super().is_ignored_dirname(dirname) or dirname in {"Library", "Temp", "Logs", "obj", "Packages"} ================================================ FILE: src/solidlsp/language_servers/intelephense.py ================================================ """ Provides PHP specific instantiation of the LanguageServer class using Intelephense. """ import logging import os import pathlib import shutil from time import sleep from overrides import override from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_utils import PlatformId, PlatformUtils from solidlsp.lsp_protocol_handler.lsp_types import Definition, DefinitionParams, InitializeParams, LocationLink from solidlsp.settings import SolidLSPSettings from ..lsp_protocol_handler import lsp_types from .common import RuntimeDependency, RuntimeDependencyCollection log = logging.getLogger(__name__) class Intelephense(SolidLanguageServer): """ Provides PHP specific instantiation of the LanguageServer class using Intelephense. You can pass the following entries in ls_specific_settings["php"]: - maxMemory: sets intelephense.maxMemory - maxFileSize: sets intelephense.files.maxSize - ignore_vendor: whether or ignore directories named "vendor" (default: true) """ @override def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in self._ignored_dirnames class DependencyProvider(LanguageServerDependencyProviderSinglePath): def _get_or_install_core_dependency(self) -> str: """ Setup runtime dependencies for Intelephense and return the path to the executable. """ platform_id = PlatformUtils.get_platform_id() valid_platforms = [ PlatformId.LINUX_x64, PlatformId.LINUX_arm64, PlatformId.OSX, PlatformId.OSX_x64, PlatformId.OSX_arm64, PlatformId.WIN_x64, PlatformId.WIN_arm64, ] assert platform_id in valid_platforms, f"Platform {platform_id} is not supported by Intelephense at the moment" # Verify both node and npm are installed is_node_installed = shutil.which("node") is not None assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." is_npm_installed = shutil.which("npm") is not None assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." # Install intelephense if not already installed intelephense_ls_dir = os.path.join(self._ls_resources_dir, "php-lsp") os.makedirs(intelephense_ls_dir, exist_ok=True) intelephense_executable_path = os.path.join(intelephense_ls_dir, "node_modules", ".bin", "intelephense") if not os.path.exists(intelephense_executable_path): deps = RuntimeDependencyCollection( [ RuntimeDependency( id="intelephense", command="npm install --prefix ./ intelephense@1.14.4", platform_id="any", ) ] ) deps.install(intelephense_ls_dir) assert os.path.exists( intelephense_executable_path ), f"intelephense executable not found at {intelephense_executable_path}, something went wrong." return intelephense_executable_path def _create_launch_command(self, core_path: str) -> list[str]: return [core_path, "--stdio"] def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): super().__init__(config, repository_root_path, None, "php", solidlsp_settings) self.request_id = 0 # For PHP projects, we should ignore: # - node_modules: if the project has JavaScript components # - cache: commonly used for caching # - (configurable) vendor: third-party dependencies managed by Composer self._ignored_dirnames = {"node_modules", "cache"} if self._custom_settings.get("ignore_vendor", True): self._ignored_dirnames.add("vendor") log.info(f"Ignoring the following directories for PHP projects: {', '.join(sorted(self._ignored_dirnames))}") def _create_dependency_provider(self) -> LanguageServerDependencyProvider: return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: """ Returns the initialization params for the Intelephense Language Server. """ root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, "definition": {"dynamicRegistration": True}, "references": {"dynamicRegistration": True}, "documentSymbol": { "dynamicRegistration": True, "hierarchicalDocumentSymbolSupport": True, "symbolKind": {"valueSet": list(range(1, 27))}, }, "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, }, "workspace": { "workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}, "symbol": {"dynamicRegistration": True}, }, }, "processId": os.getpid(), "rootPath": repository_absolute_path, "rootUri": root_uri, "workspaceFolders": [ { "uri": root_uri, "name": os.path.basename(repository_absolute_path), } ], } initialization_options = {} # Add license key if provided via environment variable license_key = os.environ.get("INTELEPHENSE_LICENSE_KEY") if license_key: initialization_options["licenceKey"] = license_key max_memory = self._custom_settings.get("maxMemory") max_file_size = self._custom_settings.get("maxFileSize") if max_memory is not None: initialization_options["intelephense.maxMemory"] = max_memory if max_file_size is not None: initialization_options["intelephense.files.maxSize"] = max_file_size initialize_params["initializationOptions"] = initialization_options return initialize_params # type: ignore def _start_server(self) -> None: """Start Intelephense server process""" def register_capability_handler(params: dict) -> None: return def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") def do_nothing(params: dict) -> None: return self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_notification("window/logMessage", window_log_message) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) log.info("Starting Intelephense server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) log.info("After sent initialize params") # Verify server capabilities capabilities = init_response["capabilities"] assert "textDocumentSync" in capabilities assert "completionProvider" in capabilities assert "definitionProvider" in capabilities assert "documentSymbolProvider" in capabilities, "Server must support document symbols" self.server.notify.initialized({}) # Intelephense server is typically ready immediately after initialization # TODO: This is probably incorrect; the server does send an initialized notification, which we could wait for! @override # For some reason, the LS may need longer to process this, so we just retry def _send_references_request(self, relative_file_path: str, line: int, column: int) -> list[lsp_types.Location] | None: # TODO: The LS doesn't return references contained in other files if it doesn't sleep. This is # despite the LS having processed requests already. I don't know what causes this, but sleeping # one second helps. It may be that sleeping only once is enough but that's hard to reliably test. # May be related to the time it takes to read the files or something like that. # The sleeping doesn't seem to be needed on all systems sleep(1) return super()._send_references_request(relative_file_path, line, column) @override def _send_definition_request(self, definition_params: DefinitionParams) -> Definition | list[LocationLink] | None: # TODO: same as above, also only a problem if the definition is in another file sleep(1) return super()._send_definition_request(definition_params) ================================================ FILE: src/solidlsp/language_servers/jedi_server.py ================================================ """ Provides Python specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Python. """ import logging import os import pathlib import threading from typing import cast from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) class JediServer(SolidLanguageServer): """ Provides Python specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Python. """ def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a JediServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ super().__init__( config, repository_root_path, ProcessLaunchInfo(cmd="jedi-language-server", cwd=repository_root_path), "python", solidlsp_settings, ) @override def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in ["venv", "__pycache__"] @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the Jedi Language Server. """ root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "processId": os.getpid(), "clientInfo": {"name": "Serena", "version": "0.1.0"}, "locale": "en", "rootPath": repository_absolute_path, "rootUri": root_uri, # Note: this is not necessarily the minimal set of capabilities... "capabilities": { "workspace": { "applyEdit": True, "workspaceEdit": { "documentChanges": True, "resourceOperations": ["create", "rename", "delete"], "failureHandling": "textOnlyTransactional", "normalizesLineEndings": True, "changeAnnotationSupport": {"groupsOnLabel": True}, }, "configuration": True, "didChangeWatchedFiles": {"dynamicRegistration": True, "relativePatternSupport": True}, "symbol": { "dynamicRegistration": True, "symbolKind": {"valueSet": list(range(1, 27))}, "tagSupport": {"valueSet": [1]}, "resolveSupport": {"properties": ["location.range"]}, }, "workspaceFolders": True, "fileOperations": { "dynamicRegistration": True, "didCreate": True, "didRename": True, "didDelete": True, "willCreate": True, "willRename": True, "willDelete": True, }, "inlineValue": {"refreshSupport": True}, "inlayHint": {"refreshSupport": True}, "diagnostics": {"refreshSupport": True}, }, "textDocument": { "publishDiagnostics": { "relatedInformation": True, "versionSupport": False, "tagSupport": {"valueSet": [1, 2]}, "codeDescriptionSupport": True, "dataSupport": True, }, "synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True}, "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, "signatureHelp": { "dynamicRegistration": True, "signatureInformation": { "documentationFormat": ["markdown", "plaintext"], "parameterInformation": {"labelOffsetSupport": True}, "activeParameterSupport": True, }, "contextSupport": True, }, "definition": {"dynamicRegistration": True, "linkSupport": True}, "references": {"dynamicRegistration": True}, "documentHighlight": {"dynamicRegistration": True}, "documentSymbol": { "dynamicRegistration": True, "symbolKind": {"valueSet": list(range(1, 27))}, "hierarchicalDocumentSymbolSupport": True, "tagSupport": {"valueSet": [1]}, "labelSupport": True, }, "documentLink": {"dynamicRegistration": True, "tooltipSupport": True}, "typeDefinition": {"dynamicRegistration": True, "linkSupport": True}, "implementation": {"dynamicRegistration": True, "linkSupport": True}, "declaration": {"dynamicRegistration": True, "linkSupport": True}, "selectionRange": {"dynamicRegistration": True}, "callHierarchy": {"dynamicRegistration": True}, "linkedEditingRange": {"dynamicRegistration": True}, "typeHierarchy": {"dynamicRegistration": True}, "inlineValue": {"dynamicRegistration": True}, "inlayHint": { "dynamicRegistration": True, "resolveSupport": {"properties": ["tooltip", "textEdits", "label.tooltip", "label.location", "label.command"]}, }, "diagnostic": {"dynamicRegistration": True, "relatedDocumentSupport": False}, }, "notebookDocument": {"synchronization": {"dynamicRegistration": True, "executionSummarySupport": True}}, "experimental": { "serverStatusNotification": True, "openServerLogs": True, }, }, # See https://github.com/pappasam/jedi-language-server?tab=readme-ov-file # We use the default options except for maxSymbols, where 0 means no limit "initializationOptions": { "workspace": { "symbols": {"ignoreFolders": [".nox", ".tox", ".venv", "__pycache__", "venv"], "maxSymbols": 0}, }, }, "trace": "verbose", "workspaceFolders": [ { "uri": root_uri, "name": os.path.basename(repository_absolute_path), } ], } return cast(InitializeParams, initialize_params) def _start_server(self) -> None: """ Starts the JEDI Language Server """ completions_available = threading.Event() def execute_client_command_handler(params: dict) -> list: return [] def do_nothing(params: dict) -> None: return def check_experimental_status(params: dict) -> None: if params["quiescent"] == True: completions_available.set() def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") self.server.on_request("client/registerCapability", do_nothing) self.server.on_notification("language/status", do_nothing) self.server.on_notification("window/logMessage", window_log_message) self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) self.server.on_notification("language/actionableNotification", do_nothing) self.server.on_notification("experimental/serverStatus", check_experimental_status) log.info("Starting jedi-language-server server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) assert init_response["capabilities"]["textDocumentSync"]["change"] == 2 # type: ignore assert "completionProvider" in init_response["capabilities"] assert init_response["capabilities"]["completionProvider"] == { "triggerCharacters": [".", "'", '"'], "resolveProvider": True, } self.server.notify.initialized({}) ================================================ FILE: src/solidlsp/language_servers/julia_server.py ================================================ import logging import os import pathlib import platform import shutil import subprocess from typing import Any from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) class JuliaLanguageServer(SolidLanguageServer): """ Language server implementation for Julia using LanguageServer.jl. """ def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): julia_executable = self._setup_runtime_dependency() # PASS LOGGER julia_code = "using LanguageServer; runserver()" julia_ls_cmd: str | list[str] if platform.system() == "Windows": # On Windows, pass as list (Serena handles shell=True differently) julia_ls_cmd = [julia_executable, "--startup-file=no", "--history-file=no", "-e", julia_code, repository_root_path] else: # On Linux/macOS, build shell-escaped string import shlex julia_ls_cmd = ( f"{shlex.quote(julia_executable)} " f"--startup-file=no " f"--history-file=no " f"-e {shlex.quote(julia_code)} " f"{shlex.quote(repository_root_path)}" ) log.info(f"[JULIA DEBUG] Command: {julia_ls_cmd}") super().__init__( config, repository_root_path, ProcessLaunchInfo(cmd=julia_ls_cmd, cwd=repository_root_path), "julia", solidlsp_settings ) @staticmethod def _setup_runtime_dependency() -> str: """ Check if the Julia runtime is available and return its full path. Raises RuntimeError with a helpful message if the dependency is missing. """ # First check if julia is in PATH julia_path = shutil.which("julia") # If not found in PATH, check common installation locations if julia_path is None: common_locations = [ os.path.expanduser("~/.juliaup/bin/julia"), os.path.expanduser("~/.julia/bin/julia"), "/usr/local/bin/julia", "/usr/bin/julia", ] for location in common_locations: if os.path.isfile(location) and os.access(location, os.X_OK): julia_path = location break if julia_path is None: raise RuntimeError( "Julia is not installed or not in your PATH. " "Please install Julia from https://julialang.org/downloads/ and ensure it is accessible. " f"Checked locations: {common_locations}" ) # Check if LanguageServer.jl is installed check_cmd = [julia_path, "-e", "using LanguageServer"] try: result = subprocess.run(check_cmd, check=False, capture_output=True, text=True, timeout=10) if result.returncode != 0: # LanguageServer.jl not found, install it JuliaLanguageServer._install_language_server(julia_path) except subprocess.TimeoutExpired: # Assume it needs installation JuliaLanguageServer._install_language_server(julia_path) return julia_path @staticmethod def _install_language_server(julia_path: str) -> None: """Install LanguageServer.jl package.""" log.info("LanguageServer.jl not found. Installing... (this may take a minute)") install_cmd = [julia_path, "-e", 'using Pkg; Pkg.add("LanguageServer")'] try: result = subprocess.run(install_cmd, check=False, capture_output=True, text=True, timeout=300) # 5 minutes for installation if result.returncode == 0: log.info("LanguageServer.jl installed successfully!") else: raise RuntimeError(f"Failed to install LanguageServer.jl: {result.stderr}") except subprocess.TimeoutExpired: raise RuntimeError( "LanguageServer.jl installation timed out. Please install manually: julia -e 'using Pkg; Pkg.add(\"LanguageServer\")'" ) @override def is_ignored_dirname(self, dirname: str) -> bool: """Define language-specific directories to ignore for Julia projects.""" return super().is_ignored_dirname(dirname) or dirname in [".julia", "build", "dist"] def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the Julia Language Server. """ root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params: InitializeParams = { # type: ignore "processId": os.getpid(), "rootPath": repository_absolute_path, "rootUri": root_uri, "capabilities": { "workspace": {"workspaceFolders": True}, "textDocument": { "definition": {"dynamicRegistration": True}, "references": {"dynamicRegistration": True}, "documentSymbol": {"dynamicRegistration": True}, }, }, "workspaceFolders": [ { "uri": root_uri, "name": os.path.basename(repository_absolute_path), } ], } return initialize_params # type: ignore def _start_server(self) -> None: """Start the LanguageServer.jl server process.""" def do_nothing(params: Any) -> None: return def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") self.server.on_notification("window/logMessage", window_log_message) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) log.info("Starting LanguageServer.jl server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.info("Sending initialize request to Julia Language Server") init_response = self.server.send.initialize(initialize_params) assert "definitionProvider" in init_response["capabilities"] assert "referencesProvider" in init_response["capabilities"] assert "documentSymbolProvider" in init_response["capabilities"] self.server.notify.initialized({}) log.info("Julia Language Server is initialized and ready.") ================================================ FILE: src/solidlsp/language_servers/kotlin_language_server.py ================================================ """ Provides Kotlin specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Kotlin. You can configure the following options in ls_specific_settings (in serena_config.yml): ls_specific_settings: kotlin: ls_path: '/path/to/kotlin-lsp.sh' # Custom path to Kotlin Language Server executable kotlin_lsp_version: '261.13587.0' # Kotlin Language Server version (default: current bundled version) jvm_options: '-Xmx2G' # JVM options for Kotlin Language Server (default: -Xmx2G) Example configuration for large projects: ls_specific_settings: kotlin: jvm_options: '-Xmx4G -XX:+UseG1GC' """ import logging import os import pathlib import stat import threading from typing import cast from overrides import override from solidlsp.ls import ( LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer, ) from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_utils import FileUtils, PlatformUtils from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) # Default JVM options for Kotlin Language Server # -Xmx2G: 2GB heap is sufficient for most projects; override via ls_specific_settings for large codebases DEFAULT_KOTLIN_JVM_OPTIONS = "-Xmx2G" # Default Kotlin Language Server version (can be overridden via ls_specific_settings) DEFAULT_KOTLIN_LSP_VERSION = "261.13587.0" # Platform-specific Kotlin LSP download suffixes PLATFORM_KOTLIN_SUFFIX = { "win-x64": "win-x64", "linux-x64": "linux-x64", "linux-arm64": "linux-aarch64", "osx-x64": "mac-x64", "osx-arm64": "mac-aarch64", } class KotlinLanguageServer(SolidLanguageServer): """ Provides Kotlin specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Kotlin. """ def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a Kotlin Language Server instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ super().__init__( config, repository_root_path, None, "kotlin", solidlsp_settings, ) # Indexing synchronisation: starts SET (= already done), cleared if the server # sends window/workDoneProgress/create (async-indexing servers like KLS v261+), # set again once all progress tokens have ended. self._indexing_complete = threading.Event() self._indexing_complete.set() self._active_progress_tokens: set[str] = set() self._progress_lock = threading.Lock() def _create_dependency_provider(self) -> LanguageServerDependencyProvider: return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) class DependencyProvider(LanguageServerDependencyProviderSinglePath): def __init__(self, custom_settings: SolidLSPSettings.CustomLSSettings, ls_resources_dir: str): super().__init__(custom_settings, ls_resources_dir) self._java_home_path: str | None = None def _get_or_install_core_dependency(self) -> str: """ Setup runtime dependencies for Kotlin Language Server and return the path to the executable script. """ platform_id = PlatformUtils.get_platform_id() # Verify platform support assert ( platform_id.value.startswith("win-") or platform_id.value.startswith("linux-") or platform_id.value.startswith("osx-") ), "Only Windows, Linux and macOS platforms are supported for Kotlin in multilspy at the moment" kotlin_suffix = PLATFORM_KOTLIN_SUFFIX.get(platform_id.value) assert kotlin_suffix, f"Unsupported platform for Kotlin LSP: {platform_id.value}" # Setup paths for dependencies static_dir = os.path.join(self._ls_resources_dir, "kotlin_language_server") os.makedirs(static_dir, exist_ok=True) # Setup Kotlin Language Server kotlin_script_name = "kotlin-lsp.cmd" if platform_id.value.startswith("win-") else "kotlin-lsp.sh" kotlin_script = os.path.join(static_dir, kotlin_script_name) if not os.path.exists(kotlin_script): kotlin_lsp_version = self._custom_settings.get("kotlin_lsp_version", DEFAULT_KOTLIN_LSP_VERSION) kotlin_url = f"https://download-cdn.jetbrains.com/kotlin-lsp/{kotlin_lsp_version}/kotlin-lsp-{kotlin_lsp_version}-{kotlin_suffix}.zip" log.info("Downloading Kotlin Language Server...") FileUtils.download_and_extract_archive(kotlin_url, static_dir, "zip") if os.path.exists(kotlin_script) and not platform_id.value.startswith("win-"): os.chmod( kotlin_script, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH, ) if not os.path.exists(kotlin_script): raise FileNotFoundError(f"Kotlin Language Server script not found at {kotlin_script}") log.info(f"Using Kotlin Language Server script at {kotlin_script}") return kotlin_script def _create_launch_command(self, core_path: str) -> list[str]: return [core_path, "--stdio"] def create_launch_command_env(self) -> dict[str, str]: """Provides JAVA_HOME and JVM options for the Kotlin Language Server process.""" env: dict[str, str] = {} if self._java_home_path is not None: env["JAVA_HOME"] = self._java_home_path # Get JVM options from settings or use default # Note: an explicit empty string means "no JVM options", which is distinct from not setting the key _sentinel = object() custom_jvm_options = self._custom_settings.get("jvm_options", _sentinel) if custom_jvm_options is not _sentinel: jvm_options = custom_jvm_options else: jvm_options = DEFAULT_KOTLIN_JVM_OPTIONS env["JAVA_TOOL_OPTIONS"] = jvm_options return env @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the Kotlin Language Server. """ if not os.path.isabs(repository_absolute_path): repository_absolute_path = os.path.abspath(repository_absolute_path) root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "clientInfo": {"name": "Multilspy Kotlin Client", "version": "1.0.0"}, "locale": "en", "rootPath": repository_absolute_path, "rootUri": root_uri, "capabilities": { "workspace": { "applyEdit": True, "workspaceEdit": { "documentChanges": True, "resourceOperations": ["create", "rename", "delete"], "failureHandling": "textOnlyTransactional", "normalizesLineEndings": True, "changeAnnotationSupport": {"groupsOnLabel": True}, }, "didChangeConfiguration": {"dynamicRegistration": True}, "didChangeWatchedFiles": {"dynamicRegistration": True, "relativePatternSupport": True}, "symbol": { "dynamicRegistration": True, "symbolKind": {"valueSet": list(range(1, 27))}, "tagSupport": {"valueSet": [1]}, "resolveSupport": {"properties": ["location.range"]}, }, "codeLens": {"refreshSupport": True}, "executeCommand": {"dynamicRegistration": True}, "configuration": True, "workspaceFolders": True, "semanticTokens": {"refreshSupport": True}, "fileOperations": { "dynamicRegistration": True, "didCreate": True, "didRename": True, "didDelete": True, "willCreate": True, "willRename": True, "willDelete": True, }, "inlineValue": {"refreshSupport": True}, "inlayHint": {"refreshSupport": True}, "diagnostics": {"refreshSupport": True}, }, "textDocument": { "publishDiagnostics": { "relatedInformation": True, "versionSupport": False, "tagSupport": {"valueSet": [1, 2]}, "codeDescriptionSupport": True, "dataSupport": True, }, "synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True}, "completion": { "dynamicRegistration": True, "contextSupport": True, "completionItem": { "snippetSupport": False, "commitCharactersSupport": True, "documentationFormat": ["markdown", "plaintext"], "deprecatedSupport": True, "preselectSupport": True, "tagSupport": {"valueSet": [1]}, "insertReplaceSupport": False, "resolveSupport": {"properties": ["documentation", "detail", "additionalTextEdits"]}, "insertTextModeSupport": {"valueSet": [1, 2]}, "labelDetailsSupport": True, }, "insertTextMode": 2, "completionItemKind": { "valueSet": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25] }, "completionList": {"itemDefaults": ["commitCharacters", "editRange", "insertTextFormat", "insertTextMode"]}, }, "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, "signatureHelp": { "dynamicRegistration": True, "signatureInformation": { "documentationFormat": ["markdown", "plaintext"], "parameterInformation": {"labelOffsetSupport": True}, "activeParameterSupport": True, }, "contextSupport": True, }, "definition": {"dynamicRegistration": True, "linkSupport": True}, "references": {"dynamicRegistration": True}, "documentHighlight": {"dynamicRegistration": True}, "documentSymbol": { "dynamicRegistration": True, "symbolKind": {"valueSet": list(range(1, 27))}, "hierarchicalDocumentSymbolSupport": True, "tagSupport": {"valueSet": [1]}, "labelSupport": True, }, "codeAction": { "dynamicRegistration": True, "isPreferredSupport": True, "disabledSupport": True, "dataSupport": True, "resolveSupport": {"properties": ["edit"]}, "codeActionLiteralSupport": { "codeActionKind": { "valueSet": [ "", "quickfix", "refactor", "refactor.extract", "refactor.inline", "refactor.rewrite", "source", "source.organizeImports", ] } }, "honorsChangeAnnotations": False, }, "codeLens": {"dynamicRegistration": True}, "formatting": {"dynamicRegistration": True}, "rangeFormatting": {"dynamicRegistration": True}, "onTypeFormatting": {"dynamicRegistration": True}, "rename": { "dynamicRegistration": True, "prepareSupport": True, "prepareSupportDefaultBehavior": 1, "honorsChangeAnnotations": True, }, "documentLink": {"dynamicRegistration": True, "tooltipSupport": True}, "typeDefinition": {"dynamicRegistration": True, "linkSupport": True}, "implementation": {"dynamicRegistration": True, "linkSupport": True}, "colorProvider": {"dynamicRegistration": True}, "foldingRange": { "dynamicRegistration": True, "rangeLimit": 5000, "lineFoldingOnly": True, "foldingRangeKind": {"valueSet": ["comment", "imports", "region"]}, "foldingRange": {"collapsedText": False}, }, "declaration": {"dynamicRegistration": True, "linkSupport": True}, "selectionRange": {"dynamicRegistration": True}, "callHierarchy": {"dynamicRegistration": True}, "semanticTokens": { "dynamicRegistration": True, "tokenTypes": [ "namespace", "type", "class", "enum", "interface", "struct", "typeParameter", "parameter", "variable", "property", "enumMember", "event", "function", "method", "macro", "keyword", "modifier", "comment", "string", "number", "regexp", "operator", "decorator", ], "tokenModifiers": [ "declaration", "definition", "readonly", "static", "deprecated", "abstract", "async", "modification", "documentation", "defaultLibrary", ], "formats": ["relative"], "requests": {"range": True, "full": {"delta": True}}, "multilineTokenSupport": False, "overlappingTokenSupport": False, "serverCancelSupport": True, "augmentsSyntaxTokens": True, }, "linkedEditingRange": {"dynamicRegistration": True}, "typeHierarchy": {"dynamicRegistration": True}, "inlineValue": {"dynamicRegistration": True}, "inlayHint": { "dynamicRegistration": True, "resolveSupport": {"properties": ["tooltip", "textEdits", "label.tooltip", "label.location", "label.command"]}, }, "diagnostic": {"dynamicRegistration": True, "relatedDocumentSupport": False}, }, "window": { "showMessage": {"messageActionItem": {"additionalPropertiesSupport": True}}, "showDocument": {"support": True}, "workDoneProgress": True, }, "general": { "staleRequestSupport": { "cancel": True, "retryOnContentModified": [ "textDocument/semanticTokens/full", "textDocument/semanticTokens/range", "textDocument/semanticTokens/full/delta", ], }, "regularExpressions": {"engine": "ECMAScript", "version": "ES2020"}, "markdown": {"parser": "marked", "version": "1.1.0"}, "positionEncodings": ["utf-16"], }, "notebookDocument": {"synchronization": {"dynamicRegistration": True, "executionSummarySupport": True}}, }, "initializationOptions": { "workspaceFolders": [root_uri], "storagePath": None, "codegen": {"enabled": False}, "compiler": {"jvm": {"target": "default"}}, "completion": {"snippets": {"enabled": True}}, "diagnostics": {"enabled": True, "level": 4, "debounceTime": 250}, "scripts": {"enabled": True, "buildScriptsEnabled": True}, "indexing": {"enabled": True}, "externalSources": {"useKlsScheme": False, "autoConvertToKotlin": False}, "inlayHints": {"typeHints": False, "parameterHints": False, "chainedHints": False}, "formatting": { "formatter": "ktfmt", "ktfmt": { "style": "google", "indent": 4, "maxWidth": 100, "continuationIndent": 8, "removeUnusedImports": True, }, }, }, "trace": "off", "processId": os.getpid(), "workspaceFolders": [ { "uri": root_uri, "name": os.path.basename(repository_absolute_path), } ], } return cast(InitializeParams, initialize_params) def _start_server(self) -> None: """ Starts the Kotlin Language Server """ def execute_client_command_handler(params: dict) -> list: return [] def do_nothing(params: dict) -> None: return def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") def work_done_progress_create(params: dict) -> dict: """Handle window/workDoneProgress/create: the server is about to report async progress. Clear the indexing-complete event so _start_server waits until all tokens finish. This is triggered by newer KLS versions (261+) that index asynchronously after initialized. Older versions (0.253.x) never send this, so _indexing_complete stays set and wait() returns instantly. """ token = str(params.get("token", "")) log.debug(f"Kotlin LSP workDoneProgress/create: token={token!r}") with self._progress_lock: self._active_progress_tokens.add(token) self._indexing_complete.clear() return {} def progress_handler(params: dict) -> None: """Track $/progress begin/end to detect when all async indexing work finishes.""" token = str(params.get("token", "")) value = params.get("value", {}) kind = value.get("kind") if kind == "begin": title = value.get("title", "") log.info(f"Kotlin LSP progress [{token}]: started - {title}") with self._progress_lock: self._active_progress_tokens.add(token) self._indexing_complete.clear() elif kind == "report": pct = value.get("percentage") msg = value.get("message", "") pct_str = f" ({pct}%)" if pct is not None else "" log.debug(f"Kotlin LSP progress [{token}]: {msg}{pct_str}") elif kind == "end": msg = value.get("message", "") log.info(f"Kotlin LSP progress [{token}]: ended - {msg}") with self._progress_lock: self._active_progress_tokens.discard(token) if not self._active_progress_tokens: self._indexing_complete.set() self.server.on_request("client/registerCapability", do_nothing) self.server.on_notification("language/status", do_nothing) self.server.on_notification("window/logMessage", window_log_message) self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) self.server.on_request("window/workDoneProgress/create", work_done_progress_create) self.server.on_notification("$/progress", progress_handler) self.server.on_notification("$/logTrace", do_nothing) self.server.on_notification("$/cancelRequest", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) self.server.on_notification("language/actionableNotification", do_nothing) log.info("Starting Kotlin server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) capabilities = init_response["capabilities"] assert "textDocumentSync" in capabilities, "Server must support textDocumentSync" assert "hoverProvider" in capabilities, "Server must support hover" assert "completionProvider" in capabilities, "Server must support code completion" assert "signatureHelpProvider" in capabilities, "Server must support signature help" assert "definitionProvider" in capabilities, "Server must support go to definition" assert "referencesProvider" in capabilities, "Server must support find references" assert "documentSymbolProvider" in capabilities, "Server must support document symbols" assert "workspaceSymbolProvider" in capabilities, "Server must support workspace symbols" assert "semanticTokensProvider" in capabilities, "Server must support semantic tokens" self.server.notify.initialized({}) # Wait for any async indexing to complete. # - Older KLS (0.253.x): indexing is synchronous inside `initialize`, no $/progress is sent, # _indexing_complete stays SET -> wait() returns immediately. # - Newer KLS (261+): server sends window/workDoneProgress/create after initialized, # which clears the event; wait() blocks until all progress tokens end. _INDEXING_TIMEOUT = 120.0 log.info("Waiting for Kotlin LSP indexing to complete (if async)...") if self._indexing_complete.wait(timeout=_INDEXING_TIMEOUT): log.info("Kotlin LSP ready") else: log.warning("Kotlin LSP did not signal indexing completion within %.0fs; proceeding anyway", _INDEXING_TIMEOUT) @override def _get_wait_time_for_cross_file_referencing(self) -> float: """Small safety buffer since we already waited for indexing to complete in _start_server.""" return 1.0 ================================================ FILE: src/solidlsp/language_servers/lean4_language_server.py ================================================ """ Provides Lean 4 specific instantiation of the LanguageServer class. Uses the built-in Lean 4 language server (lean --server). """ import logging import os import pathlib import shutil import subprocess from typing import cast from overrides import override from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) class Lean4LanguageServer(SolidLanguageServer): """ Provides Lean 4 specific instantiation of the LanguageServer class. Uses the built-in Lean 4 language server invoked via ``lean --server``. Requires ``lean`` to be installed and available on PATH (typically via elan). """ class DependencyProvider(LanguageServerDependencyProviderSinglePath): def __init__(self, custom_settings: SolidLSPSettings.CustomLSSettings, ls_resources_dir: str, repository_root_path: str): super().__init__(custom_settings, ls_resources_dir) self._repository_root_path = repository_root_path def _get_or_install_core_dependency(self) -> str: lean_path = shutil.which("lean") if lean_path is None: raise RuntimeError( "lean is not installed or not in PATH.\n" "Please install Lean 4 via elan: https://github.com/leanprover/elan\n" " curl https://raw.githubusercontent.com/leanprover/elan/master/elan-init.sh -sSf | sh\n" "After installation, make sure 'lean' is available on your PATH." ) return lean_path def _create_launch_command(self, core_path: str) -> list[str]: return [core_path, "--server"] @override def create_launch_command_env(self) -> dict[str, str]: """Provides LEAN_PATH and LEAN_SRC_PATH from ``lake env`` for cross-file references.""" env: dict[str, str] = {} lake_path = shutil.which("lake") if lake_path is None: log.warning("lake not found on PATH; cross-file references may not work") return env try: result = subprocess.run( [lake_path, "env"], check=False, cwd=self._repository_root_path, capture_output=True, text=True, timeout=30, ) if result.returncode == 0: for line in result.stdout.splitlines(): if "=" in line: key, _, value = line.partition("=") if key in ("LEAN_PATH", "LEAN_SRC_PATH"): env[key] = value log.info(f"Lake env: {key}={value}") else: log.warning(f"lake env failed (exit {result.returncode}): {result.stderr[:200]}") except Exception as e: log.warning(f"Failed to run lake env: {e}") return env def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a Lean4LanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ super().__init__( config, repository_root_path, None, "lean4", solidlsp_settings, ) def _create_dependency_provider(self) -> LanguageServerDependencyProvider: return self.DependencyProvider(self._custom_settings, self._ls_resources_dir, self.repository_root_path) @override def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in [".lake", "build"] @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the Lean 4 Language Server. """ root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "processId": os.getpid(), "rootPath": repository_absolute_path, "rootUri": root_uri, "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, "definition": {"dynamicRegistration": True, "linkSupport": True}, "references": {"dynamicRegistration": True}, "documentSymbol": { "dynamicRegistration": True, "hierarchicalDocumentSymbolSupport": True, "symbolKind": {"valueSet": list(range(1, 27))}, }, "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, "completion": { "dynamicRegistration": True, "completionItem": { "snippetSupport": True, "documentationFormat": ["markdown", "plaintext"], }, }, }, "workspace": { "workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}, }, }, "workspaceFolders": [ { "uri": root_uri, "name": os.path.basename(repository_absolute_path), } ], } return cast(InitializeParams, initialize_params) def _start_server(self) -> None: """Start the Lean 4 language server process.""" def register_capability_handler(_params: dict) -> None: return def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") def do_nothing(_params: dict) -> None: return self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_notification("window/logMessage", window_log_message) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) self.server.on_notification("$/lean/fileProgress", do_nothing) log.info("Starting Lean 4 language server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) capabilities = init_response.get("capabilities", {}) log.info(f"Lean 4 LSP capabilities: {list(capabilities.keys())}") self.server.notify.initialized({}) @override def _get_wait_time_for_cross_file_referencing(self) -> float: """Lean 4 projects need time to compile and build cross-file references.""" return 10.0 ================================================ FILE: src/solidlsp/language_servers/lua_ls.py ================================================ """ Provides Lua specific instantiation of the LanguageServer class using lua-language-server. """ import logging import os import pathlib import platform import shutil import tarfile import zipfile from pathlib import Path import requests from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) class LuaLanguageServer(SolidLanguageServer): """ Provides Lua specific instantiation of the LanguageServer class using lua-language-server. """ @override def is_ignored_dirname(self, dirname: str) -> bool: # For Lua projects, we should ignore: # - .luarocks: package manager cache # - lua_modules: local dependencies # - node_modules: if the project has JavaScript components return super().is_ignored_dirname(dirname) or dirname in [".luarocks", "lua_modules", "node_modules", "build", "dist", ".cache"] @staticmethod def _get_lua_ls_path() -> str | None: """Get the path to lua-language-server executable.""" # First check if it's in PATH lua_ls = shutil.which("lua-language-server") if lua_ls: return lua_ls # Check common installation locations home = Path.home() possible_paths = [ home / ".local" / "bin" / "lua-language-server", home / ".serena" / "language_servers" / "lua" / "bin" / "lua-language-server", Path("/usr/local/bin/lua-language-server"), Path("/opt/lua-language-server/bin/lua-language-server"), ] # Add Windows-specific paths if platform.system() == "Windows": possible_paths.extend( [ home / "AppData" / "Local" / "lua-language-server" / "bin" / "lua-language-server.exe", home / ".serena" / "language_servers" / "lua" / "bin" / "lua-language-server.exe", ] ) for path in possible_paths: if path.exists(): return str(path) return None @staticmethod def _download_lua_ls() -> str: """Download and install lua-language-server if not present.""" system = platform.system() machine = platform.machine().lower() lua_ls_version = "3.15.0" # Map platform and architecture to download URL if system == "Linux": if machine in ["x86_64", "amd64"]: download_name = f"lua-language-server-{lua_ls_version}-linux-x64.tar.gz" elif machine in ["aarch64", "arm64"]: download_name = f"lua-language-server-{lua_ls_version}-linux-arm64.tar.gz" else: raise RuntimeError(f"Unsupported Linux architecture: {machine}") elif system == "Darwin": if machine in ["x86_64", "amd64"]: download_name = f"lua-language-server-{lua_ls_version}-darwin-x64.tar.gz" elif machine in ["arm64", "aarch64"]: download_name = f"lua-language-server-{lua_ls_version}-darwin-arm64.tar.gz" else: raise RuntimeError(f"Unsupported macOS architecture: {machine}") elif system == "Windows": if machine in ["amd64", "x86_64"]: download_name = f"lua-language-server-{lua_ls_version}-win32-x64.zip" else: raise RuntimeError(f"Unsupported Windows architecture: {machine}") else: raise RuntimeError(f"Unsupported operating system: {system}") download_url = f"https://github.com/LuaLS/lua-language-server/releases/download/{lua_ls_version}/{download_name}" # Create installation directory install_dir = Path.home() / ".serena" / "language_servers" / "lua" install_dir.mkdir(parents=True, exist_ok=True) # Download the file print(f"Downloading lua-language-server from {download_url}...") response = requests.get(download_url, stream=True) response.raise_for_status() # Save and extract download_path = install_dir / download_name with open(download_path, "wb") as f: for chunk in response.iter_content(chunk_size=8192): f.write(chunk) print(f"Extracting lua-language-server to {install_dir}...") if download_name.endswith(".tar.gz"): with tarfile.open(download_path, "r:gz") as tar: tar.extractall(install_dir) elif download_name.endswith(".zip"): with zipfile.ZipFile(download_path, "r") as zip_ref: zip_ref.extractall(install_dir) # Clean up download file download_path.unlink() # Make executable on Unix systems if system != "Windows": lua_ls_path = install_dir / "bin" / "lua-language-server" if lua_ls_path.exists(): lua_ls_path.chmod(0o755) return str(lua_ls_path) else: lua_ls_path = install_dir / "bin" / "lua-language-server.exe" if lua_ls_path.exists(): return str(lua_ls_path) raise RuntimeError("Failed to find lua-language-server executable after extraction") @staticmethod def _setup_runtime_dependency() -> str: """ Check if required Lua runtime dependencies are available. Downloads lua-language-server if not present. """ lua_ls_path = LuaLanguageServer._get_lua_ls_path() if not lua_ls_path: print("lua-language-server not found. Downloading...") lua_ls_path = LuaLanguageServer._download_lua_ls() print(f"lua-language-server installed at: {lua_ls_path}") return lua_ls_path def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): lua_ls_path = self._setup_runtime_dependency() super().__init__( config, repository_root_path, ProcessLaunchInfo(cmd=lua_ls_path, cwd=repository_root_path), "lua", solidlsp_settings ) self.request_id = 0 @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the Lua Language Server. """ root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, "definition": {"dynamicRegistration": True}, "references": {"dynamicRegistration": True}, "documentSymbol": { "dynamicRegistration": True, "hierarchicalDocumentSymbolSupport": True, "symbolKind": {"valueSet": list(range(1, 27))}, }, "completion": { "dynamicRegistration": True, "completionItem": { "snippetSupport": True, "commitCharactersSupport": True, "documentationFormat": ["markdown", "plaintext"], "deprecatedSupport": True, "preselectSupport": True, }, }, "hover": { "dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"], }, "signatureHelp": { "dynamicRegistration": True, "signatureInformation": { "documentationFormat": ["markdown", "plaintext"], "parameterInformation": {"labelOffsetSupport": True}, }, }, }, "workspace": { "workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}, "configuration": True, "symbol": { "dynamicRegistration": True, "symbolKind": {"valueSet": list(range(1, 27))}, }, }, }, "processId": os.getpid(), "rootPath": repository_absolute_path, "rootUri": root_uri, "workspaceFolders": [ { "uri": root_uri, "name": os.path.basename(repository_absolute_path), } ], "initializationOptions": { # Lua Language Server specific options "runtime": { "version": "Lua 5.4", "path": ["?.lua", "?/init.lua"], }, "diagnostics": { "enable": True, "globals": ["vim", "describe", "it", "before_each", "after_each"], # Common globals }, "workspace": { "library": [], # Can be extended with project-specific libraries "checkThirdParty": False, "userThirdParty": [], }, "telemetry": { "enable": False, }, "completion": { "enable": True, "callSnippet": "Both", "keywordSnippet": "Both", }, }, } return initialize_params # type: ignore[return-value] def _start_server(self) -> None: """Start Lua Language Server process""" def register_capability_handler(params: dict) -> None: return def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") def do_nothing(params: dict) -> None: return self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_notification("window/logMessage", window_log_message) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) log.info("Starting Lua Language Server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) # Verify server capabilities assert "textDocumentSync" in init_response["capabilities"] assert "definitionProvider" in init_response["capabilities"] assert "documentSymbolProvider" in init_response["capabilities"] assert "referencesProvider" in init_response["capabilities"] self.server.notify.initialized({}) # Lua Language Server is typically ready immediately after initialization # (no need to wait for events) ================================================ FILE: src/solidlsp/language_servers/luau_lsp.py ================================================ """ Provides Luau specific instantiation of the LanguageServer class using luau-lsp. Luau is the programming language used by Roblox, derived from Lua 5.1 with additional features like type annotations, string interpolation, and more. This uses JohnnyMorganz/luau-lsp as the language server backend. Requirements: - luau-lsp binary must be installed and available in PATH, or it will be automatically downloaded from GitHub releases. Advanced settings via ls_specific_settings["luau"]: - platform: "roblox" (default) or "standard" - roblox_security_level: "None", "PluginSecurity" (default), "LocalUserSecurity", or "RobloxScriptSecurity" See: https://github.com/JohnnyMorganz/luau-lsp """ import logging import os import pathlib import platform import shutil import threading import zipfile from pathlib import Path import requests from overrides import override from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) # Pin to a known stable release LUAU_LSP_VERSION = "1.63.0" # Luau built-in docs CDN LUAU_DOCS_URL = "https://luau-lsp.pages.dev/api-docs/luau-en-us.json" # Roblox type definitions and API docs CDN ROBLOX_DOCS_URL = "https://luau-lsp.pages.dev/api-docs/en-us.json" SUPPORTED_PLATFORMS = {"roblox", "standard"} SUPPORTED_ROBLOX_SECURITY_LEVELS = { "None", "PluginSecurity", "LocalUserSecurity", "RobloxScriptSecurity", } class LuauLanguageServer(SolidLanguageServer): """ Provides Luau specific instantiation of the LanguageServer class using luau-lsp. Luau is the programming language used by Roblox (a typed superset of Lua 5.1). """ @override def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in [ "node_modules", "Packages", # Wally packages "DevPackages", # Wally dev packages "roblox_packages", # Some Rojo projects "build", "dist", ".cache", ] class DependencyProvider(LanguageServerDependencyProviderSinglePath): def _get_or_install_core_dependency(self) -> str: luau_lsp_path = shutil.which("luau-lsp") if luau_lsp_path is not None: return luau_lsp_path return self._download_luau_lsp() def _create_launch_command(self, core_path: str) -> list[str]: definitions_path, docs_path = self._resolve_support_files() cmd = [core_path, "lsp"] if definitions_path is not None: cmd.append(f"--definitions:@roblox={definitions_path}") if docs_path is not None: cmd.append(f"--docs={docs_path}") return cmd def _download_luau_lsp(self) -> str: install_dir = Path(self._ls_resources_dir) install_dir.mkdir(parents=True, exist_ok=True) binary_path = self._find_existing_binary(install_dir) if binary_path is not None: return binary_path asset_name = self._get_luau_lsp_asset_name() download_url = f"https://github.com/JohnnyMorganz/luau-lsp/releases/download/{LUAU_LSP_VERSION}/{asset_name}" download_path = install_dir / asset_name log.info("Downloading luau-lsp %s from %s", LUAU_LSP_VERSION, download_url) with requests.get(download_url, stream=True, timeout=60) as response: response.raise_for_status() with open(download_path, "wb") as f: for chunk in response.iter_content(chunk_size=8192): if chunk: f.write(chunk) log.info("Extracting luau-lsp to %s", install_dir) with zipfile.ZipFile(download_path, "r") as zip_ref: zip_ref.extractall(install_dir) if download_path.exists(): download_path.unlink() binary_path = self._find_existing_binary(install_dir) if binary_path is None: raise RuntimeError("Failed to find luau-lsp executable after extraction") return binary_path def _resolve_support_files(self) -> tuple[str | None, str | None]: platform_type = LuauLanguageServer._get_platform_type(self._custom_settings) if platform_type == "standard": return None, self._download_standard_docs() security_level = LuauLanguageServer._get_roblox_security_level(self._custom_settings) return self._download_roblox_support_files(security_level) def _download_standard_docs(self) -> str | None: install_dir = Path(self._ls_resources_dir) install_dir.mkdir(parents=True, exist_ok=True) return self._download_auxiliary_file( install_dir / "luau-en-us.json", LUAU_DOCS_URL, "Luau API docs", ) def _download_roblox_support_files(self, security_level: str) -> tuple[str | None, str | None]: install_dir = Path(self._ls_resources_dir) install_dir.mkdir(parents=True, exist_ok=True) definitions_filename = f"globalTypes.{security_level}.d.luau" definitions_path = self._download_auxiliary_file( install_dir / definitions_filename, f"https://luau-lsp.pages.dev/type-definitions/{definitions_filename}", "Roblox type definitions", ) docs_path = self._download_auxiliary_file( install_dir / "en-us.json", ROBLOX_DOCS_URL, "Roblox API docs", ) return definitions_path, docs_path @staticmethod def _download_auxiliary_file(path: Path, url: str, description: str) -> str | None: if path.exists(): return str(path) try: log.info("Downloading %s from %s", description, url) response = requests.get(url, timeout=30) response.raise_for_status() path.write_bytes(response.content) return str(path) except Exception as exc: log.warning("Failed to download %s: %s", description, exc) return None @classmethod def _find_existing_binary(cls, install_dir: Path) -> str | None: binary_name = cls._get_binary_name() direct_path = install_dir / binary_name if direct_path.exists(): cls._ensure_executable_bit(direct_path) return str(direct_path) for candidate in install_dir.rglob(binary_name): if candidate.is_file(): cls._ensure_executable_bit(candidate) return str(candidate) return None @staticmethod def _ensure_executable_bit(binary_path: Path) -> None: if platform.system() != "Windows": binary_path.chmod(0o755) @staticmethod def _get_binary_name() -> str: return "luau-lsp.exe" if platform.system() == "Windows" else "luau-lsp" @staticmethod def _get_luau_lsp_asset_name() -> str: system = platform.system() machine = platform.machine().lower() if system == "Linux": if machine in ["x86_64", "amd64"]: return "luau-lsp-linux-x86_64.zip" if machine in ["aarch64", "arm64"]: return "luau-lsp-linux-arm64.zip" raise RuntimeError( f"Unsupported Linux architecture: {machine}. " "luau-lsp only provides linux-x86_64 and linux-arm64 binaries. " "Please build from source: https://github.com/JohnnyMorganz/luau-lsp" ) if system == "Darwin": return "luau-lsp-macos.zip" if system == "Windows": return "luau-lsp-win64.zip" raise RuntimeError(f"Unsupported operating system: {system}") @staticmethod def _get_platform_type(custom_settings: SolidLSPSettings.CustomLSSettings) -> str: platform_type = custom_settings.get("platform", "roblox") if platform_type not in SUPPORTED_PLATFORMS: raise ValueError(f"Unsupported Luau platform: {platform_type}. Expected one of: {', '.join(sorted(SUPPORTED_PLATFORMS))}") return platform_type @staticmethod def _get_roblox_security_level(custom_settings: SolidLSPSettings.CustomLSSettings) -> str: security_level = custom_settings.get("roblox_security_level", "PluginSecurity") if security_level not in SUPPORTED_ROBLOX_SECURITY_LEVELS: raise ValueError( f"Unsupported Luau Roblox security level: {security_level}. " f"Expected one of: {', '.join(sorted(SUPPORTED_ROBLOX_SECURITY_LEVELS))}" ) return security_level @classmethod def _get_workspace_configuration(cls, custom_settings: SolidLSPSettings.CustomLSSettings) -> dict[str, dict[str, str]]: return {"platform": {"type": cls._get_platform_type(custom_settings)}} def _create_dependency_provider(self) -> LanguageServerDependencyProvider: return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): super().__init__(config, repository_root_path, None, "luau", solidlsp_settings) self.server_ready = threading.Event() @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the Luau Language Server. """ root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, "definition": {"dynamicRegistration": True}, "references": {"dynamicRegistration": True}, "documentSymbol": { "dynamicRegistration": True, "hierarchicalDocumentSymbolSupport": True, "symbolKind": {"valueSet": list(range(1, 27))}, }, "completion": { "dynamicRegistration": True, "completionItem": { "snippetSupport": True, "commitCharactersSupport": True, "documentationFormat": ["markdown", "plaintext"], "deprecatedSupport": True, "preselectSupport": True, }, }, "hover": { "dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"], }, "signatureHelp": { "dynamicRegistration": True, "signatureInformation": { "documentationFormat": ["markdown", "plaintext"], "parameterInformation": {"labelOffsetSupport": True}, }, }, "rename": {"dynamicRegistration": True, "prepareSupport": True}, "callHierarchy": {"dynamicRegistration": True}, }, "workspace": { "workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}, "configuration": True, "symbol": { "dynamicRegistration": True, "symbolKind": {"valueSet": list(range(1, 27))}, }, }, }, "processId": os.getpid(), "rootPath": repository_absolute_path, "rootUri": root_uri, "workspaceFolders": [ { "uri": root_uri, "name": os.path.basename(repository_absolute_path), } ], # luau-lsp initialization options # These can be overridden via .luaurc in the project root "initializationOptions": {}, } return initialize_params # type: ignore[return-value] def _start_server(self) -> None: """Start Luau Language Server process""" def register_capability_handler(params: dict) -> None: return def workspace_configuration_handler(params: dict) -> list: items = params.get("items", []) config = self._get_workspace_configuration(self._custom_settings) return [config for _ in items] def window_log_message(msg: dict) -> None: message_text = msg.get("message", "") log.info("LSP: window/logMessage: %s", message_text) if "workspace ready" in message_text.lower() or "initialized" in message_text.lower(): log.info("Luau language server signaled readiness") self.server_ready.set() def do_nothing(params: dict) -> None: return self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_request("workspace/configuration", workspace_configuration_handler) self.server.on_notification("window/logMessage", window_log_message) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) log.info("Starting Luau Language Server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) # Verify server capabilities assert "textDocumentSync" in init_response["capabilities"] assert "definitionProvider" in init_response["capabilities"] assert "documentSymbolProvider" in init_response["capabilities"] assert "referencesProvider" in init_response["capabilities"] self.server.notify.initialized({}) # Wait for luau-lsp to complete initial setup log.info("Waiting for Luau language server to become ready...") if self.server_ready.wait(timeout=5.0): log.info("Luau language server ready") else: log.warning("Timeout waiting for Luau language server readiness, proceeding anyway") self.server_ready.set() ================================================ FILE: src/solidlsp/language_servers/marksman.py ================================================ """ Provides Markdown specific instantiation of the LanguageServer class using marksman. Contains various configurations and settings specific to Markdown. """ import logging import os import pathlib from collections.abc import Hashable from overrides import override from solidlsp.ls import ( DocumentSymbols, LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, LSPFileBuffer, SolidLanguageServer, ) from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_types import SymbolKind, UnifiedSymbolInformation from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings from .common import RuntimeDependency, RuntimeDependencyCollection log = logging.getLogger(__name__) class Marksman(SolidLanguageServer): """ Provides Markdown specific instantiation of the LanguageServer class using marksman. """ class DependencyProvider(LanguageServerDependencyProviderSinglePath): marksman_releases = "https://github.com/artempyanykh/marksman/releases/download/2024-12-18" runtime_dependencies = RuntimeDependencyCollection( [ RuntimeDependency( id="marksman", url=f"{marksman_releases}/marksman-linux-x64", platform_id="linux-x64", archive_type="binary", binary_name="marksman", ), RuntimeDependency( id="marksman", url=f"{marksman_releases}/marksman-linux-arm64", platform_id="linux-arm64", archive_type="binary", binary_name="marksman", ), RuntimeDependency( id="marksman", url=f"{marksman_releases}/marksman-macos", platform_id="osx-x64", archive_type="binary", binary_name="marksman", ), RuntimeDependency( id="marksman", url=f"{marksman_releases}/marksman-macos", platform_id="osx-arm64", archive_type="binary", binary_name="marksman", ), RuntimeDependency( id="marksman", url=f"{marksman_releases}/marksman.exe", platform_id="win-x64", archive_type="binary", binary_name="marksman.exe", ), ] ) def _get_or_install_core_dependency(self) -> str: """Setup runtime dependencies for marksman and return the command to start the server.""" deps = self.runtime_dependencies dependency = deps.get_single_dep_for_current_platform() marksman_ls_dir = self._ls_resources_dir marksman_executable_path = deps.binary_path(marksman_ls_dir) if not os.path.exists(marksman_executable_path): log.info( f"Downloading marksman from {dependency.url} to {marksman_ls_dir}", ) deps.install(marksman_ls_dir) if not os.path.exists(marksman_executable_path): raise FileNotFoundError(f"Download failed? Could not find marksman executable at {marksman_executable_path}") os.chmod(marksman_executable_path, 0o755) return marksman_executable_path def _create_launch_command(self, core_path: str) -> list[str]: return [core_path, "server"] def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a Marksman instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ super().__init__( config, repository_root_path, None, "markdown", solidlsp_settings, ) def _create_dependency_provider(self) -> LanguageServerDependencyProvider: return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) @override def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in ["node_modules", ".obsidian", ".vitepress", ".vuepress"] def _document_symbols_cache_fingerprint(self) -> Hashable | None: request_document_symbols_override_version = 1 return request_document_symbols_override_version @override def request_document_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None) -> DocumentSymbols: """Override to remap Marksman's heading symbol kinds from String to Namespace. Marksman LSP returns all markdown headings (h1-h6) with SymbolKind.String (15). This is problematic because String (15) >= Variable (13), so headings are classified as "low-level" and filtered out of symbol overviews. Remapping to Namespace (3) fixes this and is semantically appropriate (headings are named sections containing other content). """ document_symbols = super().request_document_symbols(relative_file_path, file_buffer=file_buffer) # NOTE: When changing this method, also update the cache fingerprint method above def remap_heading_kinds(symbol: UnifiedSymbolInformation) -> None: if symbol["kind"] == SymbolKind.String: symbol["kind"] = SymbolKind.Namespace for child in symbol.get("children", []): remap_heading_kinds(child) for sym in document_symbols.root_symbols: remap_heading_kinds(sym) return document_symbols @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the Marksman Language Server. """ root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params: InitializeParams = { # type: ignore "processId": os.getpid(), "locale": "en", "rootPath": repository_absolute_path, "rootUri": root_uri, "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, "definition": {"dynamicRegistration": True}, "references": {"dynamicRegistration": True}, "documentSymbol": { "dynamicRegistration": True, "hierarchicalDocumentSymbolSupport": True, "symbolKind": {"valueSet": list(range(1, 27))}, # type: ignore[arg-type] }, "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, # type: ignore[list-item] "codeAction": {"dynamicRegistration": True}, }, "workspace": { "workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}, "symbol": {"dynamicRegistration": True}, }, }, "workspaceFolders": [ { "uri": root_uri, "name": os.path.basename(repository_absolute_path), } ], } return initialize_params def _start_server(self) -> None: """ Starts the Marksman Language Server and waits for it to be ready. """ def register_capability_handler(_params: dict) -> None: return def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") def do_nothing(_params: dict) -> None: return self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_notification("window/logMessage", window_log_message) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) log.info("Starting marksman server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.info("Sending initialize request from LSP client to marksman server and awaiting response") init_response = self.server.send.initialize(initialize_params) log.debug(f"Received initialize response from marksman server: {init_response}") # Verify server capabilities assert "textDocumentSync" in init_response["capabilities"] assert "completionProvider" in init_response["capabilities"] assert "definitionProvider" in init_response["capabilities"] self.server.notify.initialized({}) # marksman is typically ready immediately after initialization log.info("Marksman server initialization complete") ================================================ FILE: src/solidlsp/language_servers/matlab_language_server.py ================================================ """ MATLAB language server integration using the official MathWorks MATLAB Language Server. Architecture: This module uses the MathWorks MATLAB VS Code extension (mathworks.language-matlab) which contains a Node.js-based language server. The extension is downloaded from the VS Code Marketplace and extracted locally. The language server spawns a real MATLAB process to provide code intelligence - it is NOT a standalone static analyzer. Flow: Serena -> Node.js LSP Server -> MATLAB Process -> Code Analysis Why MATLAB installation is required: The language server launches an actual MATLAB session (via MatlabSession.js) to perform code analysis, diagnostics, and other features. Without MATLAB, the LSP cannot function. This is different from purely static analyzers that parse code without execution. Requirements: - MATLAB R2021b or later must be installed and licensed - Node.js must be installed (for running the language server) - MATLAB path can be specified via MATLAB_PATH environment variable or auto-detected The MATLAB language server provides: - Code diagnostics (publishDiagnostics) - Code completions (completionProvider) - Go to definition (definitionProvider) - Find references (referencesProvider) - Document symbols (documentSymbol) - Document formatting (documentFormattingProvider) - Function signature help (signatureHelpProvider) - Symbol rename (renameProvider) """ import glob import logging import os import pathlib import platform import shutil import threading import zipfile from typing import Any, cast import requests from solidlsp.ls import LanguageServerDependencyProvider, LSPFileBuffer, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import DocumentSymbol, InitializeParams, SymbolInformation from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) # Environment variable for MATLAB installation path MATLAB_PATH_ENV_VAR = "MATLAB_PATH" # VS Code Marketplace URL for MATLAB extension MATLAB_EXTENSION_URL = ( "https://marketplace.visualstudio.com/_apis/public/gallery/publishers/MathWorks/vsextensions/language-matlab/latest/vspackage" ) class MatlabLanguageServer(SolidLanguageServer): """ Provides MATLAB specific instantiation of the LanguageServer class using the official MathWorks MATLAB Language Server. The MATLAB language server requires: - MATLAB R2021b or later installed on the system - Node.js for running the language server The language server is automatically downloaded from the VS Code marketplace (MathWorks.language-matlab extension) and extracted. You can pass the following entries in ls_specific_settings["matlab"]: - matlab_path: Path to MATLAB installation (overrides MATLAB_PATH env var) """ def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a MatlabLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ super().__init__( config, repository_root_path, None, "matlab", solidlsp_settings, ) assert isinstance(self._dependency_provider, self.DependencyProvider) self._matlab_path = self._dependency_provider.get_matlab_path() self.server_ready = threading.Event() self.initialize_searcher_command_available = threading.Event() def _create_dependency_provider(self) -> LanguageServerDependencyProvider: return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) class DependencyProvider(LanguageServerDependencyProvider): def __init__(self, custom_settings: SolidLSPSettings.CustomLSSettings, ls_resources_dir: str): super().__init__(custom_settings, ls_resources_dir) self._matlab_path: str | None = None @classmethod def _download_matlab_extension(cls, url: str, target_dir: str) -> bool: """ Download and extract the MATLAB extension from VS Code marketplace. The VS Code marketplace packages extensions as .vsix files (which are ZIP archives). This method downloads the VSIX file and extracts it to get the language server. Args: url: VS Code marketplace URL for the MATLAB extension target_dir: Directory where the extension will be extracted Returns: True if successful, False otherwise """ try: log.info(f"Downloading MATLAB extension from {url}") # Create target directory for the extension os.makedirs(target_dir, exist_ok=True) # Download with proper headers to mimic VS Code marketplace client headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "application/octet-stream, application/vsix, */*", } response = requests.get(url, headers=headers, stream=True, timeout=300) response.raise_for_status() # Save to temporary VSIX file temp_file = os.path.join(target_dir, "matlab_extension_temp.vsix") total_size = int(response.headers.get("content-length", 0)) log.info(f"Downloading {total_size / 1024 / 1024:.1f} MB...") with open(temp_file, "wb") as f: downloaded = 0 for chunk in response.iter_content(chunk_size=8192): if chunk: f.write(chunk) downloaded += len(chunk) if total_size > 0 and downloaded % (10 * 1024 * 1024) == 0: progress = (downloaded / total_size) * 100 log.info(f"Download progress: {progress:.1f}%") log.info("Download complete, extracting...") # Extract VSIX file (VSIX files are ZIP archives) with zipfile.ZipFile(temp_file, "r") as zip_ref: zip_ref.extractall(target_dir) # Clean up temp file os.remove(temp_file) log.info("MATLAB extension extracted successfully") return True except Exception as e: log.error(f"Error downloading/extracting MATLAB extension: {e}") return False def _find_matlab_extension(self) -> str | None: """ Find MATLAB extension in various locations. Search order: 1. Environment variable (MATLAB_EXTENSION_PATH) 2. Default download location (~/.serena/ls_resources/matlab-extension) 3. VS Code installed extensions Returns: Path to MATLAB extension directory or None if not found """ # Check environment variable env_path = os.environ.get("MATLAB_EXTENSION_PATH") if env_path and os.path.exists(env_path): log.debug(f"Found MATLAB extension via MATLAB_EXTENSION_PATH: {env_path}") return env_path elif env_path: log.warning(f"MATLAB_EXTENSION_PATH set but directory not found: {env_path}") # Check default download location default_path = os.path.join(self._ls_resources_dir, "matlab-extension", "extension") if os.path.exists(default_path): log.debug(f"Found MATLAB extension in default location: {default_path}") return default_path # Search VS Code extensions vscode_extensions_dir = os.path.expanduser("~/.vscode/extensions") if os.path.exists(vscode_extensions_dir): for entry in os.listdir(vscode_extensions_dir): if entry.startswith("mathworks.language-matlab"): ext_path = os.path.join(vscode_extensions_dir, entry) if os.path.isdir(ext_path): log.debug(f"Found MATLAB extension in VS Code: {ext_path}") return ext_path log.debug("MATLAB extension not found in any known location") return None def _download_and_install_matlab_extension(self) -> str | None: """ Download and install MATLAB extension from VS Code marketplace. Returns: Path to installed extension or None if download failed """ matlab_extension_dir = os.path.join(self._ls_resources_dir, "matlab-extension") log.info(f"Downloading MATLAB extension from: {MATLAB_EXTENSION_URL}") if self._download_matlab_extension(MATLAB_EXTENSION_URL, matlab_extension_dir): extension_path = os.path.join(matlab_extension_dir, "extension") if os.path.exists(extension_path): log.info("MATLAB extension downloaded and installed successfully") return extension_path else: log.error(f"Download completed but extension not found at: {extension_path}") else: log.error("Failed to download MATLAB extension from marketplace") return None @classmethod def _get_executable_path(cls, extension_path: str) -> str: """ Get the path to the MATLAB language server executable based on platform. The language server is a Node.js script located in the extension's server directory. """ # The MATLAB extension bundles the language server in the 'server' directory server_dir = os.path.join(extension_path, "server", "out") main_script = os.path.join(server_dir, "index.js") if os.path.exists(main_script): return main_script # Alternative location alt_script = os.path.join(extension_path, "out", "index.js") if os.path.exists(alt_script): return alt_script raise RuntimeError(f"MATLAB language server script not found in extension at {extension_path}") @staticmethod def _find_matlab_installation() -> str: """ Find MATLAB installation path. Search order: 1. MATLAB_PATH environment variable 2. Common installation locations based on platform Returns: Path to MATLAB installation directory. Raises: RuntimeError: If MATLAB installation is not found. """ # Check environment variable first matlab_path = os.environ.get(MATLAB_PATH_ENV_VAR) if matlab_path and os.path.isdir(matlab_path): log.info(f"Using MATLAB from environment variable {MATLAB_PATH_ENV_VAR}: {matlab_path}") return matlab_path system = platform.system() if system == "Darwin": # macOS # Check common macOS locations search_patterns = [ "/Applications/MATLAB_*.app", "/Volumes/*/Applications/MATLAB_*.app", os.path.expanduser("~/Applications/MATLAB_*.app"), ] for pattern in search_patterns: matches = sorted(glob.glob(pattern), reverse=True) # Newest version first for match in matches: if os.path.isdir(match): log.info(f"Found MATLAB installation: {match}") return match elif system == "Windows": # Check common Windows locations search_patterns = [ "C:\\Program Files\\MATLAB\\R*", "C:\\Program Files (x86)\\MATLAB\\R*", ] for pattern in search_patterns: matches = sorted(glob.glob(pattern), reverse=True) for match in matches: if os.path.isdir(match): log.info(f"Found MATLAB installation: {match}") return match elif system == "Linux": # Check common Linux locations search_patterns = [ "/usr/local/MATLAB/R*", "/opt/MATLAB/R*", os.path.expanduser("~/MATLAB/R*"), ] for pattern in search_patterns: matches = sorted(glob.glob(pattern), reverse=True) for match in matches: if os.path.isdir(match): log.info(f"Found MATLAB installation: {match}") return match raise RuntimeError( f"MATLAB installation not found. Set the {MATLAB_PATH_ENV_VAR} environment variable " "to your MATLAB installation directory (e.g., /Applications/MATLAB_R2024b.app on macOS, " "C:\\Program Files\\MATLAB\\R2024b on Windows, or /usr/local/MATLAB/R2024b on Linux)." ) def get_matlab_path(self) -> str: """Get MATLAB path from settings or auto-detect.""" if self._matlab_path is not None: return self._matlab_path matlab_path = self._custom_settings.get("matlab_path") if not matlab_path: matlab_path = self._find_matlab_installation() # Raises RuntimeError if not found # Verify MATLAB path exists if not os.path.isdir(matlab_path): raise RuntimeError(f"MATLAB installation directory does not exist: {matlab_path}") log.info(f"Using MATLAB installation: {matlab_path}") self._matlab_path = matlab_path return matlab_path def create_launch_command(self) -> list[str]: # Verify node is installed node_path = shutil.which("node") if node_path is None: raise RuntimeError("Node.js is not installed or isn't in PATH. Please install Node.js and try again.") # Find existing extension or download if needed extension_path = self._find_matlab_extension() if extension_path is None: log.info("MATLAB extension not found on disk, attempting to download...") extension_path = self._download_and_install_matlab_extension() if extension_path is None: raise RuntimeError( "Failed to locate or download MATLAB Language Server. Please either:\n" "1. Set MATLAB_EXTENSION_PATH environment variable to the MATLAB extension directory\n" "2. Install the MATLAB extension in VS Code (MathWorks.language-matlab)\n" "3. Ensure internet connection for automatic download" ) # Get the language server script path server_script = self._get_executable_path(extension_path) if not os.path.exists(server_script): raise RuntimeError(f"MATLAB Language Server script not found at: {server_script}") # Build the command to run the language server # The MATLAB language server is run via Node.js with the --stdio flag cmd = [node_path, server_script, "--stdio"] return cmd def create_launch_command_env(self) -> dict[str, str]: return { "MATLAB_INSTALL_PATH": self.get_matlab_path(), } @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """Return the initialize params for the MATLAB Language Server.""" root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, "completion": { "dynamicRegistration": True, "completionItem": {"snippetSupport": True}, }, "definition": {"dynamicRegistration": True}, "references": {"dynamicRegistration": True}, "documentSymbol": { "dynamicRegistration": True, "hierarchicalDocumentSymbolSupport": True, "symbolKind": {"valueSet": list(range(1, 27))}, }, "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, "signatureHelp": {"dynamicRegistration": True}, "codeAction": {"dynamicRegistration": True}, "formatting": {"dynamicRegistration": True}, "rename": {"dynamicRegistration": True, "prepareSupport": True}, "publishDiagnostics": {"relatedInformation": True}, }, "workspace": { "workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}, "symbol": {"dynamicRegistration": True}, }, }, "processId": os.getpid(), "rootPath": repository_absolute_path, "rootUri": root_uri, "workspaceFolders": [ { "uri": root_uri, "name": os.path.basename(repository_absolute_path), } ], } return cast(InitializeParams, initialize_params) def _start_server(self) -> None: """Start the MATLAB Language Server and wait for it to be ready.""" root_uri = pathlib.Path(self.repository_root_path).as_uri() def register_capability_handler(params: dict) -> None: assert "registrations" in params for registration in params["registrations"]: if registration["method"] == "workspace/executeCommand": self.initialize_searcher_command_available.set() return def execute_client_command_handler(params: dict) -> list: return [] def workspace_folders_handler(params: dict) -> list: """Handle workspace/workspaceFolders request from the server.""" return [{"uri": root_uri, "name": os.path.basename(self.repository_root_path)}] def workspace_configuration_handler(params: dict) -> list: """Handle workspace/configuration request from the server.""" items = params.get("items", []) result = [] for item in items: section = item.get("section", "") if section == "MATLAB": # Return MATLAB configuration result.append({"installPath": self._matlab_path, "matlabConnectionTiming": "onStart"}) else: result.append({}) return result def do_nothing(params: dict) -> None: return def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") message_text = msg.get("message", "") # Check for MATLAB language server ready signals # Wait for "MVM attach success" or "Adding workspace folder" which indicates MATLAB is fully ready # Note: "connected to" comes earlier but the server isn't fully ready at that point if "mvm attach success" in message_text.lower() or "adding workspace folder" in message_text.lower(): log.info("MATLAB language server ready signal detected (MVM attached)") self.server_ready.set() self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_notification("window/logMessage", window_log_message) self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) self.server.on_request("workspace/workspaceFolders", workspace_folders_handler) self.server.on_request("workspace/configuration", workspace_configuration_handler) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) log.info("Starting MATLAB server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) log.debug(f"Received initialize response from MATLAB server: {init_response}") # Verify basic capabilities capabilities = init_response.get("capabilities", {}) assert capabilities.get("textDocumentSync") in [1, 2], "Expected Full or Incremental text sync" # Log available capabilities if "completionProvider" in capabilities: log.info("MATLAB server supports completions") if "definitionProvider" in capabilities: log.info("MATLAB server supports go-to-definition") if "referencesProvider" in capabilities: log.info("MATLAB server supports find-references") if "documentSymbolProvider" in capabilities: log.info("MATLAB server supports document symbols") if "documentFormattingProvider" in capabilities: log.info("MATLAB server supports document formatting") if "renameProvider" in capabilities: log.info("MATLAB server supports rename") self.server.notify.initialized({}) # Wait for server readiness with timeout # MATLAB takes longer to start than most language servers (typically 10-30 seconds) log.info("Waiting for MATLAB language server to be ready (this may take up to 60 seconds)...") if not self.server_ready.wait(timeout=60.0): # Fallback: assume server is ready after timeout log.info("Timeout waiting for MATLAB server ready signal, proceeding anyway") self.server_ready.set() else: log.info("MATLAB server initialization complete") def is_ignored_dirname(self, dirname: str) -> bool: """Define MATLAB-specific directories to ignore.""" return super().is_ignored_dirname(dirname) or dirname in [ "slprj", # Simulink project files "codegen", # Code generation output "sldemo_cache", # Simulink demo cache "helperFiles", # Common helper file directories ] def _request_document_symbols( self, relative_file_path: str, file_data: LSPFileBuffer | None ) -> list[SymbolInformation] | list[DocumentSymbol] | None: """ Override to normalize MATLAB symbol names. The MATLAB LSP sometimes returns symbol names as lists instead of strings, particularly for script sections (cell mode markers like %%). This method normalizes the names to strings for compatibility with the unified symbol format. """ symbols = super()._request_document_symbols(relative_file_path, file_data) if symbols is None or len(symbols) == 0: return symbols self._normalize_matlab_symbols(symbols) return symbols def _normalize_matlab_symbols(self, symbols: list[SymbolInformation] | list[DocumentSymbol]) -> None: """ Normalize MATLAB symbol names in-place. MATLAB LSP returns section names as lists like ["Section Name"] instead of strings. This converts them to plain strings. """ for symbol in symbols: # MATLAB LSP returns names as lists for script sections, violating LSP spec # Cast to Any to handle runtime type that differs from spec name: Any = symbol.get("name") if isinstance(name, list): symbol["name"] = name[0] if name else "" log.debug("Normalized MATLAB symbol name from list to string") # Recursively normalize children if present children: Any = symbol.get("children") if children and isinstance(children, list): self._normalize_matlab_symbols(children) ================================================ FILE: src/solidlsp/language_servers/nixd_ls.py ================================================ # type: ignore """ Provides Nix specific instantiation of the LanguageServer class using nixd (Nix Language Server). Note: Windows is not supported as Nix itself doesn't support Windows natively. """ import logging import os import pathlib import platform import shutil import subprocess from pathlib import Path from overrides import override from solidlsp import ls_types from solidlsp.ls import DocumentSymbols, LSPFileBuffer, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) class NixLanguageServer(SolidLanguageServer): """ Provides Nix specific instantiation of the LanguageServer class using nixd. """ def _extend_nix_symbol_range_to_include_semicolon( self, symbol: ls_types.UnifiedSymbolInformation, file_content: str ) -> ls_types.UnifiedSymbolInformation: """ Extend symbol range to include trailing semicolon for Nix attribute symbols. nixd provides ranges that exclude semicolons (expression-level), but serena needs statement-level ranges that include semicolons for proper replacement. """ range_info = symbol["range"] end_line = range_info["end"]["line"] end_char = range_info["end"]["character"] # Split file content into lines lines = file_content.split("\n") if end_line >= len(lines): return symbol line = lines[end_line] # Check if there's a semicolon immediately after the current range end if end_char < len(line) and line[end_char] == ";": # Extend range to include the semicolon new_range = {"start": range_info["start"], "end": {"line": end_line, "character": end_char + 1}} # Create modified symbol with extended range extended_symbol = symbol.copy() extended_symbol["range"] = new_range # CRITICAL: Also update the location.range if it exists if extended_symbol.get("location"): location = extended_symbol["location"].copy() if "range" in location: location["range"] = new_range.copy() extended_symbol["location"] = location return extended_symbol return symbol @override def request_document_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None) -> DocumentSymbols: # Override to extend Nix symbol ranges to include trailing semicolons. # nixd provides expression-level ranges (excluding semicolons) but serena needs # statement-level ranges (including semicolons) for proper symbol replacement. # Get symbols from parent implementation document_symbols = super().request_document_symbols(relative_file_path, file_buffer=file_buffer) # Get file content for range extension file_content = self.language_server.retrieve_full_file_content(relative_file_path) # Extend ranges for all symbols recursively def extend_symbol_and_children(symbol: ls_types.UnifiedSymbolInformation) -> ls_types.UnifiedSymbolInformation: # Extend this symbol's range extended = self._extend_nix_symbol_range_to_include_semicolon(symbol, file_content) # Extend children recursively if extended.get("children"): extended["children"] = [extend_symbol_and_children(child) for child in extended["children"]] return extended # Apply range extension to all symbols extended_root_symbols = [extend_symbol_and_children(sym) for sym in document_symbols.root_symbols] return DocumentSymbols(extended_root_symbols) @override def is_ignored_dirname(self, dirname: str) -> bool: # For Nix projects, we should ignore: # - result: nix build output symlinks # - result-*: multiple build outputs # - .direnv: direnv cache return super().is_ignored_dirname(dirname) or dirname in ["result", ".direnv"] or dirname.startswith("result-") @staticmethod def _get_nixd_version(): """Get the installed nixd version or None if not found.""" try: result = subprocess.run(["nixd", "--version"], capture_output=True, text=True, check=False) if result.returncode == 0: # nixd outputs version like: nixd 2.0.0 return result.stdout.strip() except FileNotFoundError: return None return None @staticmethod def _check_nixd_installed(): """Check if nixd is installed in the system.""" return shutil.which("nixd") is not None @staticmethod def _get_nixd_path(): """Get the path to nixd executable.""" # First check if it's in PATH nixd_path = shutil.which("nixd") if nixd_path: return nixd_path # Check common installation locations home = Path.home() possible_paths = [ home / ".local" / "bin" / "nixd", home / ".serena" / "language_servers" / "nixd" / "nixd", home / ".nix-profile" / "bin" / "nixd", Path("/usr/local/bin/nixd"), Path("/run/current-system/sw/bin/nixd"), # NixOS system profile Path("/opt/homebrew/bin/nixd"), # Homebrew on Apple Silicon Path("/usr/local/opt/nixd/bin/nixd"), # Homebrew on Intel Mac ] # Add Windows-specific paths if platform.system() == "Windows": possible_paths.extend( [ home / "AppData" / "Local" / "nixd" / "nixd.exe", home / ".serena" / "language_servers" / "nixd" / "nixd.exe", ] ) for path in possible_paths: if path.exists(): return str(path) return None @staticmethod def _install_nixd_with_nix(): """Install nixd using nix if available.""" # Check if nix is available if not shutil.which("nix"): return None print("Installing nixd using nix... This may take a few minutes.") try: # Try to install nixd using nix profile result = subprocess.run( ["nix", "profile", "install", "github:nix-community/nixd"], capture_output=True, text=True, check=False, timeout=600, # 10 minute timeout for building ) if result.returncode == 0: # Check if nixd is now in PATH nixd_path = shutil.which("nixd") if nixd_path: print(f"Successfully installed nixd at: {nixd_path}") return nixd_path else: # Try nix-env as fallback result = subprocess.run( ["nix-env", "-iA", "nixpkgs.nixd"], capture_output=True, text=True, check=False, timeout=600, ) if result.returncode == 0: nixd_path = shutil.which("nixd") if nixd_path: print(f"Successfully installed nixd at: {nixd_path}") return nixd_path print(f"Failed to install nixd: {result.stderr}") except subprocess.TimeoutExpired: print("Nix install timed out after 10 minutes") except Exception as e: print(f"Error installing nixd with nix: {e}") return None @staticmethod def _setup_runtime_dependency(): """ Check if required Nix runtime dependencies are available. Attempts to install nixd if not present. """ # First check if Nix is available (nixd needs it at runtime) if not shutil.which("nix"): print("WARNING: Nix is not installed. nixd requires Nix to function properly.") raise RuntimeError("Nix is required for nixd. Please install Nix from https://nixos.org/download.html") nixd_path = NixLanguageServer._get_nixd_path() if not nixd_path: print("nixd not found. Attempting to install...") # Try to install with nix if available nixd_path = NixLanguageServer._install_nixd_with_nix() if not nixd_path: raise RuntimeError( "nixd (Nix Language Server) is not installed.\n" "Please install nixd using one of the following methods:\n" " - Using Nix flakes: nix profile install github:nix-community/nixd\n" " - From nixpkgs: nix-env -iA nixpkgs.nixd\n" " - On macOS with Homebrew: brew install nixd\n\n" "After installation, make sure 'nixd' is in your PATH." ) # Verify nixd works try: result = subprocess.run([nixd_path, "--version"], capture_output=True, text=True, check=False, timeout=5) if result.returncode != 0: raise RuntimeError(f"nixd failed to run: {result.stderr}") except Exception as e: raise RuntimeError(f"Failed to verify nixd installation: {e}") return nixd_path def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): nixd_path = self._setup_runtime_dependency() super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd=nixd_path, cwd=repository_root_path), "nix", solidlsp_settings) self.request_id = 0 @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for nixd. """ root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, "definition": {"dynamicRegistration": True}, "references": {"dynamicRegistration": True}, "documentSymbol": { "dynamicRegistration": True, "hierarchicalDocumentSymbolSupport": True, "symbolKind": {"valueSet": list(range(1, 27))}, }, "completion": { "dynamicRegistration": True, "completionItem": { "snippetSupport": True, "commitCharactersSupport": True, "documentationFormat": ["markdown", "plaintext"], "deprecatedSupport": True, "preselectSupport": True, }, }, "hover": { "dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"], }, "signatureHelp": { "dynamicRegistration": True, "signatureInformation": { "documentationFormat": ["markdown", "plaintext"], "parameterInformation": {"labelOffsetSupport": True}, }, }, "codeAction": { "dynamicRegistration": True, "codeActionLiteralSupport": { "codeActionKind": { "valueSet": [ "", "quickfix", "refactor", "refactor.extract", "refactor.inline", "refactor.rewrite", "source", "source.organizeImports", ] } }, }, "rename": {"dynamicRegistration": True, "prepareSupport": True}, }, "workspace": { "workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}, "configuration": True, "symbol": { "dynamicRegistration": True, "symbolKind": {"valueSet": list(range(1, 27))}, }, }, }, "processId": os.getpid(), "rootPath": repository_absolute_path, "rootUri": root_uri, "workspaceFolders": [ { "uri": root_uri, "name": os.path.basename(repository_absolute_path), } ], "initializationOptions": { # nixd specific options "nixpkgs": {"expr": "import { }"}, "formatting": {"command": ["nixpkgs-fmt"]}, # or ["alejandra"] or ["nixfmt"] "options": { "enable": True, "target": { "installable": "", # Will be auto-detected from flake.nix if present }, }, }, } return initialize_params def _start_server(self): """Start nixd server process""" def register_capability_handler(params): return def window_log_message(msg): log.info(f"LSP: window/logMessage: {msg}") def do_nothing(params): return self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_notification("window/logMessage", window_log_message) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) log.info("Starting nixd server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) # Verify server capabilities assert "textDocumentSync" in init_response["capabilities"] assert "definitionProvider" in init_response["capabilities"] assert "documentSymbolProvider" in init_response["capabilities"] assert "referencesProvider" in init_response["capabilities"] self.server.notify.initialized({}) # nixd server is typically ready immediately after initialization ================================================ FILE: src/solidlsp/language_servers/ocaml_lsp_server.py ================================================ """ Provides OCaml and Reason specific instantiation of the SolidLanguageServer class. Contains various configurations and settings specific to OCaml and Reason. """ import logging import os import pathlib import platform import re import shutil import stat import subprocess import threading from typing import Any from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings from solidlsp.util.subprocess_util import subprocess_kwargs log = logging.getLogger(__name__) class OcamlLanguageServer(SolidLanguageServer): """ Provides OCaml and Reason specific instantiation of the SolidLanguageServer class. Contains various configurations and settings specific to OCaml and Reason. """ _ocaml_version: tuple[int, int, int] _lsp_version: tuple[int, int, int] _index_built: bool # Minimum LSP version for reliable cross-file references MIN_LSP_VERSION_FOR_CROSS_FILE_REFS: tuple[int, int, int] = (1, 23, 0) @staticmethod def _ensure_opam_installed() -> None: """Ensure OPAM is installed and available.""" opam_path = shutil.which("opam") if opam_path is None: raise RuntimeError( "OPAM is not installed or not in PATH.\n" "Please install OPAM from: https://opam.ocaml.org/doc/Install.html\n\n" "Installation instructions:\n" " - macOS: brew install opam\n" " - Ubuntu/Debian: sudo apt install opam\n" " - Fedora: sudo dnf install opam\n" " - Windows: https://fdopen.github.io/opam-repository-mingw/installation/\n\n" "After installation, initialize OPAM with: opam init" ) @staticmethod def _detect_ocaml_version(repository_root_path: str) -> tuple[int, int, int]: """ Detect and return the OCaml version as a tuple (major, minor, patch). Also checks for version compatibility with ocaml-lsp-server. Raises RuntimeError if version cannot be determined. """ try: result = subprocess.run( ["opam", "exec", "--", "ocaml", "-version"], check=True, capture_output=True, text=True, cwd=repository_root_path, **subprocess_kwargs(), ) version_match = re.search(r"(\d+)\.(\d+)\.(\d+)", result.stdout) if version_match: major = int(version_match.group(1)) minor = int(version_match.group(2)) patch = int(version_match.group(3)) version_tuple = (major, minor, patch) version_str = f"{major}.{minor}.{patch}" log.info(f"OCaml version: {version_str}") if version_tuple == (5, 1, 0): raise RuntimeError( f"OCaml {version_str} is incompatible with ocaml-lsp-server.\n" "Please use OCaml < 5.1 or >= 5.1.1.\n" "Consider creating a new opam switch:\n" " opam switch create ocaml-base-compiler.4.14.2" ) return version_tuple raise RuntimeError( f"Could not parse OCaml version from output: {result.stdout.strip()}\n" "Please ensure OCaml is properly installed: opam exec -- ocaml -version" ) except subprocess.CalledProcessError as e: raise RuntimeError( f"Failed to detect OCaml version: {e.stderr}\n" "Please ensure OCaml is installed and opam is configured:\n" " opam switch show\n" " opam exec -- ocaml -version" ) from e except FileNotFoundError as e: raise RuntimeError( "OCaml not found. Please install OCaml via opam:\n" " opam switch create ocaml-base-compiler.4.14.2\n" " eval $(opam env)" ) from e @staticmethod def _detect_lsp_version(repository_root_path: str) -> tuple[int, int, int]: """ Detect and return the ocaml-lsp-server version as a tuple (major, minor, patch). Raises RuntimeError if version cannot be determined. """ try: result = subprocess.run( ["opam", "list", "-i", "ocaml-lsp-server", "--columns=version", "--short"], check=True, capture_output=True, text=True, cwd=repository_root_path, **subprocess_kwargs(), ) version_str = result.stdout.strip() version_match = re.search(r"(\d+)\.(\d+)\.(\d+)", version_str) if version_match: major = int(version_match.group(1)) minor = int(version_match.group(2)) patch = int(version_match.group(3)) version_tuple = (major, minor, patch) log.info(f"ocaml-lsp-server version: {major}.{minor}.{patch}") return version_tuple raise RuntimeError( f"Could not parse ocaml-lsp-server version from output: {version_str}\n" "Please ensure ocaml-lsp-server is properly installed:\n" " opam list -i ocaml-lsp-server" ) except subprocess.CalledProcessError as e: raise RuntimeError( f"Failed to detect ocaml-lsp-server version: {e.stderr}\n" "Please install ocaml-lsp-server:\n" " opam install ocaml-lsp-server" ) from e except FileNotFoundError as e: raise RuntimeError("opam not found. Please install opam:\n https://opam.ocaml.org/doc/Install.html") from e @staticmethod def _ensure_ocaml_lsp_installed(repository_root_path: str) -> str: """ Ensure ocaml-lsp-server is installed and return the executable path. Raises RuntimeError with helpful message if not installed. """ # Check if ocaml-lsp-server is installed try: result = subprocess.run( ["opam", "list", "-i", "ocaml-lsp-server"], check=False, capture_output=True, text=True, cwd=repository_root_path, **subprocess_kwargs(), ) if "ocaml-lsp-server" not in result.stdout or "# No matches found" in result.stdout: raise RuntimeError( "ocaml-lsp-server is not installed.\n\n" "Please install it with:\n" " opam install ocaml-lsp-server\n\n" "Note: ocaml-lsp-server requires OCaml < 5.1 or >= 5.1.1 (OCaml 5.1.0 is not supported).\n" "If you have OCaml 5.1.0, create a new opam switch with a compatible version:\n" " opam switch create ocaml-base-compiler.4.14.2\n" " opam switch \n" " eval $(opam env)\n" " opam install ocaml-lsp-server\n\n" "For more information: https://github.com/ocaml/ocaml-lsp" ) log.info("ocaml-lsp-server is installed") except subprocess.CalledProcessError as e: raise RuntimeError(f"Failed to check ocaml-lsp-server installation: {e.stderr}") # Find the executable path try: if platform.system() == "Windows": result = subprocess.run( ["opam", "exec", "--", "where", "ocamllsp"], check=True, capture_output=True, text=True, cwd=repository_root_path, **subprocess_kwargs(), ) executable_path = result.stdout.strip().split("\n")[0] else: result = subprocess.run( ["opam", "exec", "--", "which", "ocamllsp"], check=True, capture_output=True, text=True, cwd=repository_root_path, **subprocess_kwargs(), ) executable_path = result.stdout.strip() if not os.path.exists(executable_path): raise RuntimeError(f"ocaml-lsp-server executable not found at {executable_path}") if platform.system() != "Windows": os.chmod(executable_path, os.stat(executable_path).st_mode | stat.S_IEXEC) return executable_path except subprocess.CalledProcessError as e: raise RuntimeError( f"Failed to find ocaml-lsp-server executable.\n" f"Command failed: {e.cmd}\n" f"Return code: {e.returncode}\n" f"Stderr: {e.stderr}\n\n" "This usually means ocaml-lsp-server is not installed or not in PATH.\n" "Try:\n" " 1. Check opam switch: opam switch show\n" " 2. Install ocaml-lsp-server: opam install ocaml-lsp-server\n" " 3. Ensure opam env is activated: eval $(opam env)" ) @property def supports_cross_file_references(self) -> bool: """ Check if this OCaml environment supports cross-file references. Cross-file references require OCaml >= 5.2 with project-wide occurrences AND ocaml-lsp-server >= 1.23.0 for reliable cross-file reference support. Full requirements: - OCaml 5.2+ - ocaml-lsp-server >= 1.23.0 (earlier versions have unreliable cross-file refs) - merlin >= 5.1-502 (provides ocaml-index tool) - dune >= 3.16.0 - Index built via `dune build @ocaml-index` - For best results: `dune build -w` running (enables dune RPC) Note: Even when this returns True, cross-file refs may not work in all cases. The LSP server needs dune's RPC server (via -w flag) to be fully aware of the index. Without watch mode, cross-file refs are best-effort. See: https://discuss.ocaml.org/t/ann-project-wide-occurrences-in-merlin-and-lsp/14847 """ ocaml_ok = self._ocaml_version >= (5, 2, 0) lsp_ok = self._lsp_version >= self.MIN_LSP_VERSION_FOR_CROSS_FILE_REFS return ocaml_ok and lsp_ok @staticmethod def _build_ocaml_index_static(repository_root_path: str) -> bool: """ Build the OCaml index for project-wide occurrences. This enables cross-file reference finding on OCaml 5.2+. Must be called BEFORE starting the LSP server. Returns True if successful, False otherwise. """ log.info("Building OCaml index for cross-file references (dune build @ocaml-index)...") try: result = subprocess.run( ["opam", "exec", "--", "dune", "build", "@ocaml-index"], cwd=repository_root_path, capture_output=True, text=True, timeout=120, check=False, **subprocess_kwargs(), ) if result.returncode == 0: log.info("OCaml index built successfully") return True else: log.warning(f"Failed to build OCaml index: {result.stderr}") return False except subprocess.TimeoutExpired: log.warning("OCaml index build timed out after 120 seconds") return False except FileNotFoundError: log.warning("opam not found, cannot build OCaml index") return False except Exception as e: log.warning(f"Error building OCaml index: {e}") return False def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates an OcamlLanguageServer instance. This class is not meant to be instantiated directly. Use SolidLanguageServer.create() instead. """ # Ensure dependencies are available self._ensure_opam_installed() # Detect OCaml version for feature gating self._ocaml_version = self._detect_ocaml_version(repository_root_path) self._index_built = False # Verify ocaml-lsp-server is installed (we don't need the path, just validation) self._ensure_ocaml_lsp_installed(repository_root_path) # Detect LSP version for cross-file reference support self._lsp_version = self._detect_lsp_version(repository_root_path) # Build OCaml index BEFORE starting server (required for cross-file refs on OCaml 5.2+) if self._ocaml_version >= (5, 2, 0): self._index_built = self._build_ocaml_index_static(repository_root_path) # Use opam exec to run ocamllsp - this ensures correct opam environment # which is required for project-wide occurrences (cross-file references) to work ocaml_lsp_cmd = ["opam", "exec", "--", "ocamllsp", "--fallback-read-dot-merlin"] log.info(f"Using ocaml-lsp-server via: {' '.join(ocaml_lsp_cmd)}") super().__init__( config, repository_root_path, ProcessLaunchInfo(cmd=ocaml_lsp_cmd, cwd=repository_root_path), "ocaml", solidlsp_settings, ) self.server_ready = threading.Event() self.completions_available = threading.Event() @override def is_ignored_dirname(self, dirname: str) -> bool: """Define language-specific directories to ignore for OCaml projects.""" return super().is_ignored_dirname(dirname) or dirname in ["_build", "_opam", ".opam"] @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the OCaml Language Server. Supports both OCaml and Reason. """ root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "processId": os.getpid(), "clientInfo": {"name": "Serena", "version": "0.1.0"}, "locale": "en", "rootPath": repository_absolute_path, "rootUri": root_uri, "capabilities": { "workspace": { "workspaceFolders": True, "configuration": True, }, "textDocument": { "synchronization": { "dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True, }, "completion": { "dynamicRegistration": True, "completionItem": { "snippetSupport": True, "documentationFormat": ["markdown", "plaintext"], }, }, "hover": { "dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"], }, "definition": {"dynamicRegistration": True, "linkSupport": True}, "references": {"dynamicRegistration": True}, "documentSymbol": { "dynamicRegistration": True, "hierarchicalDocumentSymbolSupport": True, }, "formatting": {"dynamicRegistration": True}, "rename": {"dynamicRegistration": True, "prepareSupport": True}, }, }, "trace": "verbose", "workspaceFolders": [ { "uri": root_uri, "name": os.path.basename(repository_absolute_path), } ], } return initialize_params # type: ignore[return-value] def _start_server(self) -> None: """ Starts the OCaml Language Server (supports both OCaml and Reason) """ def register_capability_handler(params: Any) -> None: if "registrations" in params: for registration in params.get("registrations", []): method = registration.get("method", "") log.info(f"OCaml LSP registered capability: {method}") return def lang_status_handler(params: dict[str, Any]) -> None: if params.get("type") == "ServiceReady" and params.get("message") == "ServiceReady": self.server_ready.set() def do_nothing(params: Any) -> None: return def window_log_message(msg: dict[str, Any]) -> None: log.info(f"LSP: window/logMessage: {msg}") if "initialization done" in msg.get("message", "").lower(): self.server_ready.set() self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_notification("language/status", lang_status_handler) self.server.on_notification("window/logMessage", window_log_message) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) log.info("Starting OCaml LSP server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) # Verify expected capabilities capabilities = init_response.get("capabilities", {}) log.info(f"OCaml LSP capabilities: {list(capabilities.keys())}") text_doc_sync = capabilities.get("textDocumentSync") if isinstance(text_doc_sync, dict): assert text_doc_sync.get("change") == 2, "Expected incremental sync" assert "completionProvider" in capabilities, "Expected completion support" self.server.notify.initialized({}) self.completions_available.set() self.server_ready.set() log.info("OCaml Language Server initialized successfully") ================================================ FILE: src/solidlsp/language_servers/omnisharp/initialize_params.json ================================================ { "_description": "The parameters sent by the client when initializing the language server with the \"initialize\" request. More details at https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize", "processId": "os.getpid()", "clientInfo": { "name": "Visual Studio Code - Insiders", "version": "1.82.0-insider" }, "locale": "en", "rootPath": "$rootPath", "rootUri": "$rootUri", "capabilities": { "workspace": { "applyEdit": true, "workspaceEdit": { "documentChanges": true, "resourceOperations": [ "create", "rename", "delete" ], "failureHandling": "textOnlyTransactional", "normalizesLineEndings": true, "changeAnnotationSupport": { "groupsOnLabel": true } }, "configuration": false, "didChangeWatchedFiles": { "dynamicRegistration": true, "relativePatternSupport": true }, "symbol": { "dynamicRegistration": true, "symbolKind": { "valueSet": [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26 ] }, "tagSupport": { "valueSet": [ 1 ] }, "resolveSupport": { "properties": [ "location.range" ] } }, "codeLens": { "refreshSupport": true }, "executeCommand": { "dynamicRegistration": true }, "didChangeConfiguration": { "dynamicRegistration": true }, "workspaceFolders": true, "semanticTokens": { "refreshSupport": true }, "fileOperations": { "dynamicRegistration": true, "didCreate": true, "didRename": true, "didDelete": true, "willCreate": true, "willRename": true, "willDelete": true }, "inlineValue": { "refreshSupport": true }, "inlayHint": { "refreshSupport": true }, "diagnostics": { "refreshSupport": true } }, "textDocument": { "publishDiagnostics": { "relatedInformation": true, "versionSupport": false, "tagSupport": { "valueSet": [ 1, 2 ] }, "codeDescriptionSupport": true, "dataSupport": true }, "synchronization": { "dynamicRegistration": true, "willSave": true, "willSaveWaitUntil": true, "didSave": true }, "completion": { "dynamicRegistration": true, "contextSupport": true, "completionItem": { "snippetSupport": true, "commitCharactersSupport": true, "documentationFormat": [ "markdown", "plaintext" ], "deprecatedSupport": true, "preselectSupport": true, "tagSupport": { "valueSet": [ 1 ] }, "insertReplaceSupport": true, "resolveSupport": { "properties": [ "documentation", "detail", "additionalTextEdits" ] }, "insertTextModeSupport": { "valueSet": [ 1, 2 ] }, "labelDetailsSupport": true }, "insertTextMode": 2, "completionItemKind": { "valueSet": [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25 ] }, "completionList": { "itemDefaults": [ "commitCharacters", "editRange", "insertTextFormat", "insertTextMode" ] } }, "hover": { "dynamicRegistration": true, "contentFormat": [ "markdown", "plaintext" ] }, "signatureHelp": { "dynamicRegistration": true, "signatureInformation": { "documentationFormat": [ "markdown", "plaintext" ], "parameterInformation": { "labelOffsetSupport": true }, "activeParameterSupport": true }, "contextSupport": true }, "definition": { "dynamicRegistration": true, "linkSupport": true }, "references": { "dynamicRegistration": true }, "documentHighlight": { "dynamicRegistration": true }, "documentSymbol": { "dynamicRegistration": true, "symbolKind": { "valueSet": [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26 ] }, "hierarchicalDocumentSymbolSupport": true, "tagSupport": { "valueSet": [ 1 ] }, "labelSupport": true }, "codeAction": { "dynamicRegistration": true, "isPreferredSupport": true, "disabledSupport": true, "dataSupport": true, "resolveSupport": { "properties": [ "edit" ] }, "codeActionLiteralSupport": { "codeActionKind": { "valueSet": [ "", "quickfix", "refactor", "refactor.extract", "refactor.inline", "refactor.rewrite", "source", "source.organizeImports" ] } }, "honorsChangeAnnotations": false }, "codeLens": { "dynamicRegistration": true }, "formatting": { "dynamicRegistration": true }, "rangeFormatting": { "dynamicRegistration": true }, "onTypeFormatting": { "dynamicRegistration": true }, "rename": { "dynamicRegistration": true, "prepareSupport": true, "prepareSupportDefaultBehavior": 1, "honorsChangeAnnotations": true }, "documentLink": { "dynamicRegistration": true, "tooltipSupport": true }, "typeDefinition": { "dynamicRegistration": true, "linkSupport": true }, "implementation": { "dynamicRegistration": true, "linkSupport": true }, "colorProvider": { "dynamicRegistration": true }, "foldingRange": { "dynamicRegistration": true, "rangeLimit": 5000, "lineFoldingOnly": true, "foldingRangeKind": { "valueSet": [ "comment", "imports", "region" ] }, "foldingRange": { "collapsedText": false } }, "declaration": { "dynamicRegistration": true, "linkSupport": true }, "selectionRange": { "dynamicRegistration": true }, "callHierarchy": { "dynamicRegistration": true }, "semanticTokens": { "dynamicRegistration": true, "tokenTypes": [ "namespace", "type", "class", "enum", "interface", "struct", "typeParameter", "parameter", "variable", "property", "enumMember", "event", "function", "method", "macro", "keyword", "modifier", "comment", "string", "number", "regexp", "operator", "decorator" ], "tokenModifiers": [ "declaration", "definition", "readonly", "static", "deprecated", "abstract", "async", "modification", "documentation", "defaultLibrary" ], "formats": [ "relative" ], "requests": { "range": true, "full": { "delta": true } }, "multilineTokenSupport": false, "overlappingTokenSupport": false, "serverCancelSupport": true, "augmentsSyntaxTokens": false }, "linkedEditingRange": { "dynamicRegistration": true }, "typeHierarchy": { "dynamicRegistration": true }, "inlineValue": { "dynamicRegistration": true }, "inlayHint": { "dynamicRegistration": true, "resolveSupport": { "properties": [ "tooltip", "textEdits", "label.tooltip", "label.location", "label.command" ] } }, "diagnostic": { "dynamicRegistration": true, "relatedDocumentSupport": false } }, "window": { "showMessage": { "messageActionItem": { "additionalPropertiesSupport": true } }, "showDocument": { "support": true }, "workDoneProgress": true }, "general": { "staleRequestSupport": { "cancel": true, "retryOnContentModified": [ "textDocument/semanticTokens/full", "textDocument/semanticTokens/range", "textDocument/semanticTokens/full/delta" ] }, "regularExpressions": { "engine": "ECMAScript", "version": "ES2020" }, "markdown": { "parser": "marked", "version": "1.1.0", "allowedTags": [ "ul", "li", "p", "code", "blockquote", "ol", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "em", "pre", "table", "thead", "tbody", "tr", "th", "td", "div", "del", "a", "strong", "br", "img", "span" ] }, "positionEncodings": [ "utf-16" ] }, "notebookDocument": { "synchronization": { "dynamicRegistration": true, "executionSummarySupport": true } }, "experimental": { "snippetTextEdit": true, "codeActionGroup": true, "hoverActions": true, "serverStatusNotification": true, "colorDiagnosticOutput": true, "openServerLogs": true, "commands": { "commands": [ "editor.action.triggerParameterHints" ] } } }, "initializationOptions": { "RoslynExtensionsOptions": { "EnableDecompilationSupport": false, "EnableAnalyzersSupport": true, "EnableImportCompletion": true, "EnableAsyncCompletion": false, "DocumentAnalysisTimeoutMs": 30000, "DiagnosticWorkersThreadCount": 18, "AnalyzeOpenDocumentsOnly": true, "InlayHintsOptions": { "EnableForParameters": false, "ForLiteralParameters": false, "ForIndexerParameters": false, "ForObjectCreationParameters": false, "ForOtherParameters": false, "SuppressForParametersThatDifferOnlyBySuffix": false, "SuppressForParametersThatMatchMethodIntent": false, "SuppressForParametersThatMatchArgumentName": false, "EnableForTypes": false, "ForImplicitVariableTypes": false, "ForLambdaParameterTypes": false, "ForImplicitObjectCreation": false }, "LocationPaths": null }, "FormattingOptions": { "OrganizeImports": false, "EnableEditorConfigSupport": true, "NewLine": "\n", "UseTabs": false, "TabSize": 4, "IndentationSize": 4, "SpacingAfterMethodDeclarationName": false, "SeparateImportDirectiveGroups": false, "SpaceWithinMethodDeclarationParenthesis": false, "SpaceBetweenEmptyMethodDeclarationParentheses": false, "SpaceAfterMethodCallName": false, "SpaceWithinMethodCallParentheses": false, "SpaceBetweenEmptyMethodCallParentheses": false, "SpaceAfterControlFlowStatementKeyword": true, "SpaceWithinExpressionParentheses": false, "SpaceWithinCastParentheses": false, "SpaceWithinOtherParentheses": false, "SpaceAfterCast": false, "SpaceBeforeOpenSquareBracket": false, "SpaceBetweenEmptySquareBrackets": false, "SpaceWithinSquareBrackets": false, "SpaceAfterColonInBaseTypeDeclaration": true, "SpaceAfterComma": true, "SpaceAfterDot": false, "SpaceAfterSemicolonsInForStatement": true, "SpaceBeforeColonInBaseTypeDeclaration": true, "SpaceBeforeComma": false, "SpaceBeforeDot": false, "SpaceBeforeSemicolonsInForStatement": false, "SpacingAroundBinaryOperator": "single", "IndentBraces": false, "IndentBlock": true, "IndentSwitchSection": true, "IndentSwitchCaseSection": true, "IndentSwitchCaseSectionWhenBlock": true, "LabelPositioning": "oneLess", "WrappingPreserveSingleLine": true, "WrappingKeepStatementsOnSingleLine": true, "NewLinesForBracesInTypes": true, "NewLinesForBracesInMethods": true, "NewLinesForBracesInProperties": true, "NewLinesForBracesInAccessors": true, "NewLinesForBracesInAnonymousMethods": true, "NewLinesForBracesInControlBlocks": true, "NewLinesForBracesInAnonymousTypes": true, "NewLinesForBracesInObjectCollectionArrayInitializers": true, "NewLinesForBracesInLambdaExpressionBody": true, "NewLineForElse": true, "NewLineForCatch": true, "NewLineForFinally": true, "NewLineForMembersInObjectInit": true, "NewLineForMembersInAnonymousTypes": true, "NewLineForClausesInQuery": true }, "FileOptions": { "SystemExcludeSearchPatterns": [ "**/node_modules/**/*", "**/bin/**/*", "**/obj/**/*", "**/.git/**/*", "**/.git", "**/.svn", "**/.hg", "**/CVS", "**/.DS_Store", "**/Thumbs.db" ], "ExcludeSearchPatterns": [] }, "RenameOptions": { "RenameOverloads": false, "RenameInStrings": false, "RenameInComments": false }, "ImplementTypeOptions": { "InsertionBehavior": 0, "PropertyGenerationBehavior": 0 }, "DotNetCliOptions": { "LocationPaths": null }, "Plugins": { "LocationPaths": null } }, "trace": "verbose", "workspaceFolders": [ { "uri": "$uri", "name": "$name" } ] } ================================================ FILE: src/solidlsp/language_servers/omnisharp/runtime_dependencies.json ================================================ { "_description": "Used to download the runtime dependencies for running OmniSharp. Obtained from https://github.com/dotnet/vscode-csharp/blob/main/package.json", "runtimeDependencies": [ { "id": "OmniSharp", "description": "OmniSharp for Windows (.NET 4 / x86)", "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-win-x86-1.39.10.zip", "installPath": ".omnisharp/1.39.10", "platforms": [ "win32" ], "architectures": [ "x86" ], "installTestPath": "./.omnisharp/1.39.10/OmniSharp.exe", "platformId": "win-x86", "isFramework": true, "integrity": "C81CE2099AD494EF63F9D88FAA70D55A68CF175810F944526FF94AAC7A5109F9", "dotnet_version": "4", "binaryName": "OmniSharp.exe" }, { "id": "OmniSharp", "description": "OmniSharp for Windows (.NET 6 / x86)", "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-win-x86-net6.0-1.39.10.zip", "installPath": ".omnisharp/1.39.10-net6.0", "platforms": [ "win32" ], "architectures": [ "x86" ], "installTestPath": "./.omnisharp/1.39.10-net6.0/OmniSharp.dll", "platformId": "win-x86", "isFramework": false, "integrity": "B7E62415CFC3DAC2154AC636C5BF0FB4B2C9BBF11B5A1FBF72381DDDED59791E", "dotnet_version": "6", "binaryName": "OmniSharp.exe" }, { "id": "OmniSharp", "description": "OmniSharp for Windows (.NET 4 / x64)", "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-win-x64-1.39.10.zip", "installPath": ".omnisharp/1.39.10", "platforms": [ "win32" ], "architectures": [ "x86_64" ], "installTestPath": "./.omnisharp/1.39.10/OmniSharp.exe", "platformId": "win-x64", "isFramework": true, "integrity": "BE0ED10AACEA17E14B78BD0D887DE5935D4ECA3712192A701F3F2100CA3C8B6E", "dotnet_version": "4", "binaryName": "OmniSharp.exe" }, { "id": "OmniSharp", "description": "OmniSharp for Windows (.NET 6 / x64)", "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-win-x64-net6.0-1.39.10.zip", "installPath": ".omnisharp/1.39.10-net6.0", "platforms": [ "win32" ], "architectures": [ "x86_64" ], "installTestPath": "./.omnisharp/1.39.10-net6.0/OmniSharp.dll", "platformId": "win-x64", "isFramework": false, "integrity": "A73327395E7EF92C1D8E307055463DA412662C03F077ECC743462FD2760BB537", "dotnet_version": "6", "binaryName": "OmniSharp.exe" }, { "id": "OmniSharp", "description": "OmniSharp for Windows (.NET 4 / arm64)", "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-win-arm64-1.39.10.zip", "installPath": ".omnisharp/1.39.10", "platforms": [ "win32" ], "architectures": [ "arm64" ], "installTestPath": "./.omnisharp/1.39.10/OmniSharp.exe", "platformId": "win-arm64", "isFramework": true, "integrity": "32FA0067B0639F87760CD1A769B16E6A53588C137C4D31661836CA4FB28D3DD6", "dotnet_version": "4", "binaryName": "OmniSharp.exe" }, { "id": "OmniSharp", "description": "OmniSharp for Windows (.NET 6 / arm64)", "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-win-arm64-net6.0-1.39.10.zip", "installPath": ".omnisharp/1.39.10-net6.0", "platforms": [ "win32" ], "architectures": [ "arm64" ], "installTestPath": "./.omnisharp/1.39.10-net6.0/OmniSharp.dll", "platformId": "win-arm64", "isFramework": false, "integrity": "433F9B360CAA7B4DDD85C604D5C5542C1A718BCF2E71B2BCFC7526E6D41F4E8F", "dotnet_version": "6", "binaryName": "OmniSharp.exe" }, { "id": "OmniSharp", "description": "OmniSharp for OSX (Mono / x64)", "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-osx-1.39.10.zip", "installPath": ".omnisharp/1.39.10", "platforms": [ "darwin" ], "architectures": [ "x86_64", "arm64" ], "binaries": [ "./mono.osx", "./run" ], "installTestPath": "./.omnisharp/1.39.10/run", "platformId": "osx", "isFramework": true, "integrity": "2CC42F0EC7C30CFA8858501D12ECB6FB685A1FCFB8ECB35698A4B12406551968", "dotnet_version": "mono" }, { "id": "OmniSharp", "description": "OmniSharp for OSX (.NET 6 / x64)", "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-osx-x64-net6.0-1.39.10.zip", "installPath": ".omnisharp/1.39.10-net6.0", "platforms": [ "darwin" ], "architectures": [ "x86_64" ], "installTestPath": "./.omnisharp/1.39.10-net6.0/OmniSharp.dll", "platformId": "osx-x64", "isFramework": false, "integrity": "C9D6E9F2C839A66A7283AE6A9EC545EE049B48EB230D33E91A6322CB67FF9D97", "dotnet_version": "6" }, { "id": "OmniSharp", "description": "OmniSharp for OSX (.NET 6 / arm64)", "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-osx-arm64-net6.0-1.39.10.zip", "installPath": ".omnisharp/1.39.10-net6.0", "platforms": [ "darwin" ], "architectures": [ "arm64" ], "installTestPath": "./.omnisharp/1.39.10-net6.0/OmniSharp.dll", "platformId": "osx-arm64", "isFramework": false, "integrity": "851350F52F83E3BAD5A92D113E4B9882FCD1DEB16AA84FF94B6F2CEE3C70051E", "dotnet_version": "6" }, { "id": "OmniSharp", "description": "OmniSharp for Linux (Mono / x86)", "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-linux-x86-1.39.10.zip", "installPath": ".omnisharp/1.39.10", "platforms": [ "linux" ], "architectures": [ "x86", "i686" ], "binaries": [ "./mono.linux-x86", "./run" ], "installTestPath": "./.omnisharp/1.39.10/run", "platformId": "linux-x86", "isFramework": true, "integrity": "474B1CDBAE64CFEC655FB6B0659BCE481023C48274441C72991E67B6E13E56A1", "dotnet_version": "mono" }, { "id": "OmniSharp", "description": "OmniSharp for Linux (Mono / x64)", "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-linux-x64-1.39.10.zip", "installPath": ".omnisharp/1.39.10", "platforms": [ "linux" ], "architectures": [ "x86_64" ], "binaries": [ "./mono.linux-x86_64", "./run" ], "installTestPath": "./.omnisharp/1.39.10/run", "platformId": "linux-x64", "isFramework": true, "integrity": "FB4CAA47343265100349375D79DBCCE1868950CED675CB07FCBE8462EDBCDD37", "dotnet_version": "mono" }, { "id": "OmniSharp", "description": "OmniSharp for Linux (.NET 6 / x64)", "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-linux-x64-net6.0-1.39.10.zip", "installPath": ".omnisharp/1.39.10-net6.0", "platforms": [ "linux" ], "architectures": [ "x86_64" ], "installTestPath": "./.omnisharp/1.39.10-net6.0/OmniSharp.dll", "platformId": "linux-x64", "isFramework": false, "integrity": "0926D3BEA060BF4373356B2FC0A68C10D0DE1B1150100B551BA5932814CE51E2", "dotnet_version": "6", "binaryName": "OmniSharp" }, { "id": "OmniSharp", "description": "OmniSharp for Linux (Mono / arm64)", "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-linux-arm64-1.39.10.zip", "installPath": ".omnisharp/1.39.10", "platforms": [ "linux" ], "architectures": [ "arm64" ], "binaries": [ "./mono.linux-arm64", "./run" ], "installTestPath": "./.omnisharp/1.39.10/run", "platformId": "linux-arm64", "isFramework": true, "integrity": "478F3594DFD0167E9A56E36F0364A86C73F8132A3E7EA916CA1419EFE141D2CC", "dotnet_version": "mono" }, { "id": "OmniSharp", "description": "OmniSharp for Linux (.NET 6 / arm64)", "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-linux-arm64-net6.0-1.39.10.zip", "installPath": ".omnisharp/1.39.10-net6.0", "platforms": [ "linux" ], "architectures": [ "arm64" ], "installTestPath": "./.omnisharp/1.39.10-net6.0/OmniSharp.dll", "platformId": "linux-arm64", "isFramework": false, "integrity": "6FB6A572043A74220A92F6C19C7BB0C3743321C7563A815FD2702EF4FA7D688E", "dotnet_version": "6" }, { "id": "OmniSharp", "description": "OmniSharp for Linux musl (.NET 6 / x64)", "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-linux-musl-x64-net6.0-1.39.10.zip", "installPath": ".omnisharp/1.39.10-net6.0", "platforms": [ "linux-musl" ], "architectures": [ "x86_64" ], "installTestPath": "./.omnisharp/1.39.10-net6.0/OmniSharp.dll", "platformId": "linux-musl-x64", "isFramework": false, "integrity": "6BFDA3AD11DBB0C6514B86ECC3E1597CC41C6E309B7575F7C599E07D9E2AE610", "dotnet_version": "6" }, { "id": "OmniSharp", "description": "OmniSharp for Linux musl (.NET 6 / arm64)", "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-linux-musl-arm64-net6.0-1.39.10.zip", "installPath": ".omnisharp/1.39.10-net6.0", "platforms": [ "linux-musl" ], "architectures": [ "arm64" ], "installTestPath": "./.omnisharp/1.39.10-net6.0/OmniSharp.dll", "platformId": "linux-musl-arm64", "isFramework": false, "integrity": "DA63619EA024EB9BBF6DB5A85C6150CAB5C0BD554544A3596ED1B17F926D6875", "dotnet_version": "6" }, { "id": "RazorOmnisharp", "description": "Razor Language Server for OmniSharp (Windows / x64)", "url": "https://download.visualstudio.microsoft.com/download/pr/aee63398-023f-48db-bba2-30162c68f0c4/8d42e62ea4051381c219b3e31bc4eced/razorlanguageserver-win-x64-7.0.0-preview.23363.1.zip", "installPath": ".razoromnisharp", "platforms": [ "win32" ], "architectures": [ "x86_64" ], "platformId": "win-x64", "dll_path": "OmniSharpPlugin/Microsoft.AspNetCore.Razor.OmniSharpPlugin.dll" }, { "id": "RazorOmnisharp", "description": "Razor Language Server for OmniSharp (Windows / x86)", "url": "https://download.visualstudio.microsoft.com/download/pr/aee63398-023f-48db-bba2-30162c68f0c4/e440c4f3a4a96334fe177513935fa010/razorlanguageserver-win-x86-7.0.0-preview.23363.1.zip", "installPath": ".razoromnisharp", "platforms": [ "win32" ], "architectures": [ "x86" ], "platformId": "win-x86", "dll_path": "OmniSharpPlugin/Microsoft.AspNetCore.Razor.OmniSharpPlugin.dll" }, { "id": "RazorOmnisharp", "description": "Razor Language Server for OmniSharp (Windows / ARM64)", "url": "https://download.visualstudio.microsoft.com/download/pr/aee63398-023f-48db-bba2-30162c68f0c4/4ef26e45cf32fe8d51c0e7dd21f1fef6/razorlanguageserver-win-arm64-7.0.0-preview.23363.1.zip", "installPath": ".razoromnisharp", "platforms": [ "win32" ], "architectures": [ "arm64" ], "platformId": "win-arm64", "dll_path": "OmniSharpPlugin/Microsoft.AspNetCore.Razor.OmniSharpPlugin.dll" }, { "id": "RazorOmnisharp", "description": "Razor Language Server for OmniSharp (Linux / x64)", "url": "https://download.visualstudio.microsoft.com/download/pr/aee63398-023f-48db-bba2-30162c68f0c4/6d4e23a3c7cf0465743950a39515a716/razorlanguageserver-linux-x64-7.0.0-preview.23363.1.zip", "installPath": ".razoromnisharp", "platforms": [ "linux" ], "architectures": [ "x86_64" ], "binaries": [ "./rzls" ], "platformId": "linux-x64", "dll_path": "OmniSharpPlugin/Microsoft.AspNetCore.Razor.OmniSharpPlugin.dll" }, { "id": "RazorOmnisharp", "description": "Razor Language Server for OmniSharp (Linux ARM64)", "url": "https://download.visualstudio.microsoft.com/download/pr/aee63398-023f-48db-bba2-30162c68f0c4/85deebd44647ebf65724cc291d722283/razorlanguageserver-linux-arm64-7.0.0-preview.23363.1.zip", "installPath": ".razoromnisharp", "platforms": [ "linux" ], "architectures": [ "arm64" ], "binaries": [ "./rzls" ], "platformId": "linux-arm64" }, { "id": "RazorOmnisharp", "description": "Razor Language Server for OmniSharp (Linux musl / x64)", "url": "https://download.visualstudio.microsoft.com/download/pr/aee63398-023f-48db-bba2-30162c68f0c4/4f0caa94ae182785655efb15eafcef23/razorlanguageserver-linux-musl-x64-7.0.0-preview.23363.1.zip", "installPath": ".razoromnisharp", "platforms": [ "linux-musl" ], "architectures": [ "x86_64" ], "binaries": [ "./rzls" ], "platformId": "linux-musl-x64" }, { "id": "RazorOmnisharp", "description": "Razor Language Server for OmniSharp (Linux musl ARM64)", "url": "https://download.visualstudio.microsoft.com/download/pr/aee63398-023f-48db-bba2-30162c68f0c4/0a24828206a6f3b4bc743d058ef88ce7/razorlanguageserver-linux-musl-arm64-7.0.0-preview.23363.1.zip", "installPath": ".razoromnisharp", "platforms": [ "linux-musl" ], "architectures": [ "arm64" ], "binaries": [ "./rzls" ], "platformId": "linux-musl-arm64" }, { "id": "RazorOmnisharp", "description": "Razor Language Server for OmniSharp (macOS / x64)", "url": "https://download.visualstudio.microsoft.com/download/pr/aee63398-023f-48db-bba2-30162c68f0c4/2afcafaf41082989efcc10405abb9314/razorlanguageserver-osx-x64-7.0.0-preview.23363.1.zip", "installPath": ".razoromnisharp", "platforms": [ "darwin" ], "architectures": [ "x86_64" ], "binaries": [ "./rzls" ], "platformId": "osx-x64" }, { "id": "RazorOmnisharp", "description": "Razor Language Server for OmniSharp (macOS ARM64)", "url": "https://download.visualstudio.microsoft.com/download/pr/aee63398-023f-48db-bba2-30162c68f0c4/8bf2ed2f00d481a5987e3eb5165afddd/razorlanguageserver-osx-arm64-7.0.0-preview.23363.1.zip", "installPath": ".razoromnisharp", "platforms": [ "darwin" ], "architectures": [ "arm64" ], "binaries": [ "./rzls" ], "platformId": "osx-arm64" } ] } ================================================ FILE: src/solidlsp/language_servers/omnisharp/workspace_did_change_configuration.json ================================================ { "RoslynExtensionsOptions": { "EnableDecompilationSupport": false, "EnableAnalyzersSupport": true, "EnableImportCompletion": true, "EnableAsyncCompletion": false, "DocumentAnalysisTimeoutMs": 30000, "DiagnosticWorkersThreadCount": 18, "AnalyzeOpenDocumentsOnly": true, "InlayHintsOptions": { "EnableForParameters": false, "ForLiteralParameters": false, "ForIndexerParameters": false, "ForObjectCreationParameters": false, "ForOtherParameters": false, "SuppressForParametersThatDifferOnlyBySuffix": false, "SuppressForParametersThatMatchMethodIntent": false, "SuppressForParametersThatMatchArgumentName": false, "EnableForTypes": false, "ForImplicitVariableTypes": false, "ForLambdaParameterTypes": false, "ForImplicitObjectCreation": false }, "LocationPaths": null }, "FormattingOptions": { "OrganizeImports": false, "EnableEditorConfigSupport": true, "NewLine": "\n", "UseTabs": false, "TabSize": 4, "IndentationSize": 4, "SpacingAfterMethodDeclarationName": false, "SeparateImportDirectiveGroups": false, "SpaceWithinMethodDeclarationParenthesis": false, "SpaceBetweenEmptyMethodDeclarationParentheses": false, "SpaceAfterMethodCallName": false, "SpaceWithinMethodCallParentheses": false, "SpaceBetweenEmptyMethodCallParentheses": false, "SpaceAfterControlFlowStatementKeyword": true, "SpaceWithinExpressionParentheses": false, "SpaceWithinCastParentheses": false, "SpaceWithinOtherParentheses": false, "SpaceAfterCast": false, "SpaceBeforeOpenSquareBracket": false, "SpaceBetweenEmptySquareBrackets": false, "SpaceWithinSquareBrackets": false, "SpaceAfterColonInBaseTypeDeclaration": true, "SpaceAfterComma": true, "SpaceAfterDot": false, "SpaceAfterSemicolonsInForStatement": true, "SpaceBeforeColonInBaseTypeDeclaration": true, "SpaceBeforeComma": false, "SpaceBeforeDot": false, "SpaceBeforeSemicolonsInForStatement": false, "SpacingAroundBinaryOperator": "single", "IndentBraces": false, "IndentBlock": true, "IndentSwitchSection": true, "IndentSwitchCaseSection": true, "IndentSwitchCaseSectionWhenBlock": true, "LabelPositioning": "oneLess", "WrappingPreserveSingleLine": true, "WrappingKeepStatementsOnSingleLine": true, "NewLinesForBracesInTypes": true, "NewLinesForBracesInMethods": true, "NewLinesForBracesInProperties": true, "NewLinesForBracesInAccessors": true, "NewLinesForBracesInAnonymousMethods": true, "NewLinesForBracesInControlBlocks": true, "NewLinesForBracesInAnonymousTypes": true, "NewLinesForBracesInObjectCollectionArrayInitializers": true, "NewLinesForBracesInLambdaExpressionBody": true, "NewLineForElse": true, "NewLineForCatch": true, "NewLineForFinally": true, "NewLineForMembersInObjectInit": true, "NewLineForMembersInAnonymousTypes": true, "NewLineForClausesInQuery": true }, "FileOptions": { "SystemExcludeSearchPatterns": [ "**/node_modules/**/*", "**/bin/**/*", "**/obj/**/*", "**/.git/**/*", "**/.git", "**/.svn", "**/.hg", "**/CVS", "**/.DS_Store", "**/Thumbs.db" ], "ExcludeSearchPatterns": [] }, "RenameOptions": { "RenameOverloads": false, "RenameInStrings": false, "RenameInComments": false }, "ImplementTypeOptions": { "InsertionBehavior": 0, "PropertyGenerationBehavior": 0 }, "DotNetCliOptions": { "LocationPaths": null }, "Plugins": { "LocationPaths": null } } ================================================ FILE: src/solidlsp/language_servers/omnisharp.py ================================================ """ Provides C# specific instantiation of the LanguageServer class. Contains various configurations and settings specific to C#. """ import json import logging import os import pathlib import threading from collections.abc import Iterable from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_exceptions import SolidLSPException from solidlsp.ls_utils import DotnetVersion, FileUtils, PlatformId, PlatformUtils from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) def breadth_first_file_scan(root: str) -> Iterable[str]: """ This function was obtained from https://stackoverflow.com/questions/49654234/is-there-a-breadth-first-search-option-available-in-os-walk-or-equivalent-py It traverses the directory tree in breadth first order. """ dirs = [root] # while we has dirs to scan while dirs: next_dirs = [] for parent in dirs: # scan each dir for f in os.listdir(parent): # if there is a dir, then save for next ittr # if it is a file then yield it (we'll return later) ff = os.path.join(parent, f) if os.path.isdir(ff): next_dirs.append(ff) else: yield ff # once we've done all the current dirs then # we set up the next itter as the child dirs # from the current itter. dirs = next_dirs def find_least_depth_sln_file(root_dir: str) -> str | None: for filename in breadth_first_file_scan(root_dir): if filename.endswith(".sln"): return filename return None class OmniSharp(SolidLanguageServer): """ Provides C# specific instantiation of the LanguageServer class. Contains various configurations and settings specific to C#. """ def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates an OmniSharp instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ omnisharp_executable_path, dll_path = self._setup_runtime_dependencies(config, solidlsp_settings) slnfilename = find_least_depth_sln_file(repository_root_path) if slnfilename is None: log.error("No *.sln file found in repository") raise SolidLSPException("No SLN file found in repository") cmd = " ".join( [ omnisharp_executable_path, "-lsp", "--encoding", "ascii", "-z", "-s", f'"{slnfilename}"', "--hostPID", str(os.getpid()), "DotNet:enablePackageRestore=false", "--loglevel", "trace", "--plugin", dll_path, "FileOptions:SystemExcludeSearchPatterns:0=**/.git", "FileOptions:SystemExcludeSearchPatterns:1=**/.svn", "FileOptions:SystemExcludeSearchPatterns:2=**/.hg", "FileOptions:SystemExcludeSearchPatterns:3=**/CVS", "FileOptions:SystemExcludeSearchPatterns:4=**/.DS_Store", "FileOptions:SystemExcludeSearchPatterns:5=**/Thumbs.db", "RoslynExtensionsOptions:EnableAnalyzersSupport=true", "FormattingOptions:EnableEditorConfigSupport=true", "RoslynExtensionsOptions:EnableImportCompletion=true", "Sdk:IncludePrereleases=true", "RoslynExtensionsOptions:AnalyzeOpenDocumentsOnly=true", "formattingOptions:useTabs=false", "formattingOptions:tabSize=4", "formattingOptions:indentationSize=4", ] ) super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd=cmd, cwd=repository_root_path), "csharp", solidlsp_settings) self.server_ready = threading.Event() self.definition_available = threading.Event() self.references_available = threading.Event() self.completions_available = threading.Event() @override def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in ["bin", "obj"] @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the Omnisharp Language Server. """ with open(os.path.join(os.path.dirname(__file__), "omnisharp", "initialize_params.json"), encoding="utf-8") as f: d = json.load(f) del d["_description"] d["processId"] = os.getpid() assert d["rootPath"] == "$rootPath" d["rootPath"] = repository_absolute_path assert d["rootUri"] == "$rootUri" d["rootUri"] = pathlib.Path(repository_absolute_path).as_uri() assert d["workspaceFolders"][0]["uri"] == "$uri" d["workspaceFolders"][0]["uri"] = pathlib.Path(repository_absolute_path).as_uri() assert d["workspaceFolders"][0]["name"] == "$name" d["workspaceFolders"][0]["name"] = os.path.basename(repository_absolute_path) return d @classmethod def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> tuple[str, str]: """ Setup runtime dependencies for OmniSharp. """ platform_id = PlatformUtils.get_platform_id() dotnet_version = PlatformUtils.get_dotnet_version() with open(os.path.join(os.path.dirname(__file__), "omnisharp", "runtime_dependencies.json"), encoding="utf-8") as f: d = json.load(f) del d["_description"] assert platform_id in [ PlatformId.LINUX_x64, PlatformId.WIN_x64, ], f"Only linux-x64 and win-x64 platform is supported at the moment but got {platform_id=}" assert dotnet_version in [ DotnetVersion.V6, DotnetVersion.V7, DotnetVersion.V8, DotnetVersion.V9, ], f"Only dotnet version 6-9 are supported at the moment but got {dotnet_version=}" # TODO: Do away with this assumption # Currently, runtime binaries are not available for .Net 7 and .Net 8. Hence, we assume .Net 6 runtime binaries to be compatible with .Net 7, .Net 8 if dotnet_version in [DotnetVersion.V7, DotnetVersion.V8, DotnetVersion.V9]: dotnet_version = DotnetVersion.V6 runtime_dependencies = d["runtimeDependencies"] runtime_dependencies = [dependency for dependency in runtime_dependencies if dependency["platformId"] == platform_id.value] runtime_dependencies = [ dependency for dependency in runtime_dependencies if "dotnet_version" not in dependency or dependency["dotnet_version"] == dotnet_version.value ] assert len(runtime_dependencies) == 2 runtime_dependencies = { runtime_dependencies[0]["id"]: runtime_dependencies[0], runtime_dependencies[1]["id"]: runtime_dependencies[1], } assert "OmniSharp" in runtime_dependencies assert "RazorOmnisharp" in runtime_dependencies omnisharp_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "OmniSharp") if not os.path.exists(omnisharp_ls_dir): os.makedirs(omnisharp_ls_dir) FileUtils.download_and_extract_archive(runtime_dependencies["OmniSharp"]["url"], omnisharp_ls_dir, "zip") omnisharp_executable_path = os.path.join(omnisharp_ls_dir, runtime_dependencies["OmniSharp"]["binaryName"]) assert os.path.exists(omnisharp_executable_path) os.chmod(omnisharp_executable_path, 0o755) razor_omnisharp_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "RazorOmnisharp") if not os.path.exists(razor_omnisharp_ls_dir): os.makedirs(razor_omnisharp_ls_dir) FileUtils.download_and_extract_archive(runtime_dependencies["RazorOmnisharp"]["url"], razor_omnisharp_ls_dir, "zip") razor_omnisharp_dll_path = os.path.join(razor_omnisharp_ls_dir, runtime_dependencies["RazorOmnisharp"]["dll_path"]) assert os.path.exists(razor_omnisharp_dll_path) return omnisharp_executable_path, razor_omnisharp_dll_path def _start_server(self) -> None: """ Starts the Omnisharp Language Server """ def register_capability_handler(params: dict) -> None: assert "registrations" in params for registration in params["registrations"]: if registration["method"] == "textDocument/definition": self.definition_available.set() if registration["method"] == "textDocument/references": self.references_available.set() if registration["method"] == "textDocument/completion": self.completions_available.set() def lang_status_handler(params: dict) -> None: # TODO: Should we wait for # server -> client: {'jsonrpc': '2.0', 'method': 'language/status', 'params': {'type': 'ProjectStatus', 'message': 'OK'}} # Before proceeding? # if params["type"] == "ServiceReady" and params["message"] == "ServiceReady": # self.service_ready_event.set() pass def execute_client_command_handler(params: dict) -> list: return [] def do_nothing(params: dict) -> None: return def check_experimental_status(params: dict) -> None: if params["quiescent"] is True: self.server_ready.set() def workspace_configuration_handler(params: dict) -> list[dict]: # TODO: We do not know the appropriate way to handle this request. Should ideally contact the OmniSharp dev team return [ { "RoslynExtensionsOptions": { "EnableDecompilationSupport": False, "EnableAnalyzersSupport": True, "EnableImportCompletion": True, "EnableAsyncCompletion": False, "DocumentAnalysisTimeoutMs": 30000, "DiagnosticWorkersThreadCount": 18, "AnalyzeOpenDocumentsOnly": True, "InlayHintsOptions": { "EnableForParameters": False, "ForLiteralParameters": False, "ForIndexerParameters": False, "ForObjectCreationParameters": False, "ForOtherParameters": False, "SuppressForParametersThatDifferOnlyBySuffix": False, "SuppressForParametersThatMatchMethodIntent": False, "SuppressForParametersThatMatchArgumentName": False, "EnableForTypes": False, "ForImplicitVariableTypes": False, "ForLambdaParameterTypes": False, "ForImplicitObjectCreation": False, }, "LocationPaths": None, }, "FormattingOptions": { "OrganizeImports": False, "EnableEditorConfigSupport": True, "NewLine": "\n", "UseTabs": False, "TabSize": 4, "IndentationSize": 4, "SpacingAfterMethodDeclarationName": False, "SeparateImportDirectiveGroups": False, "SpaceWithinMethodDeclarationParenthesis": False, "SpaceBetweenEmptyMethodDeclarationParentheses": False, "SpaceAfterMethodCallName": False, "SpaceWithinMethodCallParentheses": False, "SpaceBetweenEmptyMethodCallParentheses": False, "SpaceAfterControlFlowStatementKeyword": True, "SpaceWithinExpressionParentheses": False, "SpaceWithinCastParentheses": False, "SpaceWithinOtherParentheses": False, "SpaceAfterCast": False, "SpaceBeforeOpenSquareBracket": False, "SpaceBetweenEmptySquareBrackets": False, "SpaceWithinSquareBrackets": False, "SpaceAfterColonInBaseTypeDeclaration": True, "SpaceAfterComma": True, "SpaceAfterDot": False, "SpaceAfterSemicolonsInForStatement": True, "SpaceBeforeColonInBaseTypeDeclaration": True, "SpaceBeforeComma": False, "SpaceBeforeDot": False, "SpaceBeforeSemicolonsInForStatement": False, "SpacingAroundBinaryOperator": "single", "IndentBraces": False, "IndentBlock": True, "IndentSwitchSection": True, "IndentSwitchCaseSection": True, "IndentSwitchCaseSectionWhenBlock": True, "LabelPositioning": "oneLess", "WrappingPreserveSingleLine": True, "WrappingKeepStatementsOnSingleLine": True, "NewLinesForBracesInTypes": True, "NewLinesForBracesInMethods": True, "NewLinesForBracesInProperties": True, "NewLinesForBracesInAccessors": True, "NewLinesForBracesInAnonymousMethods": True, "NewLinesForBracesInControlBlocks": True, "NewLinesForBracesInAnonymousTypes": True, "NewLinesForBracesInObjectCollectionArrayInitializers": True, "NewLinesForBracesInLambdaExpressionBody": True, "NewLineForElse": True, "NewLineForCatch": True, "NewLineForFinally": True, "NewLineForMembersInObjectInit": True, "NewLineForMembersInAnonymousTypes": True, "NewLineForClausesInQuery": True, }, "FileOptions": { "SystemExcludeSearchPatterns": [ "**/node_modules/**/*", "**/bin/**/*", "**/obj/**/*", "**/.git/**/*", "**/.git", "**/.svn", "**/.hg", "**/CVS", "**/.DS_Store", "**/Thumbs.db", ], "ExcludeSearchPatterns": [], }, "RenameOptions": { "RenameOverloads": False, "RenameInStrings": False, "RenameInComments": False, }, "ImplementTypeOptions": { "InsertionBehavior": 0, "PropertyGenerationBehavior": 0, }, "DotNetCliOptions": {"LocationPaths": None}, "Plugins": {"LocationPaths": None}, } ] self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_notification("language/status", lang_status_handler) self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) self.server.on_notification("language/actionableNotification", do_nothing) self.server.on_notification("experimental/serverStatus", check_experimental_status) self.server.on_request("workspace/configuration", workspace_configuration_handler) log.info("Starting OmniSharp server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) self.server.notify.initialized({}) with open(os.path.join(os.path.dirname(__file__), "omnisharp", "workspace_did_change_configuration.json"), encoding="utf-8") as f: self.server.notify.workspace_did_change_configuration({"settings": json.load(f)}) assert "capabilities" in init_response if "definitionProvider" in init_response["capabilities"] and init_response["capabilities"]["definitionProvider"]: self.definition_available.set() if "referencesProvider" in init_response["capabilities"] and init_response["capabilities"]["referencesProvider"]: self.references_available.set() self.definition_available.wait() self.references_available.wait() ================================================ FILE: src/solidlsp/language_servers/pascal_server.py ================================================ """ Provides Pascal/Free Pascal specific instantiation of the LanguageServer class using pasls. Contains various configurations and settings specific to Pascal and Free Pascal. pasls installation strategy: 1. Use existing pasls from PATH 2. Download prebuilt binary from GitHub releases (auto-updated) Supported platforms for binary download: - linux-x64, linux-arm64 - osx-x64, osx-arm64 - win-x64 Auto-update features: - Checks for updates every 24 hours via GitHub API - SHA256 checksum verification before installation - Atomic update with rollback on failure - Windows file locking detection You can pass the following entries in ls_specific_settings["pascal"]: Environment variables (recommended for CodeTools configuration): - pp: Path to FPC compiler driver, must be "fpc.exe" (e.g., "D:/laz32/fpc/bin/i386-win32/fpc.exe"). Do NOT use backend compilers like ppc386.exe or ppcx64.exe - CodeTools queries fpc.exe for configuration (fpc -iV, fpc -iTO, etc.). This is the most important setting for hover/navigation. - fpcdir: Path to FPC source directory (e.g., "D:/laz32/fpcsrc"). Helps CodeTools locate standard library sources for better navigation. - lazarusdir: Path to Lazarus directory (e.g., "D:/laz32/lazarus"). Required for Lazarus projects using LCL and other Lazarus components. Target platform overrides (use only if pp setting is not sufficient): - fpc_target: Override target OS (e.g., "Win32", "Win64", "Linux"). Sets FPCTARGET env var. - fpc_target_cpu: Override target CPU (e.g., "i386", "x86_64", "aarch64"). Sets FPCTARGETCPU. Example configuration in ~/.serena/serena_config.yml: ls_specific_settings: pascal: pp: "D:/laz32/fpc/bin/i386-win32/fpc.exe" fpcdir: "D:/laz32/fpcsrc" lazarusdir: "D:/laz32/lazarus" """ from __future__ import annotations import hashlib import json import logging import os import pathlib import platform import shutil import tarfile import threading import time import urllib.error import urllib.request import uuid import zipfile from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection, quote_windows_path from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) class PascalLanguageServer(SolidLanguageServer): """ Provides Pascal specific instantiation of the LanguageServer class using pasls. Contains various configurations and settings specific to Free Pascal and Lazarus. """ # URL configuration PASLS_RELEASES_URL = "https://github.com/zen010101/pascal-language-server/releases/latest/download" PASLS_API_URL = "https://api.github.com/repos/zen010101/pascal-language-server/releases/latest" # Update check interval (seconds) UPDATE_CHECK_INTERVAL = 86400 # 24 hours # Metadata directory name META_DIR = ".meta" # Network timeout (seconds) NETWORK_TIMEOUT = 10 def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a PascalLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ pasls_executable_path = self._setup_runtime_dependencies(solidlsp_settings) # Build environment variables for pasls # These control CodeTools' configuration and target platform settings proc_env: dict[str, str] = {} # Read from ls_specific_settings["pascal"] from solidlsp.ls_config import Language pascal_settings = solidlsp_settings.get_ls_specific_settings(Language.PASCAL) # pp: Path to FPC compiler driver (must be fpc.exe, NOT ppc386.exe/ppcx64.exe) # CodeTools queries fpc.exe for configuration via "fpc -iV", "fpc -iTO", etc. pp = pascal_settings.get("pp", "") if pp: proc_env["PP"] = pp log.info(f"Setting PP={pp} from ls_specific_settings") # fpcdir: Path to FPC source directory (e.g., "D:/laz32/fpcsrc") fpcdir = pascal_settings.get("fpcdir", "") if fpcdir: proc_env["FPCDIR"] = fpcdir log.info(f"Setting FPCDIR={fpcdir} from ls_specific_settings") # lazarusdir: Path to Lazarus directory (e.g., "D:/laz32/lazarus") lazarusdir = pascal_settings.get("lazarusdir", "") if lazarusdir: proc_env["LAZARUSDIR"] = lazarusdir log.info(f"Setting LAZARUSDIR={lazarusdir} from ls_specific_settings") # fpc_target: Override target OS (e.g., "Win32", "Win64", "Linux") fpc_target = pascal_settings.get("fpc_target", "") if fpc_target: proc_env["FPCTARGET"] = fpc_target log.info(f"Setting FPCTARGET={fpc_target} from ls_specific_settings") # fpc_target_cpu: Override target CPU (e.g., "i386", "x86_64", "aarch64") fpc_target_cpu = pascal_settings.get("fpc_target_cpu", "") if fpc_target_cpu: proc_env["FPCTARGETCPU"] = fpc_target_cpu log.info(f"Setting FPCTARGETCPU={fpc_target_cpu} from ls_specific_settings") super().__init__( config, repository_root_path, ProcessLaunchInfo(cmd=pasls_executable_path, cwd=repository_root_path, env=proc_env), "pascal", solidlsp_settings, ) self.server_ready = threading.Event() # ============== Metadata Directory Management ============== @classmethod def _meta_dir(cls, pasls_dir: str) -> str: """Get metadata directory path, create if not exists.""" meta_path = os.path.join(pasls_dir, cls.META_DIR) os.makedirs(meta_path, exist_ok=True) return meta_path @classmethod def _meta_file(cls, pasls_dir: str, filename: str) -> str: """Get metadata file path.""" return os.path.join(cls._meta_dir(pasls_dir), filename) # ============== Version Management ============== @staticmethod def _normalize_version(version: str | None) -> str: """Normalize version string by removing 'v' prefix and whitespace.""" if not version: return "" return version.strip().lstrip("vV") @classmethod def _is_newer_version(cls, latest: str | None, local: str | None) -> bool: """Compare versions, return True if latest is newer than local.""" if not latest: return False if not local: return True latest_norm = cls._normalize_version(latest) local_norm = cls._normalize_version(local) if not latest_norm: return False if not local_norm: return True try: def parse_version(v: str) -> list[int]: parts = [] for part in v.split("."): num = "" for c in part: if c.isdigit(): num += c else: break parts.append(int(num) if num else 0) return parts latest_parts = parse_version(latest_norm) local_parts = parse_version(local_norm) # Pad to same length max_len = max(len(latest_parts), len(local_parts)) latest_parts.extend([0] * (max_len - len(latest_parts))) local_parts.extend([0] * (max_len - len(local_parts))) return latest_parts > local_parts except Exception: log.warning(f"Failed to parse versions for comparison: {latest_norm} vs {local_norm}") return False @classmethod def _get_latest_version(cls) -> str | None: """Get latest version from GitHub API, return None on failure.""" try: headers = {"Accept": "application/vnd.github.v3+json", "User-Agent": "Serena-LSP"} # Support GITHUB_TOKEN for CI environments with rate limits github_token = os.environ.get("GITHUB_TOKEN") if github_token: headers["Authorization"] = f"token {github_token}" req = urllib.request.Request(cls.PASLS_API_URL, headers=headers) with urllib.request.urlopen(req, timeout=cls.NETWORK_TIMEOUT) as response: data = json.loads(response.read().decode()) return data.get("tag_name") except Exception as e: log.debug(f"Failed to get latest pasls version: {type(e).__name__}: {e}") return None @classmethod def _get_local_version(cls, pasls_dir: str) -> str | None: """Read local version file.""" version_file = cls._meta_file(pasls_dir, "version") if os.path.exists(version_file): try: with open(version_file, encoding="utf-8") as f: return f.read().strip() except OSError: return None return None @classmethod def _save_local_version(cls, pasls_dir: str, version: str) -> None: """Save version to local file.""" version_file = cls._meta_file(pasls_dir, "version") try: with open(version_file, "w", encoding="utf-8") as f: f.write(version) except OSError as e: log.warning(f"Failed to save version file: {e}") # ============== Update Check Timing ============== @classmethod def _should_check_update(cls, pasls_dir: str) -> bool: """Check if we should query for updates (more than 24 hours since last check).""" last_check_file = cls._meta_file(pasls_dir, "last_check") if not os.path.exists(last_check_file): return True try: with open(last_check_file, encoding="utf-8") as f: last_check = float(f.read().strip()) return (time.time() - last_check) > cls.UPDATE_CHECK_INTERVAL except (OSError, ValueError): return True @classmethod def _update_last_check(cls, pasls_dir: str) -> None: """Update last check timestamp.""" last_check_file = cls._meta_file(pasls_dir, "last_check") try: with open(last_check_file, "w", encoding="utf-8") as f: f.write(str(time.time())) except OSError as e: log.warning(f"Failed to update last check time: {e}") # ============== SHA256 Checksum ============== @classmethod def _get_checksums(cls) -> dict[str, str] | None: """Download checksums file from GitHub, return {filename: sha256} dict.""" checksums_url = f"{cls.PASLS_RELEASES_URL}/checksums.sha256" try: req = urllib.request.Request(checksums_url, headers={"User-Agent": "Serena-LSP"}) with urllib.request.urlopen(req, timeout=cls.NETWORK_TIMEOUT) as response: content = response.read().decode("utf-8") checksums = {} for line in content.strip().split("\n"): line = line.strip() if not line or line.startswith("#"): continue parts = line.split() if len(parts) >= 2: sha256 = parts[0] filename = parts[1].lstrip("*") # Remove possible * prefix checksums[filename] = sha256 return checksums except Exception as e: log.warning(f"Failed to get checksums: {type(e).__name__}: {e}") return None @staticmethod def _calculate_sha256(file_path: str) -> str: """Calculate SHA256 checksum of a file.""" sha256_hash = hashlib.sha256() with open(file_path, "rb") as f: for chunk in iter(lambda: f.read(8192), b""): sha256_hash.update(chunk) return sha256_hash.hexdigest() @classmethod def _verify_checksum(cls, file_path: str, expected_sha256: str) -> bool: """Verify file checksum.""" try: actual_sha256 = cls._calculate_sha256(file_path) if actual_sha256.lower() == expected_sha256.lower(): log.debug(f"Checksum verified: {file_path}") return True else: log.error(f"Checksum mismatch for {file_path}: expected {expected_sha256}, got {actual_sha256}") return False except Exception as e: log.error(f"Failed to verify checksum: {e}") return False # ============== Windows File Locking ============== @staticmethod def _is_file_locked(file_path: str) -> bool: """Check if file is locked (Windows).""" if platform.system() != "Windows": return False if not os.path.exists(file_path): return False try: with open(file_path, "a"): pass return False except (OSError, PermissionError): return True @classmethod def _safe_remove(cls, file_path: str) -> bool: """Safely remove file, handle Windows file locking.""" if not os.path.exists(file_path): return True if platform.system() == "Windows" and cls._is_file_locked(file_path): temp_name = f"{file_path}.old.{uuid.uuid4().hex[:8]}" try: os.rename(file_path, temp_name) log.info(f"File locked, renamed to: {temp_name}") cls._mark_for_cleanup(os.path.dirname(file_path), temp_name) return True except PermissionError: log.warning(f"Cannot remove/rename locked file: {file_path}") return False else: try: os.remove(file_path) return True except OSError as e: log.warning(f"Failed to remove file {file_path}: {e}") return False @classmethod def _mark_for_cleanup(cls, pasls_dir: str, file_path: str) -> None: """Mark file for later cleanup.""" cleanup_file = cls._meta_file(pasls_dir, "cleanup_list") try: with open(cleanup_file, "a", encoding="utf-8") as f: f.write(file_path + "\n") except OSError: pass @classmethod def _cleanup_old_files(cls, pasls_dir: str) -> None: """Clean up old files marked for deletion.""" cleanup_file = cls._meta_file(pasls_dir, "cleanup_list") if not os.path.exists(cleanup_file): return try: with open(cleanup_file, encoding="utf-8") as f: files = [line.strip() for line in f if line.strip()] remaining = [] for file_path in files: if os.path.exists(file_path): try: os.remove(file_path) log.debug(f"Cleaned up old file: {file_path}") except OSError: remaining.append(file_path) if remaining: with open(cleanup_file, "w", encoding="utf-8") as f: f.write("\n".join(remaining) + "\n") else: os.remove(cleanup_file) except OSError: pass # ============== Download and Atomic Update ============== @classmethod def _download_archive(cls, url: str, target_path: str) -> bool: """Download archive to specified path.""" try: os.makedirs(os.path.dirname(target_path), exist_ok=True) req = urllib.request.Request(url, headers={"User-Agent": "Serena-LSP"}) with urllib.request.urlopen(req, timeout=60) as response: with open(target_path, "wb") as f: while True: chunk = response.read(8192) if not chunk: break f.write(chunk) return True except Exception as e: log.error(f"Failed to download {url}: {type(e).__name__}: {e}") return False @classmethod def _is_safe_tar_member(cls, member: tarfile.TarInfo, target_dir: str) -> bool: """Check if tar member is safe (prevent path traversal attack).""" # Check for .. in path components if ".." in member.name.split("/") or ".." in member.name.split("\\"): return False # Check extracted path is within target directory abs_target = os.path.abspath(target_dir) abs_member = os.path.abspath(os.path.join(target_dir, member.name)) return abs_member.startswith(abs_target + os.sep) or abs_member == abs_target @classmethod def _extract_archive(cls, archive_path: str, target_dir: str, archive_type: str) -> bool: """Safely extract archive to specified directory.""" try: os.makedirs(target_dir, exist_ok=True) if archive_type == "gztar": with tarfile.open(archive_path, "r:gz") as tar: for member in tar.getmembers(): if not cls._is_safe_tar_member(member, target_dir): log.error(f"Unsafe tar member detected (path traversal): {member.name}") return False tar.extractall(target_dir) elif archive_type == "zip": with zipfile.ZipFile(archive_path, "r") as zip_ref: for name in zip_ref.namelist(): if ".." in name.split("/") or ".." in name.split("\\"): log.error(f"Unsafe zip member detected (path traversal): {name}") return False abs_target = os.path.abspath(target_dir) abs_member = os.path.abspath(os.path.join(target_dir, name)) if not (abs_member.startswith(abs_target + os.sep) or abs_member == abs_target): log.error(f"Unsafe zip member detected (path traversal): {name}") return False zip_ref.extractall(target_dir) else: log.error(f"Unsupported archive type: {archive_type}") return False # Handle nested directory: if extraction created a single subdirectory, # move its contents up to target_dir (common with GitHub release archives) cls._flatten_single_subdir(target_dir) return True except Exception as e: log.error(f"Failed to extract archive: {type(e).__name__}: {e}") return False @classmethod def _flatten_single_subdir(cls, target_dir: str) -> None: """If target_dir contains only a single subdirectory, move its contents up.""" entries = os.listdir(target_dir) if len(entries) == 1: subdir = os.path.join(target_dir, entries[0]) if os.path.isdir(subdir): # Move all contents from subdir to target_dir for item in os.listdir(subdir): src = os.path.join(subdir, item) dst = os.path.join(target_dir, item) shutil.move(src, dst) # Remove the now-empty subdirectory os.rmdir(subdir) @classmethod def _get_archive_filename(cls, dep: RuntimeDependency) -> str: """Get archive filename from URL.""" assert dep.url is not None, "RuntimeDependency.url must be set" return dep.url.split("/")[-1] @classmethod def _atomic_install(cls, pasls_dir: str, deps: RuntimeDependencyCollection, checksums: dict[str, str] | None) -> bool: """Atomic update: download -> verify checksum -> extract -> replace.""" temp_dir = pasls_dir + ".tmp" backup_dir = pasls_dir + ".backup" temp_archive_dir = os.path.join(os.path.expanduser("~"), "solidlsp_tmp") try: dep = deps.get_single_dep_for_current_platform() assert dep.url is not None, "RuntimeDependency.url must be set" assert dep.archive_type is not None, "RuntimeDependency.archive_type must be set" archive_filename = cls._get_archive_filename(dep) archive_path = os.path.join(temp_archive_dir, archive_filename) # 1. Clean up any existing temp directory if os.path.exists(temp_dir): shutil.rmtree(temp_dir) os.makedirs(temp_archive_dir, exist_ok=True) # 2. Download archive log.info(f"Downloading pasls archive: {archive_filename}") if not cls._download_archive(dep.url, archive_path): log.error("Failed to download pasls archive") return False # 3. Verify SHA256 checksum (critical security step, before extraction) if checksums: expected_sha256 = checksums.get(archive_filename) if expected_sha256: log.info(f"Verifying SHA256 checksum for {archive_filename}...") if not cls._verify_checksum(archive_path, expected_sha256): log.error(f"SHA256 checksum verification FAILED for {archive_filename}") log.error("Aborting installation due to checksum mismatch - possible security issue!") try: os.remove(archive_path) except OSError: pass return False log.info("SHA256 checksum verified successfully") else: log.warning(f"No checksum found for {archive_filename} in checksums file") else: log.warning("No checksums available - skipping verification (not recommended for production)") # 4. Extract to temp directory os.makedirs(temp_dir, exist_ok=True) log.info("Extracting archive to temporary directory...") if not cls._extract_archive(archive_path, temp_dir, dep.archive_type): log.error("Failed to extract archive") return False # 5. Set execute permission binary_path = deps.binary_path(temp_dir) if os.path.exists(binary_path): try: os.chmod(binary_path, 0o755) except OSError: pass # May fail on Windows # 6. Backup old version if os.path.exists(pasls_dir): if os.path.exists(backup_dir): shutil.rmtree(backup_dir) shutil.move(pasls_dir, backup_dir) # 7. Replace with new version shutil.move(temp_dir, pasls_dir) # 8. Restore meta directory from backup (preserves version info, last_check, etc.) if os.path.exists(backup_dir): backup_meta = os.path.join(backup_dir, cls.META_DIR) if os.path.exists(backup_meta): target_meta = os.path.join(pasls_dir, cls.META_DIR) if not os.path.exists(target_meta): shutil.copytree(backup_meta, target_meta) # 9. Clean up downloaded archive and temp directory try: os.remove(archive_path) os.rmdir(temp_archive_dir) except OSError: pass log.info("pasls installation completed successfully") return True except Exception as e: log.error(f"Installation failed: {e}") # Rollback if os.path.exists(backup_dir) and not os.path.exists(pasls_dir): try: shutil.move(backup_dir, pasls_dir) log.info("Rolled back to previous version") except Exception as rollback_error: log.error(f"Rollback failed: {rollback_error}") # Clean up temp directory if os.path.exists(temp_dir): try: shutil.rmtree(temp_dir) except Exception: pass return False @classmethod def _setup_runtime_dependencies(cls, solidlsp_settings: SolidLSPSettings) -> str: """ Setup runtime dependencies for Pascal Language Server (pasls). Automatically checks for updates every 24 hours with security verification. Returns: str: The command to start the pasls server """ # Check if pasls is already in PATH pasls_in_path = shutil.which("pasls") if pasls_in_path: log.info(f"Found pasls in PATH: {pasls_in_path}") return quote_windows_path(pasls_in_path) pasls_dir = cls.ls_resources_dir(solidlsp_settings) os.makedirs(pasls_dir, exist_ok=True) # Clean up old files from previous sessions cls._cleanup_old_files(pasls_dir) # Use RuntimeDependencyCollection for platform detection # Asset names follow zen010101/pascal-language-server release convention: # pasls-{cpu_arch}-{os}.{ext} where cpu_arch is x86_64/aarch64/i386 deps = RuntimeDependencyCollection( [ RuntimeDependency( id="PascalLanguageServer", description="Pascal Language Server for Linux (x64)", url=f"{cls.PASLS_RELEASES_URL}/pasls-x86_64-linux.tar.gz", platform_id="linux-x64", archive_type="gztar", binary_name="pasls", ), RuntimeDependency( id="PascalLanguageServer", description="Pascal Language Server for Linux (arm64)", url=f"{cls.PASLS_RELEASES_URL}/pasls-aarch64-linux.tar.gz", platform_id="linux-arm64", archive_type="gztar", binary_name="pasls", ), RuntimeDependency( id="PascalLanguageServer", description="Pascal Language Server for macOS (x64)", url=f"{cls.PASLS_RELEASES_URL}/pasls-x86_64-darwin.zip", platform_id="osx-x64", archive_type="zip", binary_name="pasls", ), RuntimeDependency( id="PascalLanguageServer", description="Pascal Language Server for macOS (arm64)", url=f"{cls.PASLS_RELEASES_URL}/pasls-aarch64-darwin.zip", platform_id="osx-arm64", archive_type="zip", binary_name="pasls", ), RuntimeDependency( id="PascalLanguageServer", description="Pascal Language Server for Windows (x64)", url=f"{cls.PASLS_RELEASES_URL}/pasls-x86_64-win64.zip", platform_id="win-x64", archive_type="zip", binary_name="pasls.exe", ), ] ) pasls_executable_path = deps.binary_path(pasls_dir) # Determine if download is needed need_download = False latest_version = None checksums = None if not os.path.exists(pasls_executable_path): # First install log.info("pasls not found, will download...") need_download = True latest_version = cls._get_latest_version() checksums = cls._get_checksums() elif cls._should_check_update(pasls_dir): # Check for updates log.debug("Checking for pasls updates...") latest_version = cls._get_latest_version() local_version = cls._get_local_version(pasls_dir) if cls._is_newer_version(latest_version, local_version): log.info(f"New pasls version available: {latest_version} (current: {local_version})") # Check Windows file locking if cls._is_file_locked(pasls_executable_path): log.warning("Cannot update pasls: file is in use. Will retry next time.") else: need_download = True checksums = cls._get_checksums() else: log.debug(f"pasls is up to date: {local_version}") if need_download: if cls._atomic_install(pasls_dir, deps, checksums): # Update metadata after successful installation if latest_version: cls._save_local_version(pasls_dir, latest_version) else: # API failed but download succeeded, record placeholder version cls._save_local_version(pasls_dir, "unknown") cls._update_last_check(pasls_dir) else: # Installation failed, use existing version if available if not os.path.exists(pasls_executable_path): raise RuntimeError("Failed to install pasls and no local version available") log.warning("Update failed, using existing version") # Update check time even if no update (avoid frequent checks) if not need_download and cls._should_check_update(pasls_dir): cls._update_last_check(pasls_dir) assert os.path.exists(pasls_executable_path), f"pasls executable not found at {pasls_executable_path}" # Ensure execute permission try: os.chmod(pasls_executable_path, 0o755) except OSError: pass # May fail on Windows, ignore log.info(f"Using pasls at: {pasls_executable_path}") return quote_windows_path(pasls_executable_path) @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the Pascal Language Server. pasls (genericptr/pascal-language-server) reads compiler paths from: 1. Environment variables (PP, FPCDIR, LAZARUSDIR) via TCodeToolsOptions.InitWithEnvironmentVariables 2. Lazarus config files via GuessCodeToolConfig We only pass target OS/CPU in initializationOptions if explicitly set. """ root_uri = pathlib.Path(repository_absolute_path).as_uri() # Build initializationOptions from environment variables # pasls reads these to configure CodeTools: # - PP: Path to FPC compiler executable # - FPCDIR: Path to FPC source directory # - LAZARUSDIR: Path to Lazarus directory (only needed for LCL projects) # - FPCTARGET: Target OS # - FPCTARGETCPU: Target CPU initialization_options: dict = {} env_vars = ["PP", "FPCDIR", "LAZARUSDIR", "FPCTARGET", "FPCTARGETCPU"] for var in env_vars: value = os.environ.get(var, "") if value: initialization_options[var] = value initialize_params = { "locale": "en", "capabilities": { "textDocument": { "synchronization": { "didSave": True, "dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, }, "completion": { "dynamicRegistration": True, "completionItem": { "snippetSupport": True, "commitCharactersSupport": True, "documentationFormat": ["markdown", "plaintext"], }, }, "hover": { "dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"], }, "signatureHelp": { "dynamicRegistration": True, "signatureInformation": { "documentationFormat": ["markdown", "plaintext"], }, }, "definition": {"dynamicRegistration": True, "linkSupport": True}, "references": {"dynamicRegistration": True}, "documentHighlight": {"dynamicRegistration": True}, "documentSymbol": { "dynamicRegistration": True, "hierarchicalDocumentSymbolSupport": True, "symbolKind": {"valueSet": list(range(1, 27))}, }, "codeAction": { "dynamicRegistration": True, "codeActionLiteralSupport": { "codeActionKind": { "valueSet": [ "quickfix", "refactor", "refactor.extract", "refactor.inline", "refactor.rewrite", "source", "source.organizeImports", ] } }, }, "formatting": {"dynamicRegistration": True}, "rangeFormatting": {"dynamicRegistration": True}, }, "workspace": { "workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}, "symbol": {"dynamicRegistration": True}, "executeCommand": {"dynamicRegistration": True}, "configuration": True, "workspaceEdit": { "documentChanges": True, }, }, }, "initializationOptions": initialization_options, "processId": os.getpid(), "rootPath": repository_absolute_path, "rootUri": root_uri, "workspaceFolders": [ { "uri": root_uri, "name": os.path.basename(repository_absolute_path), } ], } return initialize_params # type: ignore def _start_server(self) -> None: """ Starts the Pascal Language Server, waits for the server to be ready and yields the LanguageServer instance. """ def register_capability_handler(params: dict) -> None: log.debug(f"Capability registered: {params}") return def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") # Mark server as ready when we see initialization messages message_text = msg.get("message", "") if "initialized" in message_text.lower() or "ready" in message_text.lower(): log.info("Pascal language server ready signal detected") self.server_ready.set() def publish_diagnostics(params: dict) -> None: log.debug(f"Diagnostics: {params}") return def do_nothing(params: dict) -> None: return self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_notification("window/logMessage", window_log_message) self.server.on_notification("window/showMessage", window_log_message) self.server.on_notification("textDocument/publishDiagnostics", publish_diagnostics) self.server.on_notification("$/progress", do_nothing) log.info("Starting Pascal server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) log.debug(f"Received initialize response from Pascal server: {init_response}") # Verify capabilities capabilities = init_response.get("capabilities", {}) assert "textDocumentSync" in capabilities # Check for various capabilities if "completionProvider" in capabilities: log.info("Pascal server supports code completion") if "definitionProvider" in capabilities: log.info("Pascal server supports go to definition") if "referencesProvider" in capabilities: log.info("Pascal server supports find references") if "documentSymbolProvider" in capabilities: log.info("Pascal server supports document symbols") self.server.notify.initialized({}) # Wait for server readiness with timeout log.info("Waiting for Pascal language server to be ready...") if not self.server_ready.wait(timeout=5.0): # pasls may not send explicit ready signals, so we proceed after timeout log.info("Timeout waiting for Pascal server ready signal, assuming server is ready") self.server_ready.set() else: log.info("Pascal server initialization complete") def is_ignored_dirname(self, dirname: str) -> bool: """ Check if a directory should be ignored for Pascal projects. Common Pascal/Lazarus directories to ignore. """ ignored_dirs = { "lib", "backup", "__history", "__recovery", "bin", ".git", ".svn", ".hg", "node_modules", } return dirname.lower() in ignored_dirs ================================================ FILE: src/solidlsp/language_servers/perl_language_server.py ================================================ """ Provides Perl specific instantiation of the LanguageServer class using Perl::LanguageServer. Note: Windows is not supported as Nix itself doesn't support Windows natively. """ import logging import os import pathlib import subprocess import time from typing import Any from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_utils import PlatformId, PlatformUtils from solidlsp.lsp_protocol_handler.lsp_types import DidChangeConfigurationParams, InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) class PerlLanguageServer(SolidLanguageServer): """ Provides Perl specific instantiation of the LanguageServer class using Perl::LanguageServer. """ @staticmethod def _get_perl_version() -> str | None: """Get the installed Perl version or None if not found.""" try: result = subprocess.run(["perl", "-v"], capture_output=True, text=True, check=False) if result.returncode == 0: return result.stdout.strip() except FileNotFoundError: return None return None @staticmethod def _get_perl_language_server_version() -> str | None: """Get the installed Perl::LanguageServer version or None if not found.""" try: result = subprocess.run( ["perl", "-MPerl::LanguageServer", "-e", "print $Perl::LanguageServer::VERSION"], capture_output=True, text=True, check=False, ) if result.returncode == 0: return result.stdout.strip() except FileNotFoundError: return None return None @override def is_ignored_dirname(self, dirname: str) -> bool: # For Perl projects, we should ignore: # - blib: build library directory # - local: local Perl module installation # - .carton: Carton dependency manager cache # - vendor: vendored dependencies # - _build: Module::Build output return super().is_ignored_dirname(dirname) or dirname in ["blib", "local", ".carton", "vendor", "_build", "cover_db"] @classmethod def _setup_runtime_dependencies(cls) -> str: """ Check if required Perl runtime dependencies are available. Raises RuntimeError with helpful message if dependencies are missing. """ platform_id = PlatformUtils.get_platform_id() valid_platforms = [ PlatformId.LINUX_x64, PlatformId.LINUX_arm64, PlatformId.OSX, PlatformId.OSX_x64, PlatformId.OSX_arm64, ] if platform_id not in valid_platforms: raise RuntimeError(f"Platform {platform_id} is not supported for Perl at the moment") perl_version = cls._get_perl_version() if not perl_version: raise RuntimeError( "Perl is not installed. Please install Perl from https://www.perl.org/get.html and make sure it is added to your PATH." ) perl_ls_version = cls._get_perl_language_server_version() if not perl_ls_version: raise RuntimeError( "Found a Perl version but Perl::LanguageServer is not installed.\n" "Please install Perl::LanguageServer: cpanm Perl::LanguageServer\n" "See: https://metacpan.org/pod/Perl::LanguageServer" ) return "perl -MPerl::LanguageServer -e 'Perl::LanguageServer::run'" def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): # Setup runtime dependencies before initializing perl_ls_cmd = self._setup_runtime_dependencies() super().__init__( config, repository_root_path, ProcessLaunchInfo(cmd=perl_ls_cmd, cwd=repository_root_path), "perl", solidlsp_settings ) self.request_id = 0 @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for Perl::LanguageServer. Based on the expected structure from Perl::LanguageServer::Methods::_rpcreq_initialize. """ root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "processId": os.getpid(), "rootPath": repository_absolute_path, "rootUri": root_uri, "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, "definition": {"dynamicRegistration": True}, "references": {"dynamicRegistration": True}, "documentSymbol": {"dynamicRegistration": True}, "hover": {"dynamicRegistration": True}, }, "workspace": { "workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}, "symbol": {"dynamicRegistration": True}, }, }, "initializationOptions": {}, "workspaceFolders": [ { "uri": root_uri, "name": os.path.basename(repository_absolute_path), } ], } return initialize_params # type: ignore def _start_server(self) -> None: """Start Perl::LanguageServer process""" def register_capability_handler(params: Any) -> None: return def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") def do_nothing(params: Any) -> None: return def workspace_configuration_handler(params: Any) -> Any: """Handle workspace/configuration request from Perl::LanguageServer.""" log.info(f"Received workspace/configuration request: {params}") perl_config = { "perlInc": [self.repository_root_path, "."], "fileFilter": [".pm", ".pl"], "ignoreDirs": [".git", ".svn", "blib", "local", ".carton", "vendor", "_build", "cover_db"], } return [perl_config] self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_request("workspace/configuration", workspace_configuration_handler) self.server.on_notification("window/logMessage", window_log_message) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) log.info("Starting Perl::LanguageServer process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) log.info( "After sent initialize params", ) # Verify server capabilities assert "textDocumentSync" in init_response["capabilities"] assert "definitionProvider" in init_response["capabilities"] assert "referencesProvider" in init_response["capabilities"] self.server.notify.initialized({}) # Send workspace configuration to Perl::LanguageServer # Perl::LanguageServer requires didChangeConfiguration to set perlInc, fileFilter, and ignoreDirs # See: Perl::LanguageServer::Methods::workspace::_rpcnot_didChangeConfiguration perl_config: DidChangeConfigurationParams = { "settings": { "perl": { "perlInc": [self.repository_root_path, "."], "fileFilter": [".pm", ".pl"], "ignoreDirs": [".git", ".svn", "blib", "local", ".carton", "vendor", "_build", "cover_db"], } } } log.info(f"Sending workspace/didChangeConfiguration notification with config: {perl_config}") self.server.notify.workspace_did_change_configuration(perl_config) # Perl::LanguageServer needs time to index files and resolve cross-file references # Without this delay, requests for definitions/references may return empty results settling_time = 0.5 log.info(f"Allowing {settling_time} seconds for Perl::LanguageServer to index files...") time.sleep(settling_time) log.info("Perl::LanguageServer settling period complete") ================================================ FILE: src/solidlsp/language_servers/phpactor.py ================================================ """ Provides PHP specific instantiation of the LanguageServer class using Phpactor. """ import logging import os import pathlib import re import shutil import stat import subprocess from overrides import override from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.ls_utils import FileUtils from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) PHPACTOR_VERSION = "2025.12.21.1" PHPACTOR_PHAR_URL = f"https://github.com/phpactor/phpactor/releases/download/{PHPACTOR_VERSION}/phpactor.phar" class PhpactorServer(SolidLanguageServer): """ Provides PHP specific instantiation of the LanguageServer class using Phpactor. Phpactor is an open-source (MIT) PHP language server that requires PHP 8.1+ on the system. It is an alternative to Intelephense, which is the default PHP language server. You can pass the following entries in ls_specific_settings["php_phpactor"]: - ignore_vendor: whether to ignore directories named "vendor" (default: true) """ @override def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in self._ignored_dirnames class DependencyProvider(LanguageServerDependencyProviderSinglePath): def _get_or_install_core_dependency(self) -> str: """ Setup runtime dependencies for Phpactor and return the path to the PHAR file. """ # Verify PHP is installed php_path = shutil.which("php") assert ( php_path is not None ), "PHP is not installed or not found in PATH. Phpactor requires PHP 8.1+. Please install PHP and try again." # Check PHP version (Phpactor requires PHP 8.1+) result = subprocess.run(["php", "--version"], capture_output=True, text=True, check=False) php_version_output = result.stdout.strip() log.info(f"PHP version: {php_version_output}") version_match = re.search(r"PHP (\d+)\.(\d+)", php_version_output) if version_match: major, minor = int(version_match.group(1)), int(version_match.group(2)) if major < 8 or (major == 8 and minor < 1): raise RuntimeError(f"PHP {major}.{minor} detected, but Phpactor requires PHP 8.1+. Please upgrade PHP.") else: log.warning("Could not parse PHP version from output. Continuing anyway.") phpactor_phar_path = os.path.join(self._ls_resources_dir, "phpactor.phar") if not os.path.exists(phpactor_phar_path): os.makedirs(self._ls_resources_dir, exist_ok=True) log.info(f"Downloading phpactor PHAR from {PHPACTOR_PHAR_URL}") FileUtils.download_and_extract_archive(PHPACTOR_PHAR_URL, phpactor_phar_path, "binary") assert os.path.exists(phpactor_phar_path), f"phpactor PHAR not found at {phpactor_phar_path}, download may have failed." # Ensure the PHAR is executable current_mode = os.stat(phpactor_phar_path).st_mode os.chmod(phpactor_phar_path, current_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) return phpactor_phar_path def _create_launch_command(self, core_path: str) -> list[str]: return ["php", core_path, "language-server"] def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): super().__init__(config, repository_root_path, None, "php", solidlsp_settings) # Override internal language enum for correct file matching self.language = Language.PHP_PHPACTOR self._ignored_dirnames = {"node_modules", "cache"} if self._custom_settings.get("ignore_vendor", True): self._ignored_dirnames.add("vendor") log.info(f"Ignoring the following directories for PHP (Phpactor): {', '.join(sorted(self._ignored_dirnames))}") def _create_dependency_provider(self) -> LanguageServerDependencyProvider: return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: """ Returns the initialization params for the Phpactor Language Server. """ root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "processId": os.getpid(), "rootPath": repository_absolute_path, "rootUri": root_uri, "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, "definition": {"dynamicRegistration": True}, "documentSymbol": { "hierarchicalDocumentSymbolSupport": True, "symbolKind": {"valueSet": list(range(1, 27))}, }, }, "workspace": { "workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}, }, }, "workspaceFolders": [ { "uri": root_uri, "name": os.path.basename(repository_absolute_path), } ], "initializationOptions": { "language_server_phpstan.enabled": False, "language_server_psalm.enabled": False, "language_server_php_cs_fixer.enabled": False, }, } return initialize_params # type: ignore def _start_server(self) -> None: """Start Phpactor server process.""" def register_capability_handler(params: dict) -> None: return def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") def do_nothing(params: dict) -> None: return self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_notification("window/logMessage", window_log_message) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) log.info("Starting Phpactor server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) log.info("After sent initialize params") # Verify server capabilities assert "capabilities" in init_response assert init_response["capabilities"].get("definitionProvider"), "Phpactor did not advertise definition support" self.server.notify.initialized({}) ================================================ FILE: src/solidlsp/language_servers/powershell_language_server.py ================================================ """ Provides PowerShell specific instantiation of the LanguageServer class using PowerShell Editor Services. Contains various configurations and settings specific to PowerShell scripting. """ import logging import os import pathlib import platform import shutil import tempfile import threading import zipfile from pathlib import Path import requests from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) # PowerShell Editor Services version to download PSES_VERSION = "4.4.0" class PowerShellLanguageServer(SolidLanguageServer): """ Provides PowerShell specific instantiation of the LanguageServer class using PowerShell Editor Services. Contains various configurations and settings specific to PowerShell scripting. """ @override def is_ignored_dirname(self, dirname: str) -> bool: # For PowerShell projects, ignore common build/output directories return super().is_ignored_dirname(dirname) or dirname in [ "bin", "obj", ".vscode", "TestResults", "Output", ] @staticmethod def _get_pwsh_path() -> str | None: """Get the path to PowerShell Core (pwsh) executable.""" # Check if pwsh is in PATH pwsh = shutil.which("pwsh") if pwsh: return pwsh # Check common installation locations home = Path.home() system = platform.system() possible_paths: list[Path] = [] if system == "Windows": possible_paths = [ Path(os.environ.get("PROGRAMFILES", "C:\\Program Files")) / "PowerShell" / "7" / "pwsh.exe", Path(os.environ.get("PROGRAMFILES", "C:\\Program Files")) / "PowerShell" / "7-preview" / "pwsh.exe", home / "AppData" / "Local" / "Microsoft" / "PowerShell" / "pwsh.exe", ] elif system == "Darwin": possible_paths = [ Path("/usr/local/bin/pwsh"), Path("/opt/homebrew/bin/pwsh"), home / ".dotnet" / "tools" / "pwsh", ] else: # Linux possible_paths = [ Path("/usr/bin/pwsh"), Path("/usr/local/bin/pwsh"), Path("/opt/microsoft/powershell/7/pwsh"), home / ".dotnet" / "tools" / "pwsh", ] for path in possible_paths: if path.exists(): return str(path) return None @classmethod def _get_pses_path(cls, solidlsp_settings: SolidLSPSettings) -> str | None: """Get the path to PowerShell Editor Services installation.""" install_dir = Path(cls.ls_resources_dir(solidlsp_settings)) / "powershell" start_script = install_dir / "PowerShellEditorServices" / "Start-EditorServices.ps1" if start_script.exists(): return str(start_script) return None @classmethod def _download_pses(cls, solidlsp_settings: SolidLSPSettings) -> str: """Download and install PowerShell Editor Services.""" download_url = ( f"https://github.com/PowerShell/PowerShellEditorServices/releases/download/v{PSES_VERSION}/PowerShellEditorServices.zip" ) # Create installation directory install_dir = Path(cls.ls_resources_dir(solidlsp_settings)) / "powershell" install_dir.mkdir(parents=True, exist_ok=True) # Download the file log.info(f"Downloading PowerShell Editor Services from {download_url}...") response = requests.get(download_url, stream=True, timeout=120) response.raise_for_status() # Save the zip file zip_path = install_dir / "PowerShellEditorServices.zip" with open(zip_path, "wb") as f: for chunk in response.iter_content(chunk_size=8192): f.write(chunk) log.info(f"Extracting PowerShell Editor Services to {install_dir}...") with zipfile.ZipFile(zip_path, "r") as zip_ref: zip_ref.extractall(install_dir) # Clean up zip file zip_path.unlink() start_script = install_dir / "PowerShellEditorServices" / "Start-EditorServices.ps1" if not start_script.exists(): raise RuntimeError(f"Failed to find Start-EditorServices.ps1 after extraction at {start_script}") log.info(f"PowerShell Editor Services installed at: {install_dir}") return str(start_script) @classmethod def _setup_runtime_dependency(cls, solidlsp_settings: SolidLSPSettings) -> tuple[str, str, str]: """ Check if required PowerShell runtime dependencies are available. Downloads PowerShell Editor Services if not present. Returns: tuple: (pwsh_path, start_script_path, bundled_modules_path) """ # Check for PowerShell Core pwsh_path = cls._get_pwsh_path() if not pwsh_path: raise RuntimeError( "PowerShell Core (pwsh) is not installed or not in PATH. " "Please install PowerShell 7+ from https://github.com/PowerShell/PowerShell" ) # Check for PowerShell Editor Services pses_path = cls._get_pses_path(solidlsp_settings) if not pses_path: log.info("PowerShell Editor Services not found. Downloading...") pses_path = cls._download_pses(solidlsp_settings) # The bundled modules path is the directory containing PowerShellEditorServices bundled_modules_path = str(Path(pses_path).parent) return pwsh_path, pses_path, bundled_modules_path def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): pwsh_path, pses_path, bundled_modules_path = self._setup_runtime_dependency(solidlsp_settings) # Create a temp directory for PSES logs and session details pses_temp_dir = Path(tempfile.gettempdir()) / "solidlsp_pses" pses_temp_dir.mkdir(parents=True, exist_ok=True) log_path = pses_temp_dir / "pses.log" session_details_path = pses_temp_dir / "session.json" # Build the command to start PowerShell Editor Services in stdio mode # PSES requires several parameters beyond just -Stdio # Using list format for robust argument handling - the PowerShell command # after -Command must be a single string element pses_command = ( f"& '{pses_path}' " f"-HostName 'SolidLSP' " f"-HostProfileId 'solidlsp' " f"-HostVersion '1.0.0' " f"-BundledModulesPath '{bundled_modules_path}' " f"-LogPath '{log_path}' " f"-LogLevel 'Information' " f"-SessionDetailsPath '{session_details_path}' " f"-Stdio" ) cmd: list[str] = [ pwsh_path, "-NoLogo", "-NoProfile", "-Command", pses_command, ] super().__init__( config, repository_root_path, ProcessLaunchInfo(cmd=cmd, cwd=repository_root_path), "powershell", solidlsp_settings, ) self.server_ready = threading.Event() @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the PowerShell Editor Services. """ root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, "completion": { "dynamicRegistration": True, "completionItem": { "snippetSupport": True, "commitCharactersSupport": True, "documentationFormat": ["markdown", "plaintext"], "deprecatedSupport": True, }, }, "definition": {"dynamicRegistration": True}, "references": {"dynamicRegistration": True}, "documentSymbol": { "dynamicRegistration": True, "hierarchicalDocumentSymbolSupport": True, "symbolKind": {"valueSet": list(range(1, 27))}, }, "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, "signatureHelp": { "dynamicRegistration": True, "signatureInformation": { "documentationFormat": ["markdown", "plaintext"], "parameterInformation": {"labelOffsetSupport": True}, }, }, "codeAction": {"dynamicRegistration": True}, "formatting": {"dynamicRegistration": True}, "rangeFormatting": {"dynamicRegistration": True}, }, "workspace": { "workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}, "configuration": True, "symbol": { "dynamicRegistration": True, "symbolKind": {"valueSet": list(range(1, 27))}, }, }, }, "processId": os.getpid(), "rootPath": repository_absolute_path, "rootUri": root_uri, "workspaceFolders": [ { "uri": root_uri, "name": os.path.basename(repository_absolute_path), } ], } return initialize_params # type: ignore[return-value] def _start_server(self) -> None: """ Starts the PowerShell Editor Services, waits for the server to be ready. """ self._dynamic_capabilities: set[str] = set() def register_capability_handler(params: dict) -> None: """Handle dynamic capability registration from PSES.""" registrations = params.get("registrations", []) for reg in registrations: method = reg.get("method", "") log.info(f"PSES registered dynamic capability: {method}") self._dynamic_capabilities.add(method) # Mark server ready when we get document symbol registration if method == "textDocument/documentSymbol": self.server_ready.set() return def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") # Check for PSES ready signals message_text = msg.get("message", "") if "started" in message_text.lower() or "ready" in message_text.lower(): log.info("PowerShell Editor Services ready signal detected") self.server_ready.set() def do_nothing(params: dict) -> None: return self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_notification("window/logMessage", window_log_message) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) self.server.on_notification("powerShell/executionStatusChanged", do_nothing) log.info("Starting PowerShell Editor Services process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) log.info(f"Received initialize response from PowerShell server: {init_response}") # Verify server capabilities - PSES uses dynamic capability registration # so we check for either static or dynamic capabilities capabilities = init_response.get("capabilities", {}) log.info(f"Server capabilities: {capabilities}") # Send initialized notification to trigger dynamic capability registration self.server.notify.initialized({}) # Wait for server readiness with timeout log.info("Waiting for PowerShell Editor Services to be ready...") if not self.server_ready.wait(timeout=10.0): # Fallback: assume server is ready after timeout log.info("Timeout waiting for PSES ready signal, proceeding anyway") self.server_ready.set() else: log.info("PowerShell Editor Services initialization complete") ================================================ FILE: src/solidlsp/language_servers/pyright_server.py ================================================ """ Provides Python specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Python. """ import logging import os import pathlib import re import sys import threading from typing import cast from overrides import override from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) class PyrightServer(SolidLanguageServer): """ Provides Python specific instantiation of the LanguageServer class using Pyright. Contains various configurations and settings specific to Python. """ def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a PyrightServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ super().__init__( config, repository_root_path, None, "python", solidlsp_settings, ) # Event to signal when initial workspace analysis is complete self.analysis_complete = threading.Event() self.found_source_files = False def _create_dependency_provider(self) -> LanguageServerDependencyProvider: return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) class DependencyProvider(LanguageServerDependencyProviderSinglePath): def _get_or_install_core_dependency(self) -> str: return sys.executable def _create_launch_command(self, core_path: str) -> list[str]: return [core_path, "-m", "pyright.langserver", "--stdio"] @override def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in ["venv", "__pycache__"] @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the Pyright Language Server. """ # Create basic initialization parameters initialize_params = { # type: ignore "processId": os.getpid(), "rootPath": repository_absolute_path, "rootUri": pathlib.Path(repository_absolute_path).as_uri(), "initializationOptions": { "exclude": [ "**/__pycache__", "**/.venv", "**/.env", "**/build", "**/dist", "**/.pixi", ], "reportMissingImports": "error", }, "capabilities": { "workspace": { "workspaceEdit": {"documentChanges": True}, "didChangeConfiguration": {"dynamicRegistration": True}, "didChangeWatchedFiles": {"dynamicRegistration": True}, "symbol": { "dynamicRegistration": True, "symbolKind": {"valueSet": list(range(1, 27))}, }, "executeCommand": {"dynamicRegistration": True}, }, "textDocument": { "synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True}, "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, "signatureHelp": { "dynamicRegistration": True, "signatureInformation": { "documentationFormat": ["markdown", "plaintext"], "parameterInformation": {"labelOffsetSupport": True}, }, }, "definition": {"dynamicRegistration": True}, "references": {"dynamicRegistration": True}, "documentSymbol": { "dynamicRegistration": True, "symbolKind": {"valueSet": list(range(1, 27))}, "hierarchicalDocumentSymbolSupport": True, }, "publishDiagnostics": {"relatedInformation": True}, }, }, "workspaceFolders": [ {"uri": pathlib.Path(repository_absolute_path).as_uri(), "name": os.path.basename(repository_absolute_path)} ], } return cast(InitializeParams, initialize_params) def _start_server(self) -> None: """ Starts the Pyright Language Server and waits for initial workspace analysis to complete. This prevents zombie processes by ensuring Pyright has finished its initial background tasks before we consider the server ready. Usage: ``` async with lsp.start_server(): # LanguageServer has been initialized and workspace analysis is complete await lsp.request_definition(...) await lsp.request_references(...) # Shutdown the LanguageServer on exit from scope # LanguageServer has been shutdown cleanly ``` """ def execute_client_command_handler(params: dict) -> list: return [] def do_nothing(params: dict) -> None: return def window_log_message(msg: dict) -> None: """ Monitor Pyright's log messages to detect when initial analysis is complete. Pyright logs "Found X source files" when it finishes scanning the workspace. """ message_text = msg.get("message", "") log.info(f"LSP: window/logMessage: {message_text}") # Look for "Found X source files" which indicates workspace scanning is complete # Unfortunately, pyright is unreliable and there seems to be no better way if re.search(r"Found \d+ source files?", message_text): log.info("Pyright workspace scanning complete") self.found_source_files = True self.analysis_complete.set() def check_experimental_status(params: dict) -> None: """ Also listen for experimental/serverStatus as a backup signal """ if params.get("quiescent") == True: log.info("Received experimental/serverStatus with quiescent=true") if not self.found_source_files: self.analysis_complete.set() # Set up notification handlers self.server.on_request("client/registerCapability", do_nothing) self.server.on_notification("language/status", do_nothing) self.server.on_notification("window/logMessage", window_log_message) self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) self.server.on_notification("language/actionableNotification", do_nothing) self.server.on_notification("experimental/serverStatus", check_experimental_status) log.info("Starting pyright-langserver server process") self.server.start() # Send proper initialization parameters initialize_params = self._get_initialize_params(self.repository_root_path) log.info("Sending initialize request from LSP client to pyright server and awaiting response") init_response = self.server.send.initialize(initialize_params) log.info(f"Received initialize response from pyright server: {init_response}") # Verify that the server supports our required features assert "textDocumentSync" in init_response["capabilities"] assert "completionProvider" in init_response["capabilities"] assert "definitionProvider" in init_response["capabilities"] # Complete the initialization handshake self.server.notify.initialized({}) # Wait for Pyright to complete its initial workspace analysis # This prevents zombie processes by ensuring background tasks finish log.info("Waiting for Pyright to complete initial workspace analysis...") if self.analysis_complete.wait(timeout=5.0): log.info("Pyright initial analysis complete, server ready") else: log.warning("Timeout waiting for Pyright analysis completion, proceeding anyway") # Fallback: assume analysis is complete after timeout self.analysis_complete.set() ================================================ FILE: src/solidlsp/language_servers/r_language_server.py ================================================ import logging import os import pathlib import subprocess from typing import Any from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) class RLanguageServer(SolidLanguageServer): """R Language Server implementation using the languageserver R package.""" @override def _get_wait_time_for_cross_file_referencing(self) -> float: return 5.0 # R language server needs extra time for workspace indexing in CI environments @override def is_ignored_dirname(self, dirname: str) -> bool: # For R projects, ignore common directories return super().is_ignored_dirname(dirname) or dirname in [ "renv", # R environment management "packrat", # Legacy R package management ".Rproj.user", # RStudio project files "vignettes", # Package vignettes (often large) ] @staticmethod def _check_r_installation() -> None: """Check if R and languageserver are available.""" try: # Check R installation result = subprocess.run(["R", "--version"], capture_output=True, text=True, check=False) if result.returncode != 0: raise RuntimeError("R is not installed or not in PATH") # Check languageserver package result = subprocess.run( ["R", "--vanilla", "--quiet", "--slave", "-e", "if (!require('languageserver', quietly=TRUE)) quit(status=1)"], capture_output=True, text=True, check=False, ) if result.returncode != 0: raise RuntimeError( "R languageserver package is not installed.\nInstall it with: R -e \"install.packages('languageserver')\"" ) except FileNotFoundError: raise RuntimeError("R is not installed. Please install R from https://www.r-project.org/") def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): # Check R installation self._check_r_installation() # R command to start language server # Use --vanilla for minimal startup and --quiet to suppress all output except LSP # Set specific options to improve parsing stability r_cmd = 'R --vanilla --quiet --slave -e "options(languageserver.debug_mode = FALSE); languageserver::run()"' super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd=r_cmd, cwd=repository_root_path), "r", solidlsp_settings) @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """Initialize params for R Language Server.""" root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, "completion": { "dynamicRegistration": True, "completionItem": { "snippetSupport": True, "commitCharactersSupport": True, "documentationFormat": ["markdown", "plaintext"], "deprecatedSupport": True, "preselectSupport": True, }, }, "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, "definition": {"dynamicRegistration": True}, "references": {"dynamicRegistration": True}, "documentSymbol": { "dynamicRegistration": True, "hierarchicalDocumentSymbolSupport": True, "symbolKind": {"valueSet": list(range(1, 27))}, }, "formatting": {"dynamicRegistration": True}, "rangeFormatting": {"dynamicRegistration": True}, }, "workspace": { "workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}, "symbol": { "dynamicRegistration": True, "symbolKind": {"valueSet": list(range(1, 27))}, }, }, }, "processId": os.getpid(), "rootPath": repository_absolute_path, "rootUri": root_uri, "workspaceFolders": [ { "uri": root_uri, "name": os.path.basename(repository_absolute_path), } ], } return initialize_params # type: ignore def _start_server(self) -> None: """Start R Language Server process.""" def window_log_message(msg: dict) -> None: log.info(f"R LSP: window/logMessage: {msg}") def do_nothing(params: Any) -> None: return def register_capability_handler(params: Any) -> None: return # Register LSP message handlers self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_notification("window/logMessage", window_log_message) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) log.info("Starting R Language Server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.info( "Sending initialize request to R Language Server", ) init_response = self.server.send.initialize(initialize_params) # Verify server capabilities capabilities = init_response.get("capabilities", {}) assert "textDocumentSync" in capabilities if "completionProvider" in capabilities: log.info("R LSP completion provider available") if "definitionProvider" in capabilities: log.info("R LSP definition provider available") self.server.notify.initialized({}) # R Language Server is ready after initialization ================================================ FILE: src/solidlsp/language_servers/regal_server.py ================================================ """Regal Language Server implementation for Rego policy files.""" import logging import os import shutil from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_utils import PathUtils from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) class RegalLanguageServer(SolidLanguageServer): """ Provides Rego specific instantiation of the LanguageServer class using Regal. Regal is the official linter and language server for Rego (Open Policy Agent's policy language). See: https://github.com/StyraInc/regal """ @override def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in [".regal", ".opa"] def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a RegalLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. :param config: Language server configuration :param repository_root_path: Path to the repository root :param solidlsp_settings: Settings for solidlsp """ # Regal should be installed system-wide (via CI or user installation) regal_executable_path = shutil.which("regal") if not regal_executable_path: raise RuntimeError( "Regal language server not found. Please install it from https://github.com/StyraInc/regal or via your package manager." ) super().__init__( config, repository_root_path, ProcessLaunchInfo(cmd=f"{regal_executable_path} language-server", cwd=repository_root_path), "rego", solidlsp_settings, ) @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the Regal Language Server. :param repository_absolute_path: Absolute path to the repository :return: LSP initialization parameters """ root_uri = PathUtils.path_to_uri(repository_absolute_path) return { "processId": os.getpid(), "locale": "en", "rootPath": repository_absolute_path, "rootUri": root_uri, "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, "definition": {"dynamicRegistration": True}, "references": {"dynamicRegistration": True}, "documentSymbol": { "dynamicRegistration": True, "hierarchicalDocumentSymbolSupport": True, "symbolKind": {"valueSet": list(range(1, 27))}, # type: ignore[arg-type] }, "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, # type: ignore[list-item] "codeAction": {"dynamicRegistration": True}, "formatting": {"dynamicRegistration": True}, }, "workspace": { "workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}, "symbol": {"dynamicRegistration": True}, }, }, "workspaceFolders": [ { "name": os.path.basename(repository_absolute_path), "uri": root_uri, } ], } def _start_server(self) -> None: """Start Regal language server process and wait for initialization.""" def register_capability_handler(params) -> None: # type: ignore[no-untyped-def] return def window_log_message(msg) -> None: # type: ignore[no-untyped-def] log.info(f"LSP: window/logMessage: {msg}") def do_nothing(params) -> None: # type: ignore[no-untyped-def] return self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_notification("window/logMessage", window_log_message) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) log.info("Starting Regal language server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.info( "Sending initialize request from LSP client to LSP server and awaiting response", ) init_response = self.server.send.initialize(initialize_params) # Verify server capabilities assert "capabilities" in init_response assert "textDocumentSync" in init_response["capabilities"] self.server.notify.initialized({}) # Regal server is ready immediately after initialization ================================================ FILE: src/solidlsp/language_servers/ruby_lsp.py ================================================ """ Ruby LSP Language Server implementation using Shopify's ruby-lsp. Provides modern Ruby language server capabilities with improved performance. """ import json import logging import os import pathlib import shutil import subprocess import threading from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams, InitializeResult from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) class RubyLsp(SolidLanguageServer): """ Provides Ruby specific instantiation of the LanguageServer class using ruby-lsp. Contains various configurations and settings specific to Ruby with modern LSP features. """ def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a RubyLsp instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ ruby_lsp_executable = self._setup_runtime_dependencies(config, repository_root_path) super().__init__( config, repository_root_path, ProcessLaunchInfo(cmd=ruby_lsp_executable, cwd=repository_root_path), "ruby", solidlsp_settings ) self.analysis_complete = threading.Event() self.service_ready_event = threading.Event() # Set timeout for ruby-lsp requests - ruby-lsp is fast self.set_request_timeout(30.0) # 30 seconds for initialization and requests @override def is_ignored_dirname(self, dirname: str) -> bool: """Override to ignore Ruby-specific directories that cause performance issues.""" ruby_ignored_dirs = [ "vendor", # Ruby vendor directory ".bundle", # Bundler cache "tmp", # Temporary files "log", # Log files "coverage", # Test coverage reports ".yardoc", # YARD documentation cache "doc", # Generated documentation "node_modules", # Node modules (for Rails with JS) "storage", # Active Storage files (Rails) "public/packs", # Webpacker output "public/webpack", # Webpack output "public/assets", # Rails compiled assets ] return super().is_ignored_dirname(dirname) or dirname in ruby_ignored_dirs @override def _get_wait_time_for_cross_file_referencing(self) -> float: """Override to provide optimal wait time for ruby-lsp cross-file reference resolution. ruby-lsp typically initializes quickly, but may need a brief moment for cross-file analysis in larger projects. """ return 0.5 # 500ms should be sufficient for ruby-lsp @staticmethod def _find_executable_with_extensions(executable_name: str) -> str | None: """ Find executable with Windows-specific extensions (.bat, .cmd, .exe) if on Windows. Returns the full path to the executable or None if not found. """ import platform if platform.system() == "Windows": # Try Windows-specific extensions first for ext in [".bat", ".cmd", ".exe"]: path = shutil.which(f"{executable_name}{ext}") if path: return path # Fall back to default search return shutil.which(executable_name) else: # Unix systems return shutil.which(executable_name) @staticmethod def _setup_runtime_dependencies(config: LanguageServerConfig, repository_root_path: str) -> list[str]: """ Setup runtime dependencies for ruby-lsp and return the command list to start the server. Installation strategy: Bundler project > global ruby-lsp > gem install ruby-lsp """ # Detect rbenv-managed Ruby environment # When .ruby-version exists, it indicates the project uses rbenv for version management. # rbenv automatically reads .ruby-version to determine which Ruby version to use. # Using "rbenv exec" ensures commands run with the correct Ruby version and its gems. # # Why rbenv is preferred over system Ruby: # - Respects project-specific Ruby versions # - Avoids bundler version mismatches between system and project # - Ensures consistent environment across developers # # Fallback behavior: # If .ruby-version doesn't exist or rbenv isn't installed, we fall back to system Ruby. # This may cause issues if: # - System Ruby version differs from what the project expects # - System bundler version is incompatible with Gemfile.lock # - Project gems aren't installed in system Ruby ruby_version_file = os.path.join(repository_root_path, ".ruby-version") use_rbenv = os.path.exists(ruby_version_file) and shutil.which("rbenv") is not None if use_rbenv: ruby_cmd = ["rbenv", "exec", "ruby"] bundle_cmd = ["rbenv", "exec", "bundle"] log.info(f"Using rbenv-managed Ruby (found {ruby_version_file})") else: ruby_cmd = ["ruby"] bundle_cmd = ["bundle"] if os.path.exists(ruby_version_file): log.warning( f"Found {ruby_version_file} but rbenv is not installed. " "Using system Ruby. Consider installing rbenv for better version management: https://github.com/rbenv/rbenv", ) else: log.info("No .ruby-version file found, using system Ruby") # Check if Ruby is installed try: result = subprocess.run(ruby_cmd + ["--version"], check=True, capture_output=True, cwd=repository_root_path, text=True) ruby_version = result.stdout.strip() log.info(f"Ruby version: {ruby_version}") # Extract version number for compatibility checks import re version_match = re.search(r"ruby (\d+)\.(\d+)\.(\d+)", ruby_version) if version_match: major, minor, patch = map(int, version_match.groups()) if major < 2 or (major == 2 and minor < 6): log.warning(f"Warning: Ruby {major}.{minor}.{patch} detected. ruby-lsp works best with Ruby 2.6+") except subprocess.CalledProcessError as e: error_msg = e.stderr if isinstance(e.stderr, str) else e.stderr.decode() if e.stderr else "Unknown error" raise RuntimeError( f"Error checking Ruby installation: {error_msg}. Please ensure Ruby is properly installed and in PATH." ) from e except FileNotFoundError as e: raise RuntimeError( "Ruby is not installed or not found in PATH. Please install Ruby using one of these methods:\n" " - Using rbenv: rbenv install 3.0.0 && rbenv global 3.0.0\n" " - Using RVM: rvm install 3.0.0 && rvm use 3.0.0 --default\n" " - Using asdf: asdf install ruby 3.0.0 && asdf global ruby 3.0.0\n" " - System package manager (brew install ruby, apt install ruby, etc.)" ) from e # Check for Bundler project (Gemfile exists) gemfile_path = os.path.join(repository_root_path, "Gemfile") gemfile_lock_path = os.path.join(repository_root_path, "Gemfile.lock") is_bundler_project = os.path.exists(gemfile_path) if is_bundler_project: log.info("Detected Bundler project (Gemfile found)") # Check if bundle command is available using Windows-compatible search bundle_path = RubyLsp._find_executable_with_extensions(bundle_cmd[0] if len(bundle_cmd) == 1 else "bundle") if not bundle_path: # Try common bundle executables for bundle_executable in ["bin/bundle", "bundle"]: bundle_full_path: str | None if bundle_executable.startswith("bin/"): bundle_full_path = os.path.join(repository_root_path, bundle_executable) else: bundle_full_path = RubyLsp._find_executable_with_extensions(bundle_executable) if bundle_full_path and os.path.exists(bundle_full_path): bundle_path = bundle_full_path if bundle_executable.startswith("bin/") else bundle_executable break if not bundle_path: log.warning( "Bundler project detected but 'bundle' command not found. Falling back to global ruby-lsp installation.", ) else: # Check if ruby-lsp is in Gemfile.lock ruby_lsp_in_bundle = False if os.path.exists(gemfile_lock_path): try: with open(gemfile_lock_path) as f: content = f.read() ruby_lsp_in_bundle = "ruby-lsp" in content.lower() except Exception as e: log.warning(f"Warning: Could not read Gemfile.lock: {e}") if ruby_lsp_in_bundle: log.info("Found ruby-lsp in Gemfile.lock") return bundle_cmd + ["exec", "ruby-lsp"] else: log.info( "ruby-lsp not found in Gemfile.lock. Consider adding 'gem \"ruby-lsp\"' to your Gemfile for better compatibility.", ) # Fall through to global installation check # Check if ruby-lsp is available globally using Windows-compatible search ruby_lsp_path = RubyLsp._find_executable_with_extensions("ruby-lsp") if ruby_lsp_path: log.info(f"Found ruby-lsp at: {ruby_lsp_path}") return [ruby_lsp_path] # Try to install ruby-lsp globally log.info("ruby-lsp not found, attempting to install globally...") try: subprocess.run(["gem", "install", "ruby-lsp"], check=True, capture_output=True, cwd=repository_root_path) log.info("Successfully installed ruby-lsp globally") # Find the newly installed ruby-lsp executable ruby_lsp_path = RubyLsp._find_executable_with_extensions("ruby-lsp") return [ruby_lsp_path] if ruby_lsp_path else ["ruby-lsp"] except subprocess.CalledProcessError as e: error_msg = e.stderr if isinstance(e.stderr, str) else e.stderr.decode() if e.stderr else str(e) if is_bundler_project: raise RuntimeError( f"Failed to install ruby-lsp globally: {error_msg}\n" "For Bundler projects, please add 'gem \"ruby-lsp\"' to your Gemfile and run 'bundle install'.\n" "Alternatively, install globally: gem install ruby-lsp" ) from e raise RuntimeError(f"Failed to install ruby-lsp: {error_msg}\nPlease try installing manually: gem install ruby-lsp") from e @staticmethod def _detect_rails_project(repository_root_path: str) -> bool: """ Detect if this is a Rails project by checking for Rails-specific files. """ rails_indicators = [ "config/application.rb", "config/environment.rb", "app/controllers/application_controller.rb", "Rakefile", ] for indicator in rails_indicators: if os.path.exists(os.path.join(repository_root_path, indicator)): return True # Check for Rails in Gemfile gemfile_path = os.path.join(repository_root_path, "Gemfile") if os.path.exists(gemfile_path): try: with open(gemfile_path) as f: content = f.read().lower() if "gem 'rails'" in content or 'gem "rails"' in content: return True except Exception: pass return False @staticmethod def _get_ruby_exclude_patterns(repository_root_path: str) -> list[str]: """ Get Ruby and Rails-specific exclude patterns for better performance. """ base_patterns = [ "**/vendor/**", # Ruby vendor directory "**/.bundle/**", # Bundler cache "**/tmp/**", # Temporary files "**/log/**", # Log files "**/coverage/**", # Test coverage reports "**/.yardoc/**", # YARD documentation cache "**/doc/**", # Generated documentation "**/.git/**", # Git directory "**/node_modules/**", # Node modules (for Rails with JS) "**/public/assets/**", # Rails compiled assets ] # Add Rails-specific patterns if this is a Rails project if RubyLsp._detect_rails_project(repository_root_path): base_patterns.extend( [ "**/app/assets/builds/**", # Rails 7+ CSS builds "**/storage/**", # Active Storage "**/public/packs/**", # Webpacker "**/public/webpack/**", # Webpack ] ) return base_patterns def _get_initialize_params(self) -> InitializeParams: """ Returns ruby-lsp specific initialization parameters. """ exclude_patterns = self._get_ruby_exclude_patterns(self.repository_root_path) initialize_params = { "processId": os.getpid(), "rootPath": self.repository_root_path, "rootUri": pathlib.Path(self.repository_root_path).as_uri(), "capabilities": { "workspace": { "workspaceEdit": {"documentChanges": True}, "configuration": True, }, "window": { "workDoneProgress": True, }, "textDocument": { "documentSymbol": { "hierarchicalDocumentSymbolSupport": True, "symbolKind": {"valueSet": list(range(1, 27))}, }, "completion": { "completionItem": { "snippetSupport": True, "commitCharactersSupport": True, } }, }, }, "initializationOptions": { # ruby-lsp enables all features by default, so we don't need to specify enabledFeatures "experimentalFeaturesEnabled": False, "featuresConfiguration": {}, "indexing": { "includedPatterns": ["**/*.rb", "**/*.rake", "**/*.ru", "**/*.erb"], "excludedPatterns": exclude_patterns, }, }, } return initialize_params # type: ignore def _start_server(self) -> None: """ Starts the ruby-lsp Language Server for Ruby """ def register_capability_handler(params: dict) -> None: assert "registrations" in params for registration in params["registrations"]: log.info(f"Registered capability: {registration['method']}") return def lang_status_handler(params: dict) -> None: log.info(f"LSP: language/status: {params}") if params.get("type") == "ready": log.info("ruby-lsp service is ready.") self.analysis_complete.set() def execute_client_command_handler(params: dict) -> list: return [] def do_nothing(params: dict) -> None: return def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") def progress_handler(params: dict) -> None: # ruby-lsp sends progress notifications during indexing log.debug(f"LSP: $/progress: {params}") if "value" in params: value = params["value"] # Check for completion indicators if value.get("kind") == "end": log.info("ruby-lsp indexing complete ($/progress end)") self.analysis_complete.set() elif value.get("kind") == "begin": log.info("ruby-lsp indexing started ($/progress begin)") elif "percentage" in value: percentage = value.get("percentage", 0) log.debug(f"ruby-lsp indexing progress: {percentage}%") # Handle direct progress format (fallback) elif "token" in params and "value" in params: token = params.get("token") if isinstance(token, str) and "indexing" in token.lower(): value = params.get("value", {}) if value.get("kind") == "end" or value.get("percentage") == 100: log.info("ruby-lsp indexing complete (token progress)") self.analysis_complete.set() def window_work_done_progress_create(params: dict) -> dict: """Handle workDoneProgress/create requests from ruby-lsp""" log.debug(f"LSP: window/workDoneProgress/create: {params}") return {} self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_notification("language/status", lang_status_handler) self.server.on_notification("window/logMessage", window_log_message) self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) self.server.on_notification("$/progress", progress_handler) self.server.on_request("window/workDoneProgress/create", window_work_done_progress_create) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) log.info("Starting ruby-lsp server process") self.server.start() initialize_params = self._get_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") log.info(f"Sending init params: {json.dumps(initialize_params, indent=4)}") init_response = self.server.send.initialize(initialize_params) log.info(f"Received init response: {init_response}") # Verify expected capabilities # Note: ruby-lsp may return textDocumentSync in different formats (number or object) text_document_sync = init_response["capabilities"].get("textDocumentSync") if isinstance(text_document_sync, int): assert text_document_sync in [1, 2], f"Unexpected textDocumentSync value: {text_document_sync}" elif isinstance(text_document_sync, dict): # ruby-lsp returns an object with change property assert "change" in text_document_sync, "textDocumentSync object should have 'change' property" assert "completionProvider" in init_response["capabilities"] self.server.notify.initialized({}) # Wait for ruby-lsp to complete its initial indexing # ruby-lsp has fast indexing log.info("Waiting for ruby-lsp to complete initial indexing...") if self.analysis_complete.wait(timeout=30.0): log.info("ruby-lsp initial indexing complete, server ready") else: log.warning("Timeout waiting for ruby-lsp indexing completion, proceeding anyway") # Fallback: assume indexing is complete after timeout self.analysis_complete.set() def _handle_initialization_response(self, init_response: InitializeResult) -> None: """ Handle the initialization response from ruby-lsp and validate capabilities. """ if "capabilities" in init_response: capabilities = init_response["capabilities"] # Validate textDocumentSync (ruby-lsp may return different formats) text_document_sync = capabilities.get("textDocumentSync") if isinstance(text_document_sync, int): assert text_document_sync in [1, 2], f"Unexpected textDocumentSync value: {text_document_sync}" elif isinstance(text_document_sync, dict): # ruby-lsp returns an object with change property assert "change" in text_document_sync, "textDocumentSync object should have 'change' property" # Log important capabilities important_capabilities = [ "completionProvider", "hoverProvider", "definitionProvider", "referencesProvider", "documentSymbolProvider", "codeActionProvider", "documentFormattingProvider", "semanticTokensProvider", ] for cap in important_capabilities: if cap in capabilities: log.debug(f"ruby-lsp {cap}: available") # Signal that the service is ready self.service_ready_event.set() ================================================ FILE: src/solidlsp/language_servers/rust_analyzer.py ================================================ """ Provides Rust specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Rust. """ import logging import os import pathlib import platform import shutil import subprocess import threading from typing import cast from overrides import override from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) class RustAnalyzer(SolidLanguageServer): """ Provides Rust specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Rust. """ @staticmethod def _determine_log_level(line: str) -> int: """Classify rust-analyzer stderr output to avoid false-positive errors.""" line_lower = line.lower() # Known informational/warning messages from rust-analyzer that aren't critical errors if any( [ "failed to find any projects in" in line_lower, "fetchworkspaceerror" in line_lower, ] ): return logging.DEBUG return SolidLanguageServer._determine_log_level(line) class DependencyProvider(LanguageServerDependencyProviderSinglePath): @staticmethod def _get_rustup_version() -> str | None: """Get installed rustup version or None if not found.""" try: result = subprocess.run(["rustup", "--version"], capture_output=True, text=True, check=False) if result.returncode == 0: return result.stdout.strip() except FileNotFoundError: return None return None @staticmethod def _get_rust_analyzer_via_rustup() -> str | None: """Get rust-analyzer path via rustup. Returns None if not found.""" try: result = subprocess.run(["rustup", "which", "rust-analyzer"], capture_output=True, text=True, check=False) if result.returncode == 0: return result.stdout.strip() except FileNotFoundError: pass return None @staticmethod def _ensure_rust_analyzer_installed() -> str: """ Ensure rust-analyzer is available. Priority order: 1. Rustup existing installation (preferred - matches toolchain version) 2. Rustup auto-install if rustup is available (ensures correct version) 3. Common installation locations as fallback (only if rustup not available) 4. System PATH last (can pick up incompatible versions) :return: path to rust-analyzer executable """ # Try rustup FIRST (preferred - avoids picking up incompatible versions from PATH) rustup_path = RustAnalyzer.DependencyProvider._get_rust_analyzer_via_rustup() if rustup_path: return rustup_path # If rustup is available but rust-analyzer not installed, auto-install it BEFORE # checking common paths. This ensures we get the correct version matching the toolchain. if RustAnalyzer.DependencyProvider._get_rustup_version(): result = subprocess.run(["rustup", "component", "add", "rust-analyzer"], check=False, capture_output=True, text=True) if result.returncode == 0: # Verify installation worked rustup_path = RustAnalyzer.DependencyProvider._get_rust_analyzer_via_rustup() if rustup_path: return rustup_path # If auto-install failed, fall through to common paths as last resort # Determine platform-specific binary name and paths is_windows = platform.system() == "Windows" binary_name = "rust-analyzer.exe" if is_windows else "rust-analyzer" # Fallback to common installation locations (only used if rustup not available) common_paths: list[str | None] = [] if is_windows: # Windows-specific paths home = pathlib.Path.home() common_paths.extend( [ str(home / ".cargo" / "bin" / binary_name), # cargo install / rustup str(home / "scoop" / "shims" / binary_name), # Scoop package manager str(home / "scoop" / "apps" / "rust-analyzer" / "current" / binary_name), # Scoop direct str( pathlib.Path(os.environ.get("LOCALAPPDATA", "")) / "Programs" / "rust-analyzer" / binary_name ), # Standalone install ] ) else: # Unix-like paths (macOS, Linux) common_paths.extend( [ "/opt/homebrew/bin/rust-analyzer", # macOS Homebrew (Apple Silicon) "/usr/local/bin/rust-analyzer", # macOS Homebrew (Intel) / Linux system os.path.expanduser("~/.cargo/bin/rust-analyzer"), # cargo install os.path.expanduser("~/.local/bin/rust-analyzer"), # User local bin ] ) for path in common_paths: if path and os.path.isfile(path) and os.access(path, os.X_OK): return path # Last resort: check system PATH (can pick up incorrect aliases, hence checked last) path_result = shutil.which("rust-analyzer") if path_result and os.path.isfile(path_result) and os.access(path_result, os.X_OK): return path_result # Provide helpful error message with all searched locations searched = [p for p in common_paths if p] install_instructions = [ " - Rustup: rustup component add rust-analyzer", " - Cargo: cargo install rust-analyzer", ] if is_windows: install_instructions.extend( [ " - Scoop: scoop install rust-analyzer", " - Chocolatey: choco install rust-analyzer", " - Standalone: Download from https://github.com/rust-lang/rust-analyzer/releases", ] ) else: install_instructions.extend( [ " - Homebrew (macOS): brew install rust-analyzer", " - System package manager (Linux): apt/dnf/pacman install rust-analyzer", ] ) raise RuntimeError( "rust-analyzer is not installed or not in PATH.\n" "Searched locations:\n" + "\n".join(f" - {p}" for p in searched) + "\n" "Please install rust-analyzer via:\n" + "\n".join(install_instructions) ) def _get_or_install_core_dependency(self) -> str: return self._ensure_rust_analyzer_installed() def _create_launch_command(self, core_path: str) -> list[str]: return [core_path] def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a RustAnalyzer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ super().__init__( config, repository_root_path, None, "rust", solidlsp_settings, ) self.server_ready = threading.Event() self.service_ready_event = threading.Event() self.initialize_searcher_command_available = threading.Event() self.resolve_main_method_available = threading.Event() def _create_dependency_provider(self) -> LanguageServerDependencyProvider: return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) @override def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in ["target"] @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the Rust Analyzer Language Server. """ root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "clientInfo": {"name": "Visual Studio Code - Insiders", "version": "1.82.0-insider"}, "locale": "en", "capabilities": { "workspace": { "applyEdit": True, "workspaceEdit": { "documentChanges": True, "resourceOperations": ["create", "rename", "delete"], "failureHandling": "textOnlyTransactional", "normalizesLineEndings": True, "changeAnnotationSupport": {"groupsOnLabel": True}, }, "configuration": True, "didChangeWatchedFiles": {"dynamicRegistration": True, "relativePatternSupport": True}, "symbol": { "dynamicRegistration": True, "symbolKind": {"valueSet": list(range(1, 27))}, "tagSupport": {"valueSet": [1]}, "resolveSupport": {"properties": ["location.range"]}, }, "codeLens": {"refreshSupport": True}, "executeCommand": {"dynamicRegistration": True}, "didChangeConfiguration": {"dynamicRegistration": True}, "workspaceFolders": True, "semanticTokens": {"refreshSupport": True}, "fileOperations": { "dynamicRegistration": True, "didCreate": True, "didRename": True, "didDelete": True, "willCreate": True, "willRename": True, "willDelete": True, }, "inlineValue": {"refreshSupport": True}, "inlayHint": {"refreshSupport": True}, "diagnostics": {"refreshSupport": True}, }, "textDocument": { "publishDiagnostics": { "relatedInformation": True, "versionSupport": False, "tagSupport": {"valueSet": [1, 2]}, "codeDescriptionSupport": True, "dataSupport": True, }, "synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True}, "completion": { "dynamicRegistration": True, "contextSupport": True, "completionItem": { "snippetSupport": True, "commitCharactersSupport": True, "documentationFormat": ["markdown", "plaintext"], "deprecatedSupport": True, "preselectSupport": True, "tagSupport": {"valueSet": [1]}, "insertReplaceSupport": True, "resolveSupport": {"properties": ["documentation", "detail", "additionalTextEdits"]}, "insertTextModeSupport": {"valueSet": [1, 2]}, "labelDetailsSupport": True, }, "insertTextMode": 2, "completionItemKind": { "valueSet": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25] }, "completionList": {"itemDefaults": ["commitCharacters", "editRange", "insertTextFormat", "insertTextMode"]}, }, "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, "signatureHelp": { "dynamicRegistration": True, "signatureInformation": { "documentationFormat": ["markdown", "plaintext"], "parameterInformation": {"labelOffsetSupport": True}, "activeParameterSupport": True, }, "contextSupport": True, }, "definition": {"dynamicRegistration": True, "linkSupport": True}, "references": {"dynamicRegistration": True}, "documentHighlight": {"dynamicRegistration": True}, "documentSymbol": { "dynamicRegistration": True, "symbolKind": {"valueSet": list(range(1, 27))}, "hierarchicalDocumentSymbolSupport": True, "tagSupport": {"valueSet": [1]}, "labelSupport": True, }, "codeAction": { "dynamicRegistration": True, "isPreferredSupport": True, "disabledSupport": True, "dataSupport": True, "resolveSupport": {"properties": ["edit"]}, "codeActionLiteralSupport": { "codeActionKind": { "valueSet": [ "", "quickfix", "refactor", "refactor.extract", "refactor.inline", "refactor.rewrite", "source", "source.organizeImports", ] } }, "honorsChangeAnnotations": False, }, "codeLens": {"dynamicRegistration": True}, "formatting": {"dynamicRegistration": True}, "rangeFormatting": {"dynamicRegistration": True}, "onTypeFormatting": {"dynamicRegistration": True}, "rename": { "dynamicRegistration": True, "prepareSupport": True, "prepareSupportDefaultBehavior": 1, "honorsChangeAnnotations": True, }, "documentLink": {"dynamicRegistration": True, "tooltipSupport": True}, "typeDefinition": {"dynamicRegistration": True, "linkSupport": True}, "implementation": {"dynamicRegistration": True, "linkSupport": True}, "colorProvider": {"dynamicRegistration": True}, "foldingRange": { "dynamicRegistration": True, "rangeLimit": 5000, "lineFoldingOnly": True, "foldingRangeKind": {"valueSet": ["comment", "imports", "region"]}, "foldingRange": {"collapsedText": False}, }, "declaration": {"dynamicRegistration": True, "linkSupport": True}, "selectionRange": {"dynamicRegistration": True}, "callHierarchy": {"dynamicRegistration": True}, "semanticTokens": { "dynamicRegistration": True, "tokenTypes": [ "namespace", "type", "class", "enum", "interface", "struct", "typeParameter", "parameter", "variable", "property", "enumMember", "event", "function", "method", "macro", "keyword", "modifier", "comment", "string", "number", "regexp", "operator", "decorator", ], "tokenModifiers": [ "declaration", "definition", "readonly", "static", "deprecated", "abstract", "async", "modification", "documentation", "defaultLibrary", ], "formats": ["relative"], "requests": {"range": True, "full": {"delta": True}}, "multilineTokenSupport": False, "overlappingTokenSupport": False, "serverCancelSupport": True, "augmentsSyntaxTokens": False, }, "linkedEditingRange": {"dynamicRegistration": True}, "typeHierarchy": {"dynamicRegistration": True}, "inlineValue": {"dynamicRegistration": True}, "inlayHint": { "dynamicRegistration": True, "resolveSupport": {"properties": ["tooltip", "textEdits", "label.tooltip", "label.location", "label.command"]}, }, "diagnostic": {"dynamicRegistration": True, "relatedDocumentSupport": False}, }, "window": { "showMessage": {"messageActionItem": {"additionalPropertiesSupport": True}}, "showDocument": {"support": True}, "workDoneProgress": True, }, "general": { "staleRequestSupport": { "cancel": True, "retryOnContentModified": [ "textDocument/semanticTokens/full", "textDocument/semanticTokens/range", "textDocument/semanticTokens/full/delta", ], }, "regularExpressions": {"engine": "ECMAScript", "version": "ES2020"}, "markdown": { "parser": "marked", "version": "1.1.0", "allowedTags": [ "ul", "li", "p", "code", "blockquote", "ol", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "em", "pre", "table", "thead", "tbody", "tr", "th", "td", "div", "del", "a", "strong", "br", "img", "span", ], }, "positionEncodings": ["utf-16"], }, "notebookDocument": {"synchronization": {"dynamicRegistration": True, "executionSummarySupport": True}}, "experimental": { "snippetTextEdit": True, "codeActionGroup": True, "hoverActions": True, "serverStatusNotification": True, "colorDiagnosticOutput": True, "openServerLogs": True, "localDocs": True, "commands": { "commands": [ "rust-analyzer.runSingle", "rust-analyzer.debugSingle", "rust-analyzer.showReferences", "rust-analyzer.gotoLocation", "editor.action.triggerParameterHints", ] }, }, }, "initializationOptions": { "cargoRunner": None, "runnables": {"extraEnv": None, "problemMatcher": ["$rustc"], "command": None, "extraArgs": []}, "statusBar": {"clickAction": "openLogs"}, "server": {"path": None, "extraEnv": None}, "trace": {"server": "verbose", "extension": False}, "debug": { "engine": "auto", "sourceFileMap": {"/rustc/": "${env:USERPROFILE}/.rustup/toolchains//lib/rustlib/src/rust"}, "openDebugPane": False, "engineSettings": {}, }, "restartServerOnConfigChange": False, "typing": {"continueCommentsOnNewline": True, "autoClosingAngleBrackets": {"enable": False}}, "diagnostics": { "previewRustcOutput": False, "useRustcErrorCode": False, "disabled": [], "enable": True, "experimental": {"enable": False}, "remapPrefix": {}, "warningsAsHint": [], "warningsAsInfo": [], }, "discoverProjectRunner": None, "showUnlinkedFileNotification": True, "showDependenciesExplorer": True, "assist": {"emitMustUse": False, "expressionFillDefault": "todo"}, "cachePriming": {"enable": True, "numThreads": 0}, "cargo": { "autoreload": True, "buildScripts": { "enable": True, "invocationLocation": "workspace", "invocationStrategy": "per_workspace", "overrideCommand": None, "useRustcWrapper": True, }, "cfgs": [], "extraArgs": [], "extraEnv": {}, "features": [], "noDefaultFeatures": False, "sysroot": "discover", "sysrootSrc": None, "target": None, "unsetTest": ["core"], }, "checkOnSave": True, "check": { "allTargets": True, "command": "check", "extraArgs": [], "extraEnv": {}, "features": None, "ignore": [], "invocationLocation": "workspace", "invocationStrategy": "per_workspace", "noDefaultFeatures": None, "overrideCommand": None, "targets": None, }, "completion": { "autoimport": {"enable": True}, "autoself": {"enable": True}, "callable": {"snippets": "fill_arguments"}, "fullFunctionSignatures": {"enable": False}, "limit": None, "postfix": {"enable": True}, "privateEditable": {"enable": False}, "snippets": { "custom": { "Arc::new": { "postfix": "arc", "body": "Arc::new(${receiver})", "requires": "std::sync::Arc", "description": "Put the expression into an `Arc`", "scope": "expr", }, "Rc::new": { "postfix": "rc", "body": "Rc::new(${receiver})", "requires": "std::rc::Rc", "description": "Put the expression into an `Rc`", "scope": "expr", }, "Box::pin": { "postfix": "pinbox", "body": "Box::pin(${receiver})", "requires": "std::boxed::Box", "description": "Put the expression into a pinned `Box`", "scope": "expr", }, "Ok": { "postfix": "ok", "body": "Ok(${receiver})", "description": "Wrap the expression in a `Result::Ok`", "scope": "expr", }, "Err": { "postfix": "err", "body": "Err(${receiver})", "description": "Wrap the expression in a `Result::Err`", "scope": "expr", }, "Some": { "postfix": "some", "body": "Some(${receiver})", "description": "Wrap the expression in an `Option::Some`", "scope": "expr", }, } }, }, "files": {"excludeDirs": [], "watcher": "client"}, "highlightRelated": { "breakPoints": {"enable": True}, "closureCaptures": {"enable": True}, "exitPoints": {"enable": True}, "references": {"enable": True}, "yieldPoints": {"enable": True}, }, "hover": { "actions": { "debug": {"enable": True}, "enable": True, "gotoTypeDef": {"enable": True}, "implementations": {"enable": True}, "references": {"enable": False}, "run": {"enable": True}, }, "documentation": {"enable": True, "keywords": {"enable": True}}, "links": {"enable": True}, "memoryLayout": {"alignment": "hexadecimal", "enable": True, "niches": False, "offset": "hexadecimal", "size": "both"}, }, "imports": { "granularity": {"enforce": False, "group": "crate"}, "group": {"enable": True}, "merge": {"glob": True}, "preferNoStd": False, "preferPrelude": False, "prefix": "plain", }, "inlayHints": { "bindingModeHints": {"enable": False}, "chainingHints": {"enable": True}, "closingBraceHints": {"enable": True, "minLines": 25}, "closureCaptureHints": {"enable": False}, "closureReturnTypeHints": {"enable": "never"}, "closureStyle": "impl_fn", "discriminantHints": {"enable": "never"}, "expressionAdjustmentHints": {"enable": "never", "hideOutsideUnsafe": False, "mode": "prefix"}, "lifetimeElisionHints": {"enable": "never", "useParameterNames": False}, "maxLength": 25, "parameterHints": {"enable": True}, "reborrowHints": {"enable": "never"}, "renderColons": True, "typeHints": {"enable": True, "hideClosureInitialization": False, "hideNamedConstructor": False}, }, "interpret": {"tests": False}, "joinLines": {"joinAssignments": True, "joinElseIf": True, "removeTrailingComma": True, "unwrapTrivialBlock": True}, "lens": { "debug": {"enable": True}, "enable": True, "forceCustomCommands": True, "implementations": {"enable": True}, "location": "above_name", "references": { "adt": {"enable": False}, "enumVariant": {"enable": False}, "method": {"enable": False}, "trait": {"enable": False}, }, "run": {"enable": True}, }, "linkedProjects": [], "lru": {"capacity": None, "query": {"capacities": {}}}, "notifications": {"cargoTomlNotFound": True}, "numThreads": None, "procMacro": {"attributes": {"enable": True}, "enable": True, "ignored": {}, "server": None}, "references": {"excludeImports": False}, "rust": {"analyzerTargetDir": None}, "rustc": {"source": None}, "rustfmt": {"extraArgs": [], "overrideCommand": None, "rangeFormatting": {"enable": False}}, "semanticHighlighting": { "doc": {"comment": {"inject": {"enable": True}}}, "nonStandardTokens": True, "operator": {"enable": True, "specialization": {"enable": False}}, "punctuation": {"enable": False, "separate": {"macro": {"bang": False}}, "specialization": {"enable": False}}, "strings": {"enable": True}, }, "signatureInfo": {"detail": "full", "documentation": {"enable": True}}, "workspace": {"symbol": {"search": {"kind": "only_types", "limit": 128, "scope": "workspace"}}}, }, "trace": "verbose", "processId": os.getpid(), "rootPath": repository_absolute_path, "rootUri": root_uri, "workspaceFolders": [ { "uri": root_uri, "name": os.path.basename(repository_absolute_path), } ], } return cast(InitializeParams, initialize_params) def _start_server(self) -> None: """ Starts the Rust Analyzer Language Server """ def register_capability_handler(params: dict) -> None: assert "registrations" in params for registration in params["registrations"]: if registration["method"] == "workspace/executeCommand": self.initialize_searcher_command_available.set() self.resolve_main_method_available.set() return def lang_status_handler(params: dict) -> None: # TODO: Should we wait for # server -> client: {'jsonrpc': '2.0', 'method': 'language/status', 'params': {'type': 'ProjectStatus', 'message': 'OK'}} # Before proceeding? if params["type"] == "ServiceReady" and params["message"] == "ServiceReady": self.service_ready_event.set() def execute_client_command_handler(params: dict) -> list: return [] def do_nothing(params: dict) -> None: return def check_experimental_status(params: dict) -> None: if params["quiescent"] == True: self.server_ready.set() def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_notification("language/status", lang_status_handler) self.server.on_notification("window/logMessage", window_log_message) self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) self.server.on_notification("language/actionableNotification", do_nothing) self.server.on_notification("experimental/serverStatus", check_experimental_status) log.info("Starting RustAnalyzer server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) assert init_response["capabilities"]["textDocumentSync"]["change"] == 2 # type: ignore assert "completionProvider" in init_response["capabilities"] assert init_response["capabilities"]["completionProvider"] == { "resolveProvider": True, "triggerCharacters": [":", ".", "'", "("], "completionItem": {"labelDetailsSupport": True}, } self.server.notify.initialized({}) self.server_ready.wait() ================================================ FILE: src/solidlsp/language_servers/scala_language_server.py ================================================ """ Provides Scala specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Scala. """ import logging import os import pathlib import shutil import subprocess from enum import Enum from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_utils import PlatformUtils from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings if not PlatformUtils.get_platform_id().value.startswith("win"): pass log = logging.getLogger(__name__) # Default configuration constants DEFAULT_METALS_VERSION = "1.6.4" DEFAULT_CLIENT_NAME = "Serena" DEFAULT_ON_STALE_LOCK = "auto-clean" DEFAULT_LOG_MULTI_INSTANCE_NOTICE = True class StaleLockMode(Enum): """Mode for handling stale Metals H2 database locks.""" AUTO_CLEAN = "auto-clean" """Automatically remove stale lock files (default, recommended).""" WARN = "warn" """Log a warning but proceed; may result in degraded experience.""" FAIL = "fail" """Raise an error and refuse to start.""" def _get_scala_settings(solidlsp_settings: SolidLSPSettings) -> dict[str, object]: """ Extract Scala-specific settings with defaults applied. Returns a dictionary with keys: - metals_version: str - client_name: str - on_stale_lock: StaleLockMode - log_multi_instance_notice: bool """ from solidlsp.ls_config import Language defaults: dict[str, object] = { "metals_version": DEFAULT_METALS_VERSION, "client_name": DEFAULT_CLIENT_NAME, "on_stale_lock": StaleLockMode.AUTO_CLEAN, "log_multi_instance_notice": DEFAULT_LOG_MULTI_INSTANCE_NOTICE, } if not solidlsp_settings.ls_specific_settings: return defaults scala_settings = solidlsp_settings.get_ls_specific_settings(Language.SCALA) # Parse stale lock mode with validation on_stale_lock_str = scala_settings.get("on_stale_lock", DEFAULT_ON_STALE_LOCK) try: on_stale_lock = StaleLockMode(on_stale_lock_str) except ValueError: log.warning(f"Invalid on_stale_lock value '{on_stale_lock_str}', using '{DEFAULT_ON_STALE_LOCK}'") on_stale_lock = StaleLockMode.AUTO_CLEAN return { "metals_version": scala_settings.get("metals_version", DEFAULT_METALS_VERSION), "client_name": scala_settings.get("client_name", DEFAULT_CLIENT_NAME), "on_stale_lock": on_stale_lock, "log_multi_instance_notice": scala_settings.get("log_multi_instance_notice", DEFAULT_LOG_MULTI_INSTANCE_NOTICE), } class ScalaLanguageServer(SolidLanguageServer): """ Provides Scala specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Scala. Configurable options in ls_specific_settings (in serena_config.yml): ls_specific_settings: scala: # Stale lock handling: auto-clean | warn | fail on_stale_lock: 'auto-clean' # Log notice when another Metals instance is detected log_multi_instance_notice: true # Metals version to bootstrap (default: DEFAULT_METALS_VERSION) metals_version: '1.6.4' # Client identifier sent to Metals (default: DEFAULT_CLIENT_NAME) client_name: 'Serena' Multi-instance support: Metals uses H2 AUTO_SERVER mode (enabled by default) to support multiple concurrent instances sharing the same database. Running Serena's Metals alongside VS Code's Metals is designed to work. The only issue is stale locks from crashed processes, which this class can detect and clean up. """ def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a ScalaLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ # Check for stale locks before setting up dependencies (fail-fast) self._check_metals_db_status(repository_root_path, solidlsp_settings) scala_lsp_executable_path = self._setup_runtime_dependencies(config, solidlsp_settings) super().__init__( config, repository_root_path, ProcessLaunchInfo(cmd=scala_lsp_executable_path, cwd=repository_root_path), config.code_language.value, solidlsp_settings, ) def _check_metals_db_status(self, repository_root_path: str, solidlsp_settings: SolidLSPSettings) -> None: """ Check the Metals H2 database status and handle stale locks. This method is called before setting up runtime dependencies to fail-fast if there's a stale lock that the user has configured to fail on. """ from pathlib import Path from solidlsp.ls_exceptions import MetalsStaleLockError from solidlsp.util.metals_db_utils import ( MetalsDbStatus, check_metals_db_status, cleanup_stale_lock, ) project_path = Path(repository_root_path) status, lock_info = check_metals_db_status(project_path) # Get settings using the shared helper function settings = _get_scala_settings(solidlsp_settings) on_stale_lock: StaleLockMode = settings["on_stale_lock"] # type: ignore[assignment] log_multi_instance_notice: bool = settings["log_multi_instance_notice"] # type: ignore[assignment] if status == MetalsDbStatus.ACTIVE_INSTANCE: if log_multi_instance_notice and lock_info: log.info( f"Another Metals instance detected (PID: {lock_info.pid}). " "This is fine - Metals supports multiple instances via H2 AUTO_SERVER. " "Both instances will share the database and Bloop build server." ) elif status == MetalsDbStatus.STALE_LOCK: lock_path = lock_info.lock_path if lock_info else project_path / ".metals" / "metals.mv.db.lock.db" lock_path_str = str(lock_path) if on_stale_lock == StaleLockMode.AUTO_CLEAN: log.info(f"Stale Metals lock detected, cleaning up: {lock_path_str}") cleanup_success = cleanup_stale_lock(lock_path) if not cleanup_success: log.warning( f"Failed to clean up stale lock at {lock_path_str}. " "Metals may fall back to in-memory database (degraded experience)." ) elif on_stale_lock == StaleLockMode.WARN: log.warning( f"Stale Metals lock detected at {lock_path_str}. " "A previous Metals process may have crashed. " "Metals will fall back to in-memory database (degraded experience). " "Consider removing the lock file manually or setting on_stale_lock='auto-clean'." ) elif on_stale_lock == StaleLockMode.FAIL: raise MetalsStaleLockError(lock_path_str) @override def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in [ ".bloop", ".metals", "target", ] @classmethod def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> list[str]: """ Setup runtime dependencies for Scala Language Server and return the command to start the server. """ assert shutil.which("java") is not None, "JDK is not installed or not in PATH." # Check if metals is available globally in PATH global_metals = shutil.which("metals") if global_metals: log.info(f"Found metals in PATH: {global_metals}") return [global_metals] # Get settings using the shared helper function settings = _get_scala_settings(solidlsp_settings) metals_version: str = settings["metals_version"] # type: ignore[assignment] client_name: str = settings["client_name"] # type: ignore[assignment] metals_home = os.path.join(cls.ls_resources_dir(solidlsp_settings), "metals-lsp") os.makedirs(metals_home, exist_ok=True) metals_executable = os.path.join(metals_home, metals_version, "metals") if not os.path.exists(metals_executable): coursier_command_path = shutil.which("coursier") cs_command_path = shutil.which("cs") assert cs_command_path is not None or coursier_command_path is not None, "coursier is not installed or not in PATH." if not cs_command_path: assert coursier_command_path is not None log.info("'cs' command not found. Trying to install it using 'coursier'.") try: log.info("Running 'coursier setup --yes' to install 'cs'...") subprocess.run([coursier_command_path, "setup", "--yes"], check=True, capture_output=True, text=True) except subprocess.CalledProcessError as e: raise RuntimeError(f"Failed to set up 'cs' command with 'coursier setup'. Stderr: {e.stderr}") cs_command_path = shutil.which("cs") if not cs_command_path: raise RuntimeError( "'cs' command not found after running 'coursier setup'. Please check your PATH or install it manually." ) log.info("'cs' command installed successfully.") log.info(f"metals executable not found at {metals_executable}, bootstrapping...") subprocess.run(["mkdir", "-p", os.path.join(metals_home, metals_version)], check=True) artifact = f"org.scalameta:metals_2.13:{metals_version}" cmd = [ cs_command_path, "bootstrap", "--java-opt", "-XX:+UseG1GC", "--java-opt", "-XX:+UseStringDeduplication", "--java-opt", "-Xss4m", "--java-opt", "-Xms100m", "--java-opt", f"-Dmetals.client={client_name}", artifact, "-o", metals_executable, "-f", ] log.info("Bootstrapping metals...") subprocess.run(cmd, cwd=metals_home, check=True) log.info("Bootstrapping metals finished.") return [metals_executable] @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the Scala Language Server. """ root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "processId": os.getpid(), "rootPath": repository_absolute_path, "rootUri": root_uri, "initializationOptions": { "compilerOptions": { "completionCommand": None, "isCompletionItemDetailEnabled": True, "isCompletionItemDocumentationEnabled": True, "isCompletionItemResolve": True, "isHoverDocumentationEnabled": True, "isSignatureHelpDocumentationEnabled": True, "overrideDefFormat": "ascli", "snippetAutoIndent": False, }, "debuggingProvider": True, "decorationProvider": False, "didFocusProvider": False, "doctorProvider": False, "executeClientCommandProvider": False, "globSyntax": "uri", "icons": "unicode", "inputBoxProvider": False, "isVirtualDocumentSupported": False, "isExitOnShutdown": True, "isHttpEnabled": True, "openFilesOnRenameProvider": False, "quickPickProvider": False, "renameFileThreshold": 200, "statusBarProvider": "false", "treeViewProvider": False, "testExplorerProvider": False, "openNewWindowProvider": False, "copyWorksheetOutputProvider": False, "doctorVisibilityProvider": False, }, "capabilities": {"textDocument": {"documentSymbol": {"hierarchicalDocumentSymbolSupport": True}}}, } return initialize_params # type: ignore def _start_server(self) -> None: """ Starts the Scala Language Server """ log.info("Starting Scala server process") self.server.start() log.info("Sending initialize request from LSP client to LSP server and awaiting response") initialize_params = self._get_initialize_params(self.repository_root_path) self.server.send.initialize(initialize_params) self.server.notify.initialized({}) @override def _get_wait_time_for_cross_file_referencing(self) -> float: return 5 ================================================ FILE: src/solidlsp/language_servers/solargraph.py ================================================ """ Provides Ruby specific instantiation of the LanguageServer class using Solargraph. Contains various configurations and settings specific to Ruby. """ import json import logging import os import pathlib import re import shutil import subprocess import threading from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) class Solargraph(SolidLanguageServer): """ Provides Ruby specific instantiation of the LanguageServer class using Solargraph. Contains various configurations and settings specific to Ruby. """ def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a Solargraph instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ solargraph_executable_path = self._setup_runtime_dependencies(config, repository_root_path) super().__init__( config, repository_root_path, ProcessLaunchInfo(cmd=f"{solargraph_executable_path} stdio", cwd=repository_root_path), "ruby", solidlsp_settings, ) # Override internal language enum for file matching (excludes .erb files) # while keeping LSP languageId as "ruby" for protocol compliance from solidlsp.ls_config import Language self.language = Language.RUBY_SOLARGRAPH self.analysis_complete = threading.Event() self.service_ready_event = threading.Event() self.initialize_searcher_command_available = threading.Event() self.resolve_main_method_available = threading.Event() # Set timeout for Solargraph requests - Bundler environments may need more time self.set_request_timeout(120.0) # 120 seconds for initialization and requests @override def is_ignored_dirname(self, dirname: str) -> bool: ruby_ignored_dirs = [ "vendor", # Ruby vendor directory ".bundle", # Bundler cache "tmp", # Temporary files "log", # Log files "coverage", # Test coverage reports ".yardoc", # YARD documentation cache "doc", # Generated documentation "node_modules", # Node modules (for Rails with JS) "storage", # Active Storage files (Rails) ] return super().is_ignored_dirname(dirname) or dirname in ruby_ignored_dirs @staticmethod def _setup_runtime_dependencies(config: LanguageServerConfig, repository_root_path: str) -> str: """ Setup runtime dependencies for Solargraph and return the command to start the server. """ # Check if Ruby is installed try: result = subprocess.run(["ruby", "--version"], check=True, capture_output=True, cwd=repository_root_path, text=True) ruby_version = result.stdout.strip() log.info(f"Ruby version: {ruby_version}") # Extract version number for compatibility checks version_match = re.search(r"ruby (\d+)\.(\d+)\.(\d+)", ruby_version) if version_match: major, minor, patch = map(int, version_match.groups()) if major < 2 or (major == 2 and minor < 6): log.warning(f"Warning: Ruby {major}.{minor}.{patch} detected. Solargraph works best with Ruby 2.6+") except subprocess.CalledProcessError as e: error_msg = e.stderr.decode() if e.stderr else "Unknown error" raise RuntimeError( f"Error checking Ruby installation: {error_msg}. Please ensure Ruby is properly installed and in PATH." ) from e except FileNotFoundError as e: raise RuntimeError( "Ruby is not installed or not found in PATH. Please install Ruby using one of these methods:\n" " - Using rbenv: rbenv install 3.0.0 && rbenv global 3.0.0\n" " - Using RVM: rvm install 3.0.0 && rvm use 3.0.0 --default\n" " - Using asdf: asdf install ruby 3.0.0 && asdf global ruby 3.0.0\n" " - System package manager (brew install ruby, apt install ruby, etc.)" ) from e # Helper function for Windows-compatible executable search def find_executable_with_extensions(executable_name: str) -> str | None: """Find executable with Windows-specific extensions if on Windows.""" import platform if platform.system() == "Windows": for ext in [".bat", ".cmd", ".exe"]: path = shutil.which(f"{executable_name}{ext}") if path: return path return shutil.which(executable_name) else: return shutil.which(executable_name) # Check for Bundler project (Gemfile exists) gemfile_path = os.path.join(repository_root_path, "Gemfile") gemfile_lock_path = os.path.join(repository_root_path, "Gemfile.lock") is_bundler_project = os.path.exists(gemfile_path) if is_bundler_project: log.info("Detected Bundler project (Gemfile found)") # Check if bundle command is available bundle_path = find_executable_with_extensions("bundle") if not bundle_path: # Try common bundle executables for bundle_cmd in ["bin/bundle", "bundle"]: if bundle_cmd.startswith("bin/"): bundle_full_path = os.path.join(repository_root_path, bundle_cmd) else: bundle_full_path = find_executable_with_extensions(bundle_cmd) # type: ignore[assignment] if bundle_full_path and os.path.exists(bundle_full_path): bundle_path = bundle_full_path if bundle_cmd.startswith("bin/") else bundle_cmd break if not bundle_path: raise RuntimeError( "Bundler project detected but 'bundle' command not found. Please install Bundler:\n" " - gem install bundler\n" " - Or use your Ruby version manager's bundler installation\n" " - Ensure the bundle command is in your PATH" ) # Check if solargraph is in Gemfile.lock solargraph_in_bundle = False if os.path.exists(gemfile_lock_path): try: with open(gemfile_lock_path) as f: content = f.read() solargraph_in_bundle = "solargraph" in content.lower() except Exception as e: log.warning(f"Warning: Could not read Gemfile.lock: {e}") if solargraph_in_bundle: log.info("Found solargraph in Gemfile.lock") return f"{bundle_path} exec solargraph" else: log.warning( "solargraph not found in Gemfile.lock. Please add 'gem \"solargraph\"' to your Gemfile and run 'bundle install'", ) # Fall through to global installation check # Check if solargraph is installed globally # First, try to find solargraph in PATH (includes asdf shims) with Windows support solargraph_path = find_executable_with_extensions("solargraph") if solargraph_path: log.info(f"Found solargraph at: {solargraph_path}") return solargraph_path # Fallback to gem exec (for non-Bundler projects or when global solargraph not found) if not is_bundler_project: runtime_dependencies = [ { "url": "https://rubygems.org/downloads/solargraph-0.51.1.gem", "installCommand": "gem install solargraph -v 0.51.1", "binaryName": "solargraph", "archiveType": "gem", } ] dependency = runtime_dependencies[0] try: result = subprocess.run( ["gem", "list", "^solargraph$", "-i"], check=False, capture_output=True, text=True, cwd=repository_root_path ) if result.stdout.strip() == "false": log.info("Installing Solargraph...") subprocess.run(dependency["installCommand"].split(), check=True, capture_output=True, cwd=repository_root_path) return "gem exec solargraph" except subprocess.CalledProcessError as e: error_msg = e.stderr.decode() if e.stderr else str(e) raise RuntimeError( f"Failed to check or install Solargraph: {error_msg}\nPlease try installing manually: gem install solargraph" ) from e else: raise RuntimeError( "This appears to be a Bundler project, but solargraph is not available. " "Please add 'gem \"solargraph\"' to your Gemfile and run 'bundle install'." ) @staticmethod def _detect_rails_project(repository_root_path: str) -> bool: """ Detect if this is a Rails project by checking for Rails-specific files. """ rails_indicators = [ "config/application.rb", "config/environment.rb", "app/controllers/application_controller.rb", "Rakefile", ] for indicator in rails_indicators: if os.path.exists(os.path.join(repository_root_path, indicator)): return True # Check for Rails in Gemfile gemfile_path = os.path.join(repository_root_path, "Gemfile") if os.path.exists(gemfile_path): try: with open(gemfile_path) as f: content = f.read().lower() if "gem 'rails'" in content or 'gem "rails"' in content: return True except Exception: pass return False @staticmethod def _get_ruby_exclude_patterns(repository_root_path: str) -> list[str]: """ Get Ruby and Rails-specific exclude patterns for better performance. """ base_patterns = [ "**/vendor/**", # Ruby vendor directory (similar to node_modules) "**/.bundle/**", # Bundler cache "**/tmp/**", # Temporary files "**/log/**", # Log files "**/coverage/**", # Test coverage reports "**/.yardoc/**", # YARD documentation cache "**/doc/**", # Generated documentation "**/.git/**", # Git directory "**/node_modules/**", # Node modules (for Rails with JS) "**/public/assets/**", # Rails compiled assets ] # Add Rails-specific patterns if this is a Rails project if Solargraph._detect_rails_project(repository_root_path): rails_patterns = [ "**/public/packs/**", # Webpacker output "**/public/webpack/**", # Webpack output "**/storage/**", # Active Storage files "**/tmp/cache/**", # Rails cache "**/tmp/pids/**", # Process IDs "**/tmp/sessions/**", # Session files "**/tmp/sockets/**", # Socket files "**/db/*.sqlite3", # SQLite databases ] base_patterns.extend(rails_patterns) return base_patterns @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the Solargraph Language Server. """ root_uri = pathlib.Path(repository_absolute_path).as_uri() exclude_patterns = Solargraph._get_ruby_exclude_patterns(repository_absolute_path) initialize_params: InitializeParams = { # type: ignore "processId": os.getpid(), "rootPath": repository_absolute_path, "rootUri": root_uri, "initializationOptions": { "exclude": exclude_patterns, # type: ignore[dict-item] }, "capabilities": { "workspace": { "workspaceEdit": {"documentChanges": True}, }, "textDocument": { "documentSymbol": { "hierarchicalDocumentSymbolSupport": True, "symbolKind": {"valueSet": list(range(1, 27))}, # type: ignore[arg-type] }, }, }, "trace": "verbose", # type: ignore[typeddict-item] "workspaceFolders": [ { "uri": root_uri, "name": os.path.basename(repository_absolute_path), } ], } return initialize_params # type: ignore[return-value] def _start_server(self) -> None: """ Starts the Solargraph Language Server for Ruby """ def register_capability_handler(params: dict) -> None: assert "registrations" in params for registration in params["registrations"]: if registration["method"] == "workspace/executeCommand": self.initialize_searcher_command_available.set() self.resolve_main_method_available.set() return def lang_status_handler(params: dict) -> None: log.info(f"LSP: language/status: {params}") if params.get("type") == "ServiceReady" and params.get("message") == "Service is ready.": log.info("Solargraph service is ready.") self.analysis_complete.set() def execute_client_command_handler(params: dict) -> list: return [] def do_nothing(params: dict) -> None: return def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_notification("language/status", lang_status_handler) self.server.on_notification("window/logMessage", window_log_message) self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) self.server.on_notification("language/actionableNotification", do_nothing) log.info("Starting solargraph server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.info("Sending initialize request from LSP client to LSP server and awaiting response") log.info(f"Sending init params: {json.dumps(initialize_params, indent=4)}") init_response = self.server.send.initialize(initialize_params) log.info(f"Received init response: {init_response}") assert init_response["capabilities"]["textDocumentSync"] == 2 assert "completionProvider" in init_response["capabilities"] assert init_response["capabilities"]["completionProvider"] == { "resolveProvider": True, "triggerCharacters": [".", ":", "@"], } self.server.notify.initialized({}) # Wait for Solargraph to complete its initial workspace analysis # This prevents issues by ensuring background tasks finish log.info("Waiting for Solargraph to complete initial workspace analysis...") if self.analysis_complete.wait(timeout=60.0): log.info("Solargraph initial analysis complete, server ready") else: log.warning("Timeout waiting for Solargraph analysis completion, proceeding anyway") # Fallback: assume analysis is complete after timeout self.analysis_complete.set() ================================================ FILE: src/solidlsp/language_servers/solidity_language_server.py ================================================ """ Provides Solidity-specific instantiation of the LanguageServer class using the Nomic Foundation Solidity Language Server (@nomicfoundation/solidity-language-server). """ import glob import logging import os import pathlib import shutil import threading from time import sleep from typing import Any from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) class SolidityLanguageServer(SolidLanguageServer): """ Provides Solidity-specific instantiation of the LanguageServer class using the Nomic Foundation Solidity Language Server (@nomicfoundation/solidity-language-server). Supports go-to-definition, find references, document symbols, hover, and diagnostics. Requires Node.js and npm to be installed. """ @staticmethod def _determine_log_level(line: str) -> int: """Suppress known non-critical stderr output from the Solidity language server.""" line_lower = line.lower() if any( [ "telemetry" in line_lower, "could not find" in line_lower and "hardhat" in line_lower, "no workspaceroot" in line_lower, ] ): return logging.DEBUG return SolidLanguageServer._determine_log_level(line) def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a SolidityLanguageServer instance. Not meant to be instantiated directly. Use LanguageServer.create() instead. """ super().__init__( config, repository_root_path, None, "solidity", solidlsp_settings, ) def _create_dependency_provider(self) -> LanguageServerDependencyProvider: return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) class DependencyProvider(LanguageServerDependencyProviderSinglePath): def _get_or_install_core_dependency(self) -> str: """ Install @nomicfoundation/solidity-language-server via npm and return the path to the solidity-language-server executable. """ is_node_installed = shutil.which("node") is not None assert is_node_installed, "node is not installed or isn't in PATH. Please install Node.js and try again." is_npm_installed = shutil.which("npm") is not None assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." deps = RuntimeDependencyCollection( [ RuntimeDependency( id="solidity-language-server", description="Nomic Foundation Solidity Language Server", command="npm install --prefix ./ @nomicfoundation/solidity-language-server@0.8.4", platform_id="any", ), ] ) solidity_ls_dir = os.path.join(self._ls_resources_dir, "solidity-lsp") solidity_executable_path = os.path.join(solidity_ls_dir, "node_modules", ".bin", "nomicfoundation-solidity-language-server") if os.name == "nt": solidity_executable_path += ".cmd" if not os.path.exists(solidity_executable_path): log.info(f"Solidity Language Server executable not found at {solidity_executable_path}. Installing...") deps.install(solidity_ls_dir) log.info("Solidity language server dependencies installed successfully.") if not os.path.exists(solidity_executable_path): raise FileNotFoundError( f"nomicfoundation-solidity-language-server executable not found at {solidity_executable_path}. " "Something went wrong with the installation." ) return solidity_executable_path def _create_launch_command(self, core_path: str) -> list[str]: return [core_path, "--stdio"] def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in {"artifacts", "cache", "typechain-types"} @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """Return LSP InitializeParams for the Solidity language server.""" root_uri = pathlib.Path(repository_absolute_path).as_uri() return { # type: ignore "locale": "en", "processId": os.getpid(), "rootPath": repository_absolute_path, "rootUri": root_uri, "workspaceFolders": [ { "uri": root_uri, "name": os.path.basename(repository_absolute_path), } ], "capabilities": { "textDocument": { "synchronization": { "dynamicRegistration": True, "didSave": True, }, "completion": { "dynamicRegistration": True, "completionItem": {"snippetSupport": True}, }, "definition": {"dynamicRegistration": True}, "references": {"dynamicRegistration": True}, "documentSymbol": { "dynamicRegistration": True, "hierarchicalDocumentSymbolSupport": True, "symbolKind": {"valueSet": list(range(1, 27))}, # type: ignore[arg-type] }, "hover": { "dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"], # type: ignore[list-item] }, }, "workspace": { "workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}, "symbol": {"dynamicRegistration": True}, }, }, "initializationOptions": {}, } def _get_wait_time_for_cross_file_referencing(self) -> float: # Small buffer for any post-indexing analysis the LSP performs after file-indexed events. return 3.0 def _start_server(self) -> None: """Start the Solidity language server and wait for project indexing to finish.""" def do_nothing(params: Any) -> None: return def register_capability_handler(params: Any) -> None: return def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") # Count .sol files in the project to know when indexing is complete. sol_files = glob.glob(os.path.join(self.repository_root_path, "**", "*.sol"), recursive=True) expected_count = len(sol_files) indexed_count = [0] all_indexed = threading.Event() def on_file_indexed(params: Any) -> None: indexed_count[0] += 1 uri = (params or {}).get("uri", "") log.debug(f"Solidity LSP: file indexed ({indexed_count[0]}/{expected_count}): {uri}") if indexed_count[0] >= expected_count: all_indexed.set() self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_notification("window/logMessage", window_log_message) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) self.server.on_notification("custom/file-indexed", on_file_indexed) log.info("Starting Solidity language server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.debug("Sending initialize request to Solidity language server") init_response = self.server.send.initialize(initialize_params) log.debug(f"Received initialize response from Solidity server: {init_response}") if "documentSymbolProvider" in init_response.get("capabilities", {}): log.debug("Solidity server supports document symbols") else: log.warning("Solidity server does not report document symbol support") self.server.notify.initialized({}) if expected_count > 0: log.info(f"Waiting for Solidity LSP to index {expected_count} .sol file(s)…") completed = all_indexed.wait(timeout=60) if completed: log.info(f"Solidity LSP indexing complete ({indexed_count[0]}/{expected_count} files indexed)") else: log.warning( f"Solidity LSP indexing timed out ({indexed_count[0]}/{expected_count} files indexed). " "Waiting additional 30s for slow environments (e.g., CI)." ) sleep(30) log.info(f"Additional wait complete ({indexed_count[0]}/{expected_count} files indexed)") else: log.info("No .sol files found; skipping indexing wait") log.info("Solidity language server initialization complete") ================================================ FILE: src/solidlsp/language_servers/sourcekit_lsp.py ================================================ import logging import os import pathlib import subprocess import time from overrides import override from solidlsp import ls_types from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) class SourceKitLSP(SolidLanguageServer): """ Provides Swift specific instantiation of the LanguageServer class using sourcekit-lsp. """ @override def is_ignored_dirname(self, dirname: str) -> bool: # For Swift projects, we should ignore: # - .build: Swift Package Manager build artifacts # - .swiftpm: Swift Package Manager metadata # - node_modules: if the project has JavaScript components # - dist/build: common output directories return super().is_ignored_dirname(dirname) or dirname in [".build", ".swiftpm", "node_modules", "dist", "build"] @staticmethod def _get_sourcekit_lsp_version() -> str: """Get the installed sourcekit-lsp version or raise error if sourcekit was not found.""" try: result = subprocess.run(["sourcekit-lsp", "-h"], capture_output=True, text=True, check=False) if result.returncode == 0: return result.stdout.strip() else: raise Exception(f"`sourcekit-lsp -h` resulted in: {result}") except Exception as e: raise RuntimeError( "Could not find sourcekit-lsp, please install it as described in https://github.com/apple/sourcekit-lsp#installation" "And make sure it is available on your PATH." ) from e def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): sourcekit_version = self._get_sourcekit_lsp_version() log.info(f"Starting sourcekit lsp with version: {sourcekit_version}") super().__init__( config, repository_root_path, ProcessLaunchInfo(cmd="sourcekit-lsp", cwd=repository_root_path), "swift", solidlsp_settings ) self.request_id = 0 self._did_sleep_before_requesting_references = False self._initialization_timestamp: float | None = None @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the Swift Language Server. """ root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "capabilities": { "general": { "markdown": {"parser": "marked", "version": "1.1.0"}, "positionEncodings": ["utf-16"], "regularExpressions": {"engine": "ECMAScript", "version": "ES2020"}, "staleRequestSupport": { "cancel": True, "retryOnContentModified": [ "textDocument/semanticTokens/full", "textDocument/semanticTokens/range", "textDocument/semanticTokens/full/delta", ], }, }, "notebookDocument": {"synchronization": {"dynamicRegistration": True, "executionSummarySupport": True}}, "textDocument": { "callHierarchy": {"dynamicRegistration": True}, "codeAction": { "codeActionLiteralSupport": { "codeActionKind": { "valueSet": [ "", "quickfix", "refactor", "refactor.extract", "refactor.inline", "refactor.rewrite", "source", "source.organizeImports", ] } }, "dataSupport": True, "disabledSupport": True, "dynamicRegistration": True, "honorsChangeAnnotations": True, "isPreferredSupport": True, "resolveSupport": {"properties": ["edit"]}, }, "codeLens": {"dynamicRegistration": True}, "colorProvider": {"dynamicRegistration": True}, "completion": { "completionItem": { "commitCharactersSupport": True, "deprecatedSupport": True, "documentationFormat": ["markdown", "plaintext"], "insertReplaceSupport": True, "insertTextModeSupport": {"valueSet": [1, 2]}, "labelDetailsSupport": True, "preselectSupport": True, "resolveSupport": {"properties": ["documentation", "detail", "additionalTextEdits"]}, "snippetSupport": True, "tagSupport": {"valueSet": [1]}, }, "completionItemKind": { "valueSet": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25] }, "completionList": {"itemDefaults": ["commitCharacters", "editRange", "insertTextFormat", "insertTextMode", "data"]}, "contextSupport": True, "dynamicRegistration": True, "insertTextMode": 2, }, "declaration": {"dynamicRegistration": True, "linkSupport": True}, "definition": {"dynamicRegistration": True, "linkSupport": True}, "diagnostic": {"dynamicRegistration": True, "relatedDocumentSupport": False}, "documentHighlight": {"dynamicRegistration": True}, "documentLink": {"dynamicRegistration": True, "tooltipSupport": True}, "documentSymbol": { "dynamicRegistration": True, "hierarchicalDocumentSymbolSupport": True, "labelSupport": True, "symbolKind": { "valueSet": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26] }, "tagSupport": {"valueSet": [1]}, }, "foldingRange": { "dynamicRegistration": True, "foldingRange": {"collapsedText": False}, "foldingRangeKind": {"valueSet": ["comment", "imports", "region"]}, "lineFoldingOnly": True, "rangeLimit": 5000, }, "formatting": {"dynamicRegistration": True}, "hover": {"contentFormat": ["markdown", "plaintext"], "dynamicRegistration": True}, "implementation": {"dynamicRegistration": True, "linkSupport": True}, "inlayHint": { "dynamicRegistration": True, "resolveSupport": {"properties": ["tooltip", "textEdits", "label.tooltip", "label.location", "label.command"]}, }, "inlineValue": {"dynamicRegistration": True}, "linkedEditingRange": {"dynamicRegistration": True}, "onTypeFormatting": {"dynamicRegistration": True}, "publishDiagnostics": { "codeDescriptionSupport": True, "dataSupport": True, "relatedInformation": True, "tagSupport": {"valueSet": [1, 2]}, "versionSupport": False, }, "rangeFormatting": {"dynamicRegistration": True, "rangesSupport": True}, "references": {"dynamicRegistration": True}, "rename": { "dynamicRegistration": True, "honorsChangeAnnotations": True, "prepareSupport": True, "prepareSupportDefaultBehavior": 1, }, "selectionRange": {"dynamicRegistration": True}, "semanticTokens": { "augmentsSyntaxTokens": True, "dynamicRegistration": True, "formats": ["relative"], "multilineTokenSupport": False, "overlappingTokenSupport": False, "requests": {"full": {"delta": True}, "range": True}, "serverCancelSupport": True, "tokenModifiers": [ "declaration", "definition", "readonly", "static", "deprecated", "abstract", "async", "modification", "documentation", "defaultLibrary", ], "tokenTypes": [ "namespace", "type", "class", "enum", "interface", "struct", "typeParameter", "parameter", "variable", "property", "enumMember", "event", "function", "method", "macro", "keyword", "modifier", "comment", "string", "number", "regexp", "operator", "decorator", ], }, "signatureHelp": { "contextSupport": True, "dynamicRegistration": True, "signatureInformation": { "activeParameterSupport": True, "documentationFormat": ["markdown", "plaintext"], "parameterInformation": {"labelOffsetSupport": True}, }, }, "synchronization": {"didSave": True, "dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True}, "typeDefinition": {"dynamicRegistration": True, "linkSupport": True}, "typeHierarchy": {"dynamicRegistration": True}, }, "window": { "showDocument": {"support": True}, "showMessage": {"messageActionItem": {"additionalPropertiesSupport": True}}, "workDoneProgress": True, }, "workspace": { "applyEdit": True, "codeLens": {"refreshSupport": True}, "configuration": True, "diagnostics": {"refreshSupport": True}, "didChangeConfiguration": {"dynamicRegistration": True}, "didChangeWatchedFiles": {"dynamicRegistration": True, "relativePatternSupport": True}, "executeCommand": {"dynamicRegistration": True}, "fileOperations": { "didCreate": True, "didDelete": True, "didRename": True, "dynamicRegistration": True, "willCreate": True, "willDelete": True, "willRename": True, }, "foldingRange": {"refreshSupport": True}, "inlayHint": {"refreshSupport": True}, "inlineValue": {"refreshSupport": True}, "semanticTokens": {"refreshSupport": False}, "symbol": { "dynamicRegistration": True, "resolveSupport": {"properties": ["location.range"]}, "symbolKind": { "valueSet": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26] }, "tagSupport": {"valueSet": [1]}, }, "workspaceEdit": { "changeAnnotationSupport": {"groupsOnLabel": True}, "documentChanges": True, "failureHandling": "textOnlyTransactional", "normalizesLineEndings": True, "resourceOperations": ["create", "rename", "delete"], }, "workspaceFolders": True, }, }, "clientInfo": {"name": "Visual Studio Code", "version": "1.102.2"}, "initializationOptions": { "backgroundIndexing": True, "backgroundPreparationMode": "enabled", "textDocument/codeLens": {"supportedCommands": {"swift.debug": "swift.debug", "swift.run": "swift.run"}}, "window/didChangeActiveDocument": True, "workspace/getReferenceDocument": True, "workspace/peekDocuments": True, }, "locale": "en", "processId": os.getpid(), "rootPath": repository_absolute_path, "rootUri": root_uri, "workspaceFolders": [ { "uri": root_uri, "name": os.path.basename(repository_absolute_path), } ], } return initialize_params # type: ignore[return-value] def _start_server(self) -> None: """Start sourcekit-lsp server process""" def register_capability_handler(_params: dict) -> None: return def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") def do_nothing(_params: dict) -> None: return self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_notification("window/logMessage", window_log_message) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) log.info("Starting sourcekit-lsp server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) capabilities = init_response["capabilities"] log.info(f"SourceKit LSP capabilities: {list(capabilities.keys())}") assert "textDocumentSync" in capabilities, "textDocumentSync capability missing" assert "definitionProvider" in capabilities, "definitionProvider capability missing" self.server.notify.initialized({}) # Mark initialization timestamp for smarter delay calculation self._initialization_timestamp = time.time() @override def request_references(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]: # SourceKit LSP needs initialization + indexing time after startup # before it can provide accurate reference information. This sleep # prevents race conditions where references might not be available yet. # CI environments need extra time for project indexing and cross-file analysis if not self._did_sleep_before_requesting_references: # Calculate minimum delay based on how much time has passed since initialization if self._initialization_timestamp: elapsed = time.time() - self._initialization_timestamp # Increased CI delay for project indexing: 15s CI, 5s local base_delay = 15 if os.getenv("CI") else 5 remaining_delay = max(2, base_delay - elapsed) else: # Fallback if initialization timestamp is missing remaining_delay = 15 if os.getenv("CI") else 5 log.info(f"Sleeping {remaining_delay:.1f}s before requesting references for the first time (CI needs extra indexing time)") time.sleep(remaining_delay) self._did_sleep_before_requesting_references = True # Get references with retry logic for CI stability references = super().request_references(relative_file_path, line, column) # In CI, if no references found, retry once after additional delay if os.getenv("CI") and not references: log.info("No references found in CI - retrying after additional 5s delay") time.sleep(5) references = super().request_references(relative_file_path, line, column) return references ================================================ FILE: src/solidlsp/language_servers/systemverilog_server.py ================================================ """ SystemVerilog language server using verible-verilog-ls. """ import logging import os import pathlib import shutil import subprocess from typing import Any, cast from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings from .common import RuntimeDependency, RuntimeDependencyCollection log = logging.getLogger(__name__) class SystemVerilogLanguageServer(SolidLanguageServer): """ SystemVerilog language server using verible-verilog-ls. Supports .sv, .svh, .v, .vh files. """ def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings) -> None: super().__init__(config, repository_root_path, None, "systemverilog", solidlsp_settings) def _create_dependency_provider(self) -> LanguageServerDependencyProvider: return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) class DependencyProvider(LanguageServerDependencyProviderSinglePath): def _get_or_install_core_dependency(self) -> str: # 1. Check PATH first for system-installed verible system_verible = shutil.which("verible-verilog-ls") if system_verible: # Log version information try: result = subprocess.run( [system_verible, "--version"], capture_output=True, text=True, check=False, timeout=5, ) if result.returncode == 0: version_info = result.stdout.strip().split("\n")[0] log.info(f"Using system-installed verible-verilog-ls: {version_info}") else: log.info(f"Using system-installed verible-verilog-ls at {system_verible}") except Exception: log.info(f"Using system-installed verible-verilog-ls at {system_verible}") return system_verible # 2. Not found in PATH, try to download verible_version = self._custom_settings.get("verible_version", "v0.0-4051-g9fdb4057") base_url = f"https://github.com/chipsalliance/verible/releases/download/{verible_version}" deps = RuntimeDependencyCollection( [ RuntimeDependency( id="verible-ls", description="verible-verilog-ls for Linux (x64)", url=f"{base_url}/verible-{verible_version}-linux-static-x86_64.tar.gz", platform_id="linux-x64", archive_type="gztar", binary_name=f"verible-{verible_version}/bin/verible-verilog-ls", ), RuntimeDependency( id="verible-ls", description="verible-verilog-ls for Linux (arm64)", url=f"{base_url}/verible-{verible_version}-linux-static-arm64.tar.gz", platform_id="linux-arm64", archive_type="gztar", binary_name=f"verible-{verible_version}/bin/verible-verilog-ls", ), RuntimeDependency( id="verible-ls", description="verible-verilog-ls for macOS", url=f"{base_url}/verible-{verible_version}-macOS.tar.gz", platform_id="osx-x64", archive_type="gztar", binary_name=f"verible-{verible_version}/bin/verible-verilog-ls", ), RuntimeDependency( id="verible-ls", description="verible-verilog-ls for macOS", url=f"{base_url}/verible-{verible_version}-macOS.tar.gz", platform_id="osx-arm64", archive_type="gztar", binary_name=f"verible-{verible_version}/bin/verible-verilog-ls", ), RuntimeDependency( id="verible-ls", description="verible-verilog-ls for Windows (x64)", url=f"{base_url}/verible-{verible_version}-win64.zip", platform_id="win-x64", archive_type="zip", binary_name=f"verible-{verible_version}/bin/verible-verilog-ls.exe", ), ] ) try: dep = deps.get_single_dep_for_current_platform() except RuntimeError: dep = None if dep is None: raise FileNotFoundError( "verible-verilog-ls is not installed on your system.\n" + "Please install verible using one of the following methods:\n" + " conda: conda install -c conda-forge verible\n" + " Homebrew: brew install verible\n" + " GitHub: Download from https://github.com/chipsalliance/verible/releases\n" + "See https://github.com/chipsalliance/verible for more details." ) verible_ls_dir = os.path.join(self._ls_resources_dir, "verible-ls") executable_path = deps.binary_path(verible_ls_dir) if not os.path.exists(executable_path): log.info(f"verible-verilog-ls not found. Downloading from {dep.url}") _ = deps.install(verible_ls_dir) if not os.path.exists(executable_path): raise FileNotFoundError(f"verible-verilog-ls not found at {executable_path}") os.chmod(executable_path, 0o755) return executable_path def _create_launch_command(self, core_path: str) -> list[str]: return [core_path] @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "processId": os.getpid(), "rootPath": repository_absolute_path, "rootUri": root_uri, "locale": "en", "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, "completion": { "dynamicRegistration": True, "completionItem": {"snippetSupport": True}, }, "definition": {"dynamicRegistration": True}, "references": {"dynamicRegistration": True}, "hover": { "dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"], }, "documentSymbol": { "dynamicRegistration": True, "hierarchicalDocumentSymbolSupport": True, "symbolKind": {"valueSet": list(range(1, 27))}, }, "codeAction": {"dynamicRegistration": True}, "formatting": {"dynamicRegistration": True}, "documentHighlight": {"dynamicRegistration": True}, "publishDiagnostics": {"relatedInformation": True}, }, "workspace": { "workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}, }, }, "workspaceFolders": [{"uri": root_uri, "name": os.path.basename(repository_absolute_path)}], } return cast(InitializeParams, initialize_params) def _start_server(self) -> None: def do_nothing(params: Any) -> None: return def on_log_message(params: Any) -> None: message = params.get("message", "") if isinstance(params, dict) else str(params) log.info(f"verible-verilog-ls: {message}") self.server.on_request("client/registerCapability", do_nothing) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) self.server.on_notification("window/logMessage", on_log_message) log.info("Starting verible-verilog-ls process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.info("Sending initialize request") init_response = self.server.send.initialize(initialize_params) # Validate server capabilities (follows Gopls/Bash pattern) capabilities = init_response.get("capabilities", {}) log.info(f"Initialize response capabilities: {list(capabilities.keys())}") assert "textDocumentSync" in capabilities, "verible-verilog-ls must support textDocumentSync" if "documentSymbolProvider" not in capabilities: log.warning("verible-verilog-ls does not advertise documentSymbolProvider") if "definitionProvider" not in capabilities: log.warning("verible-verilog-ls does not advertise definitionProvider") self.server.notify.initialized({}) ================================================ FILE: src/solidlsp/language_servers/taplo_server.py ================================================ """ Provides TOML specific instantiation of the LanguageServer class using Taplo. Contains various configurations and settings specific to TOML files. """ import gzip import hashlib import logging import os import platform import shutil import socket import stat import urllib.request from typing import Any # Download timeout in seconds (prevents indefinite hangs) DOWNLOAD_TIMEOUT_SECONDS = 120 from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_utils import PathUtils from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) # Taplo release version and download URLs TAPLO_VERSION = "0.10.0" TAPLO_DOWNLOAD_BASE = f"https://github.com/tamasfe/taplo/releases/download/{TAPLO_VERSION}" # SHA256 checksums for Taplo releases (verified from official GitHub releases) # Source: https://github.com/tamasfe/taplo/releases/tag/0.10.0 # To update: download each release file and run: sha256sum TAPLO_SHA256_CHECKSUMS: dict[str, str] = { "taplo-windows-x86_64.zip": "1615eed140039bd58e7089109883b1c434de5d6de8f64a993e6e8c80ca57bdf9", "taplo-windows-x86.zip": "b825701daab10dcfc0251e6d668cd1a9c0e351e7f6762dd20844c3f3f3553aa0", "taplo-darwin-x86_64.gz": "898122cde3a0b1cd1cbc2d52d3624f23338218c91b5ddb71518236a4c2c10ef2", "taplo-darwin-aarch64.gz": "713734314c3e71894b9e77513c5349835eefbd52908445a0d73b0c7dc469347d", "taplo-linux-x86_64.gz": "8fe196b894ccf9072f98d4e1013a180306e17d244830b03986ee5e8eabeb6156", "taplo-linux-aarch64.gz": "033681d01eec8376c3fd38fa3703c79316f5e14bb013d859943b60a07bccdcc3", "taplo-linux-armv7.gz": "6b728896afe2573522f38b8e668b1ff40eb5928fd9d6d0c253ecae508274d417", } def _verify_sha256(file_path: str, expected_hash: str) -> bool: """Verify SHA256 checksum of a downloaded file.""" sha256_hash = hashlib.sha256() with open(file_path, "rb") as f: for chunk in iter(lambda: f.read(8192), b""): sha256_hash.update(chunk) actual_hash = sha256_hash.hexdigest() return actual_hash.lower() == expected_hash.lower() def _get_taplo_download_url() -> tuple[str, str]: """ Get the appropriate Taplo download URL for the current platform. Returns: Tuple of (download_url, executable_name) """ system = platform.system().lower() machine = platform.machine().lower() # Map machine architecture to Taplo naming convention arch_map = { "x86_64": "x86_64", "amd64": "x86_64", "x86": "x86", "i386": "x86", "i686": "x86", "aarch64": "aarch64", "arm64": "aarch64", "armv7l": "armv7", } arch = arch_map.get(machine, "x86_64") # Default to x86_64 if system == "windows": filename = f"taplo-windows-{arch}.zip" executable = "taplo.exe" elif system == "darwin": filename = f"taplo-darwin-{arch}.gz" executable = "taplo" else: # Linux and others filename = f"taplo-linux-{arch}.gz" executable = "taplo" return f"{TAPLO_DOWNLOAD_BASE}/{filename}", executable class TaploServer(SolidLanguageServer): """ Provides TOML specific instantiation of the LanguageServer class using Taplo. Taplo is a TOML toolkit with LSP support for validation, formatting, and schema support. """ @staticmethod def _determine_log_level(line: str) -> int: """Classify Taplo stderr output to avoid false-positive errors.""" line_lower = line.lower() # Known informational messages from Taplo if any( [ "schema" in line_lower and "not found" in line_lower, "warning" in line_lower, ] ): return logging.DEBUG return SolidLanguageServer._determine_log_level(line) def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a TaploServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ super().__init__( config, repository_root_path, None, "toml", solidlsp_settings, ) def _create_dependency_provider(self) -> LanguageServerDependencyProvider: return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) class DependencyProvider(LanguageServerDependencyProviderSinglePath): def _get_or_install_core_dependency(self) -> str: """ Setup runtime dependencies for Taplo and return the command to start the server. """ # First check if taplo is already installed system-wide system_taplo = shutil.which("taplo") if system_taplo: log.info(f"Using system-installed Taplo at: {system_taplo}") return system_taplo # Setup local installation directory taplo_dir = os.path.join(self._ls_resources_dir, "taplo") os.makedirs(taplo_dir, exist_ok=True) _, executable_name = _get_taplo_download_url() taplo_executable = os.path.join(taplo_dir, executable_name) if os.path.exists(taplo_executable) and os.access(taplo_executable, os.X_OK): log.info(f"Using cached Taplo at: {taplo_executable}") return taplo_executable # Download and install Taplo log.info(f"Taplo not found. Downloading version {TAPLO_VERSION}...") self._download_taplo(taplo_dir, taplo_executable) if not os.path.exists(taplo_executable): raise FileNotFoundError( f"Taplo executable not found at {taplo_executable}. " "Installation may have failed. Try installing manually: cargo install taplo-cli --locked" ) return taplo_executable def _create_launch_command(self, core_path: str) -> list[str]: return [core_path, "lsp", "stdio"] @classmethod def _download_taplo(cls, install_dir: str, executable_path: str) -> None: """Download and extract Taplo binary with SHA256 verification.""" # TODO: consider using existing download utilities in SolidLSP instead of the custom logic here download_url, _ = _get_taplo_download_url() archive_filename = os.path.basename(download_url) try: log.info(f"Downloading Taplo from: {download_url}") archive_path = os.path.join(install_dir, archive_filename) # Download the archive with timeout to prevent indefinite hangs old_timeout = socket.getdefaulttimeout() try: socket.setdefaulttimeout(DOWNLOAD_TIMEOUT_SECONDS) urllib.request.urlretrieve(download_url, archive_path) finally: socket.setdefaulttimeout(old_timeout) # Verify SHA256 checksum expected_hash = TAPLO_SHA256_CHECKSUMS.get(archive_filename) if expected_hash: if not _verify_sha256(archive_path, expected_hash): os.remove(archive_path) raise RuntimeError( f"SHA256 checksum verification failed for {archive_filename}. " "The downloaded file may be corrupted or tampered with. " "Try installing manually: cargo install taplo-cli --locked" ) log.info(f"SHA256 checksum verified for {archive_filename}") else: log.warning( f"No SHA256 checksum available for {archive_filename}. " "Skipping verification - consider installing manually: cargo install taplo-cli --locked" ) # Extract based on format if archive_path.endswith(".gz") and not archive_path.endswith(".tar.gz"): # Single file gzip with gzip.open(archive_path, "rb") as f_in: with open(executable_path, "wb") as f_out: f_out.write(f_in.read()) elif archive_path.endswith(".zip"): import zipfile with zipfile.ZipFile(archive_path, "r") as zip_ref: # Security: Validate paths to prevent zip slip vulnerability for member in zip_ref.namelist(): member_path = os.path.normpath(os.path.join(install_dir, member)) if not member_path.startswith(os.path.normpath(install_dir)): raise RuntimeError(f"Zip slip detected: {member} attempts to escape install directory") zip_ref.extractall(install_dir) # Make executable on Unix systems if os.name != "nt": os.chmod(executable_path, os.stat(executable_path).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) # Clean up archive os.remove(archive_path) log.info(f"Taplo installed successfully at: {executable_path}") except Exception as e: log.error(f"Failed to download Taplo: {e}") raise RuntimeError( f"Failed to download Taplo from {download_url}. Try installing manually: cargo install taplo-cli --locked" ) from e @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the Taplo Language Server. """ root_uri = PathUtils.path_to_uri(repository_absolute_path) initialize_params = { "locale": "en", "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, "definition": {"dynamicRegistration": True}, "references": {"dynamicRegistration": True}, "documentSymbol": { "dynamicRegistration": True, "hierarchicalDocumentSymbolSupport": True, "symbolKind": {"valueSet": list(range(1, 27))}, }, "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, "codeAction": {"dynamicRegistration": True}, }, "workspace": { "workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}, "symbol": {"dynamicRegistration": True}, }, }, "processId": os.getpid(), "rootPath": repository_absolute_path, "rootUri": root_uri, "workspaceFolders": [ { "uri": root_uri, "name": os.path.basename(repository_absolute_path), } ], } return initialize_params # type: ignore def _start_server(self) -> None: """ Starts the Taplo Language Server and initializes it. """ def register_capability_handler(params: Any) -> None: return def do_nothing(params: Any) -> None: return def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_notification("window/logMessage", window_log_message) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) log.info("Starting Taplo server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.info("Sending initialize request to Taplo server") init_response = self.server.send.initialize(initialize_params) log.debug(f"Received initialize response from Taplo: {init_response}") # Verify document symbol support capabilities = init_response.get("capabilities", {}) if capabilities.get("documentSymbolProvider"): log.info("Taplo server supports document symbols") else: log.warning("Taplo server may have limited document symbol support") self.server.notify.initialized({}) log.info("Taplo server initialization complete") def is_ignored_dirname(self, dirname: str) -> bool: """Define TOML-specific directories to ignore.""" return super().is_ignored_dirname(dirname) or dirname in ["target", ".cargo", "node_modules"] ================================================ FILE: src/solidlsp/language_servers/terraform_ls.py ================================================ import logging import os import shutil from typing import cast from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_utils import PathUtils, PlatformUtils from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings from .common import RuntimeDependency, RuntimeDependencyCollection log = logging.getLogger(__name__) class TerraformLS(SolidLanguageServer): """ Provides Terraform specific instantiation of the LanguageServer class using terraform-ls. """ @override def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in [".terraform", "terraform.tfstate.d"] @staticmethod def _determine_log_level(line: str) -> int: """Classify terraform-ls stderr output to avoid false-positive errors.""" line_lower = line.lower() # File discovery messages that are not actual errors if any( [ "discover.go:" in line_lower, "walker.go:" in line_lower, "walking of {file://" in line_lower, "bus: -> discover" in line_lower, ] ): return logging.DEBUG # Known informational messages from terraform-ls that contain "error" but aren't errors # Note: pattern match is flexible to handle file paths between keywords if any( [ "loading module metadata returned error:" in line_lower and "state not changed" in line_lower, "incoming notification for" in line_lower, ] ): return logging.DEBUG return SolidLanguageServer._determine_log_level(line) @staticmethod def _ensure_tf_command_available() -> None: log.debug("Starting terraform version detection...") # 1. Try to find terraform using shutil.which terraform_cmd = shutil.which("terraform") if terraform_cmd is not None: log.debug(f"Found terraform via shutil.which: {terraform_cmd}") return # TODO: is this needed? # 2. Fallback to TERRAFORM_CLI_PATH (set by hashicorp/setup-terraform action) if not terraform_cmd: terraform_cli_path = os.environ.get("TERRAFORM_CLI_PATH") if terraform_cli_path: log.debug(f"Trying TERRAFORM_CLI_PATH: {terraform_cli_path}") # TODO: use binary name from runtime dependencies if we keep this code if os.name == "nt": terraform_binary = os.path.join(terraform_cli_path, "terraform.exe") else: terraform_binary = os.path.join(terraform_cli_path, "terraform") if os.path.exists(terraform_binary): terraform_cmd = terraform_binary log.debug(f"Found terraform via TERRAFORM_CLI_PATH: {terraform_cmd}") return raise RuntimeError( "Terraform executable not found, please ensure Terraform is installed." "See https://developer.hashicorp.com/terraform/tutorials/aws-get-started/install-cli for instructions." ) @classmethod def _setup_runtime_dependencies(cls, solidlsp_settings: SolidLSPSettings) -> str: """ Setup runtime dependencies for terraform-ls. Downloads and installs terraform-ls if not already present. """ cls._ensure_tf_command_available() platform_id = PlatformUtils.get_platform_id() deps = RuntimeDependencyCollection( [ RuntimeDependency( id="TerraformLS", description="terraform-ls for macOS (ARM64)", url="https://releases.hashicorp.com/terraform-ls/0.36.5/terraform-ls_0.36.5_darwin_arm64.zip", platform_id="osx-arm64", archive_type="zip", binary_name="terraform-ls", ), RuntimeDependency( id="TerraformLS", description="terraform-ls for macOS (x64)", url="https://releases.hashicorp.com/terraform-ls/0.36.5/terraform-ls_0.36.5_darwin_amd64.zip", platform_id="osx-x64", archive_type="zip", binary_name="terraform-ls", ), RuntimeDependency( id="TerraformLS", description="terraform-ls for Linux (ARM64)", url="https://releases.hashicorp.com/terraform-ls/0.36.5/terraform-ls_0.36.5_linux_arm64.zip", platform_id="linux-arm64", archive_type="zip", binary_name="terraform-ls", ), RuntimeDependency( id="TerraformLS", description="terraform-ls for Linux (x64)", url="https://releases.hashicorp.com/terraform-ls/0.36.5/terraform-ls_0.36.5_linux_amd64.zip", platform_id="linux-x64", archive_type="zip", binary_name="terraform-ls", ), RuntimeDependency( id="TerraformLS", description="terraform-ls for Windows (x64)", url="https://releases.hashicorp.com/terraform-ls/0.36.5/terraform-ls_0.36.5_windows_amd64.zip", platform_id="win-x64", archive_type="zip", binary_name="terraform-ls.exe", ), ] ) dependency = deps.get_single_dep_for_current_platform() terraform_ls_executable_path = deps.binary_path(cls.ls_resources_dir(solidlsp_settings)) if not os.path.exists(terraform_ls_executable_path): log.info(f"Downloading terraform-ls from {dependency.url}") deps.install(cls.ls_resources_dir(solidlsp_settings)) assert os.path.exists(terraform_ls_executable_path), f"terraform-ls executable not found at {terraform_ls_executable_path}" # Make the executable file executable on Unix-like systems if platform_id.value != "win-x64": os.chmod(terraform_ls_executable_path, 0o755) return terraform_ls_executable_path def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a TerraformLS instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ terraform_ls_executable_path = self._setup_runtime_dependencies(solidlsp_settings) super().__init__( config, repository_root_path, ProcessLaunchInfo(cmd=f"{terraform_ls_executable_path} serve", cwd=repository_root_path), "terraform", solidlsp_settings, ) self.request_id = 0 @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the Terraform Language Server. """ root_uri = PathUtils.path_to_uri(repository_absolute_path) result = { "processId": os.getpid(), "locale": "en", "rootPath": repository_absolute_path, "rootUri": root_uri, "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, "definition": {"dynamicRegistration": True}, "documentSymbol": { "dynamicRegistration": True, "hierarchicalDocumentSymbolSupport": True, "symbolKind": {"valueSet": list(range(1, 27))}, }, }, "workspace": {"workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}}, }, "workspaceFolders": [ { "name": os.path.basename(repository_absolute_path), "uri": root_uri, } ], } return cast(InitializeParams, result) def _start_server(self) -> None: """Start terraform-ls server process""" def register_capability_handler(params: dict) -> None: return def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") def do_nothing(params: dict) -> None: return self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_notification("window/logMessage", window_log_message) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) log.info("Starting terraform-ls server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) # Verify server capabilities assert "textDocumentSync" in init_response["capabilities"] assert "completionProvider" in init_response["capabilities"] assert "definitionProvider" in init_response["capabilities"] self.server.notify.initialized({}) # terraform-ls server is typically ready immediately after initialization ================================================ FILE: src/solidlsp/language_servers/typescript_language_server.py ================================================ """ Provides TypeScript specific instantiation of the LanguageServer class. Contains various configurations and settings specific to TypeScript. """ import logging import os import pathlib import shutil import threading from typing import Any, cast from overrides import override from sensai.util.logging import LogTime from solidlsp import ls_types from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_utils import PlatformId, PlatformUtils from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings from .common import RuntimeDependency, RuntimeDependencyCollection log = logging.getLogger(__name__) # Platform-specific imports if os.name != "nt": # Unix-like systems import pwd else: # Dummy pwd module for Windows class pwd: # type: ignore @staticmethod def getpwuid(uid: Any) -> Any: return type("obj", (), {"pw_name": os.environ.get("USERNAME", "unknown")})() # Conditionally import pwd module (Unix-only) if not PlatformUtils.get_platform_id().value.startswith("win"): pass def prefer_non_node_modules_definition(definitions: list[ls_types.Location]) -> ls_types.Location: """ Select the preferred definition, preferring source files over type definitions. TypeScript language servers often return both type definitions (.d.ts files in node_modules) and source definitions. This function prefers: 1. Files not in node_modules 2. Falls back to first definition if all are in node_modules :param definitions: A non-empty list of definition locations. :return: The preferred definition location. """ for d in definitions: rel_path = d.get("relativePath", "") if rel_path and "node_modules" not in rel_path: return d return definitions[0] class TypeScriptLanguageServer(SolidLanguageServer): """ Provides TypeScript specific instantiation of the LanguageServer class. Contains various configurations and settings specific to TypeScript. You can pass the following entries in ls_specific_settings["typescript"]: - typescript_version: Version of TypeScript to install (default: "5.9.3") - typescript_language_server_version: Version of typescript-language-server to install (default: "5.1.3") """ # Safety timeout for $/progress-based indexing wait. Normally the event fires # well within this window; the timeout is only hit if the server never sends progress. INDEXING_PROGRESS_TIMEOUT = 15.0 if os.name == "nt" else 10.0 def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a TypeScriptLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ super().__init__( config, repository_root_path, None, "typescript", solidlsp_settings, ) self.server_ready = threading.Event() self.initialize_searcher_command_available = threading.Event() # Progress tracking for $/progress notifications (project indexing, etc.) self._progress_lock = threading.Lock() self._active_progress_tokens: set[str] = set() self._indexing_complete = threading.Event() self._indexing_complete.set() # Initially set (no active work) def wait_for_indexing(self, timeout: float) -> bool: """Block until all $/progress tokens complete. :param timeout: Maximum seconds to wait. :return: True if indexing completed, False on timeout. """ return self._indexing_complete.wait(timeout=timeout) def expect_indexing(self) -> None: """Signal that new files are about to be opened and async indexing should be awaited. Clears the internal indexing-complete event so that a subsequent :meth:`wait_for_indexing` call blocks until all $/progress tokens complete (or the timeout expires). """ self._indexing_complete.clear() def _create_dependency_provider(self) -> LanguageServerDependencyProvider: return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) @override def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in [ "node_modules", "dist", "build", "coverage", ] @staticmethod def _determine_log_level(line: str) -> int: """Classify typescript-language-server stderr output to avoid false-positive errors.""" return SolidLanguageServer._determine_log_level(line) class DependencyProvider(LanguageServerDependencyProviderSinglePath): def _get_or_install_core_dependency(self) -> str: """ Setup runtime dependencies for TypeScript Language Server and return the path to the executable. """ platform_id = PlatformUtils.get_platform_id() valid_platforms = [ PlatformId.LINUX_x64, PlatformId.LINUX_arm64, PlatformId.OSX, PlatformId.OSX_x64, PlatformId.OSX_arm64, PlatformId.WIN_x64, PlatformId.WIN_arm64, ] assert ( platform_id in valid_platforms ), f"Platform {platform_id} is not supported for multilspy javascript/typescript at the moment" # Get version settings from ls_specific_settings or use defaults language_specific_config = self._custom_settings typescript_version = language_specific_config.get("typescript_version", "5.9.3") typescript_language_server_version = language_specific_config.get("typescript_language_server_version", "5.1.3") deps = RuntimeDependencyCollection( [ RuntimeDependency( id="typescript", description="typescript package", command=["npm", "install", "--prefix", "./", f"typescript@{typescript_version}"], platform_id="any", ), RuntimeDependency( id="typescript-language-server", description="typescript-language-server package", command=["npm", "install", "--prefix", "./", f"typescript-language-server@{typescript_language_server_version}"], platform_id="any", ), ] ) # Verify both node and npm are installed is_node_installed = shutil.which("node") is not None assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." is_npm_installed = shutil.which("npm") is not None assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." # Install typescript and typescript-language-server if not already installed or version mismatch tsserver_ls_dir = os.path.join(self._ls_resources_dir, "ts-lsp") tsserver_executable_path = os.path.join(tsserver_ls_dir, "node_modules", ".bin", "typescript-language-server") # Check if installation is needed based on executable AND version version_file = os.path.join(tsserver_ls_dir, ".installed_version") expected_version = f"{typescript_version}_{typescript_language_server_version}" needs_install = False if not os.path.exists(tsserver_executable_path): log.info(f"Typescript Language Server executable not found at {tsserver_executable_path}.") needs_install = True elif os.path.exists(version_file): with open(version_file) as f: installed_version = f.read().strip() if installed_version != expected_version: log.info( f"TypeScript Language Server version mismatch: installed={installed_version}, expected={expected_version}. Reinstalling..." ) needs_install = True else: # No version file exists, assume old installation needs refresh log.info("TypeScript Language Server version file not found. Reinstalling to ensure correct version...") needs_install = True if needs_install: log.info("Installing TypeScript Language Server dependencies...") with LogTime("Installation of TypeScript language server dependencies", logger=log): deps.install(tsserver_ls_dir) # Write version marker file with open(version_file, "w") as f: f.write(expected_version) log.info("TypeScript language server dependencies installed successfully") if not os.path.exists(tsserver_executable_path): raise FileNotFoundError( f"typescript-language-server executable not found at {tsserver_executable_path}, something went wrong with the installation." ) return tsserver_executable_path def _create_launch_command(self, core_path: str) -> list[str]: return [core_path, "--stdio"] def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the TypeScript Language Server. """ root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, "definition": {"dynamicRegistration": True}, "references": {"dynamicRegistration": True}, "documentSymbol": { "dynamicRegistration": True, "hierarchicalDocumentSymbolSupport": True, "symbolKind": {"valueSet": list(range(1, 27))}, }, "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, "signatureHelp": {"dynamicRegistration": True}, "codeAction": {"dynamicRegistration": True}, "rename": {"dynamicRegistration": True, "prepareSupport": True}, }, "workspace": { "workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}, "symbol": {"dynamicRegistration": True}, }, "window": { "workDoneProgress": True, # Enables $/progress notifications for project loading }, }, "processId": os.getpid(), "rootPath": repository_absolute_path, "rootUri": root_uri, "workspaceFolders": [ { "uri": root_uri, "name": os.path.basename(repository_absolute_path), } ], } return cast(InitializeParams, initialize_params) def _start_server(self) -> None: """ Starts the TypeScript Language Server, waits for the server to be ready and yields the LanguageServer instance. Usage: ``` async with lsp.start_server(): # LanguageServer has been initialized and ready to serve requests await lsp.request_definition(...) await lsp.request_references(...) # Shutdown the LanguageServer on exit from scope # LanguageServer has been shutdown """ def register_capability_handler(params: dict) -> None: assert "registrations" in params for registration in params["registrations"]: if registration["method"] == "workspace/executeCommand": self.initialize_searcher_command_available.set() # TypeScript doesn't have a direct equivalent to resolve_main_method # You might want to set a different flag or remove this line # self.resolve_main_method_available.set() return def execute_client_command_handler(params: dict) -> list: return [] def do_nothing(params: dict) -> None: return def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") def handle_typescript_version(params: dict) -> None: """ The $/typescriptVersion notification is sent by typescript-language-server once tsserver has loaded and reported its version. This is a reliable signal that tsserver is running and responsive. """ log.info(f"TypeScript server version notification received: {params}") self.server_ready.set() def work_done_progress_create(params: dict) -> dict: """Handle window/workDoneProgress/create: the server is about to report async progress. Clear the indexing-complete event so callers waiting on it will block until all progress tokens finish. This is sent by typescript-language-server when tsserver starts processing files (e.g. "Initializing JS/TS language features..."). """ token = str(params.get("token", "")) log.debug(f"TypeScript LSP workDoneProgress/create: token={token!r}") with self._progress_lock: self._active_progress_tokens.add(token) self._indexing_complete.clear() return {} def progress_handler(params: dict) -> None: """Track $/progress begin/end to detect when all async work finishes. typescript-language-server sends $/progress for project loading operations like "Initializing JS/TS language features...". When all progress tokens complete (kind='end'), _indexing_complete is set. """ token = str(params.get("token", "")) value = params.get("value", {}) kind = value.get("kind") if kind == "begin": title = value.get("title", "") log.info(f"TypeScript LSP progress [{token}]: started - {title}") with self._progress_lock: self._active_progress_tokens.add(token) self._indexing_complete.clear() elif kind == "report": pct = value.get("percentage") msg = value.get("message", "") pct_str = f" ({pct}%)" if pct is not None else "" log.debug(f"TypeScript LSP progress [{token}]: {msg}{pct_str}") elif kind == "end": msg = value.get("message", "") log.info(f"TypeScript LSP progress [{token}]: ended - {msg}") with self._progress_lock: self._active_progress_tokens.discard(token) if not self._active_progress_tokens: self._indexing_complete.set() self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_notification("window/logMessage", window_log_message) self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) self.server.on_request("window/workDoneProgress/create", work_done_progress_create) self.server.on_notification("$/progress", progress_handler) self.server.on_notification("$/typescriptVersion", handle_typescript_version) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) log.info("Starting TypeScript server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.info( "Sending initialize request from LSP client to LSP server and awaiting response", ) init_response = self.server.send.initialize(initialize_params) # TypeScript-specific capability checks assert init_response["capabilities"]["textDocumentSync"] == 2 assert "completionProvider" in init_response["capabilities"] assert init_response["capabilities"]["completionProvider"] == { "triggerCharacters": [".", '"', "'", "/", "@", "<"], "resolveProvider": True, } self.server.notify.initialized({}) if self.server_ready.wait(timeout=10.0): log.info("TypeScript server is ready") else: log.info("Timeout waiting for TypeScript server to become ready, proceeding anyway") # Fallback: assume server is ready after timeout self.server_ready.set() # Wait for any async project loading to complete. # typescript-language-server may send $/progress for "Initializing JS/TS # language features…" after initialized. If no progress is sent, # _indexing_complete stays SET and wait() returns immediately. log.info("Waiting for TypeScript project indexing to complete (if async)...") if self.wait_for_indexing(timeout=self.INDEXING_PROGRESS_TIMEOUT): log.info("TypeScript project indexing complete") else: log.warning( "TypeScript project indexing did not complete within %.0fs; proceeding anyway", self.INDEXING_PROGRESS_TIMEOUT, ) @override def _get_wait_time_for_cross_file_referencing(self) -> float: return 2 @override def _get_preferred_definition(self, definitions: list[ls_types.Location]) -> ls_types.Location: return prefer_non_node_modules_definition(definitions) ================================================ FILE: src/solidlsp/language_servers/vts_language_server.py ================================================ """ Language Server implementation for TypeScript/JavaScript using https://github.com/yioneko/vtsls, which provides TypeScript language server functionality via VSCode's TypeScript extension (contrary to typescript-language-server, which uses the TypeScript compiler directly). """ import logging import os import pathlib import shutil import threading from typing import cast from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_utils import PlatformId, PlatformUtils from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings from .common import RuntimeDependency, RuntimeDependencyCollection log = logging.getLogger(__name__) class VtsLanguageServer(SolidLanguageServer): """ Provides TypeScript specific instantiation of the LanguageServer class using vtsls. Contains various configurations and settings specific to TypeScript via vtsls wrapper. """ def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a VtsLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ vts_lsp_executable_path = self._setup_runtime_dependencies(config, solidlsp_settings) super().__init__( config, repository_root_path, ProcessLaunchInfo(cmd=vts_lsp_executable_path, cwd=repository_root_path), "typescript", solidlsp_settings, ) self.server_ready = threading.Event() self.initialize_searcher_command_available = threading.Event() @override def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in [ "node_modules", "dist", "build", "coverage", ] @classmethod def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> str: """ Setup runtime dependencies for VTS Language Server and return the command to start the server. """ platform_id = PlatformUtils.get_platform_id() valid_platforms = [ PlatformId.LINUX_x64, PlatformId.LINUX_arm64, PlatformId.OSX, PlatformId.OSX_x64, PlatformId.OSX_arm64, PlatformId.WIN_x64, PlatformId.WIN_arm64, ] assert platform_id in valid_platforms, f"Platform {platform_id} is not supported for vtsls at the moment" deps = RuntimeDependencyCollection( [ RuntimeDependency( id="vtsls", description="vtsls language server package", command="npm install --prefix ./ @vtsls/language-server@0.2.9", platform_id="any", ), ] ) vts_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "vts-lsp") vts_executable_path = os.path.join(vts_ls_dir, "vtsls") # Verify both node and npm are installed is_node_installed = shutil.which("node") is not None assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." is_npm_installed = shutil.which("npm") is not None assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." # Install vtsls if not already installed if not os.path.exists(vts_ls_dir): os.makedirs(vts_ls_dir, exist_ok=True) deps.install(vts_ls_dir) vts_executable_path = os.path.join(vts_ls_dir, "node_modules", ".bin", "vtsls") assert os.path.exists(vts_executable_path), "vtsls executable not found. Please install @vtsls/language-server and try again." return f"{vts_executable_path} --stdio" @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the VTS Language Server. """ root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, "definition": {"dynamicRegistration": True}, "references": {"dynamicRegistration": True}, "documentSymbol": { "dynamicRegistration": True, "hierarchicalDocumentSymbolSupport": True, "symbolKind": {"valueSet": list(range(1, 27))}, }, "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, "signatureHelp": {"dynamicRegistration": True}, "codeAction": {"dynamicRegistration": True}, }, "workspace": { "workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}, "symbol": {"dynamicRegistration": True}, "configuration": True, # This might be needed for vtsls }, }, "processId": os.getpid(), "rootPath": repository_absolute_path, "rootUri": root_uri, "workspaceFolders": [ { "uri": root_uri, "name": os.path.basename(repository_absolute_path), } ], } return cast(InitializeParams, initialize_params) def _start_server(self) -> None: """ Starts the VTS Language Server, waits for the server to be ready and yields the LanguageServer instance. Usage: ``` async with lsp.start_server(): # LanguageServer has been initialized and ready to serve requests await lsp.request_definition(...) await lsp.request_references(...) # Shutdown the LanguageServer on exit from scope # LanguageServer has been shutdown """ def register_capability_handler(params: dict) -> None: assert "registrations" in params for registration in params["registrations"]: if registration["method"] == "workspace/executeCommand": self.initialize_searcher_command_available.set() return def execute_client_command_handler(params: dict) -> list: return [] def workspace_configuration_handler(params: dict) -> list[dict] | dict: # VTS may request workspace configuration # Return empty configuration for each requested item if "items" in params: return [{}] * len(params["items"]) return {} def do_nothing(params: dict) -> None: return def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") def check_experimental_status(params: dict) -> None: """ Also listen for experimental/serverStatus as a backup signal """ if params.get("quiescent") is True: self.server_ready.set() self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_notification("window/logMessage", window_log_message) self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) self.server.on_request("workspace/configuration", workspace_configuration_handler) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) self.server.on_notification("experimental/serverStatus", check_experimental_status) log.info("Starting VTS server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) # VTS-specific capability checks # Be more flexible with capabilities since vtsls might have different structure log.debug(f"VTS init response capabilities: {init_response['capabilities']}") # Basic checks to ensure essential capabilities are present assert "textDocumentSync" in init_response["capabilities"] assert "completionProvider" in init_response["capabilities"] # Log the actual values for debugging log.debug(f"textDocumentSync: {init_response['capabilities']['textDocumentSync']}") log.debug(f"completionProvider: {init_response['capabilities']['completionProvider']}") self.server.notify.initialized({}) if self.server_ready.wait(timeout=1.0): log.info("VTS server is ready") else: log.info("Timeout waiting for VTS server to become ready, proceeding anyway") # Fallback: assume server is ready after timeout self.server_ready.set() @override def _get_wait_time_for_cross_file_referencing(self) -> float: return 1 ================================================ FILE: src/solidlsp/language_servers/vue_language_server.py ================================================ """ Vue Language Server implementation using @vue/language-server (Volar) with companion TypeScript LS. Operates in hybrid mode: Vue LS handles .vue files, TypeScript LS handles .ts/.js files. """ import logging import os import pathlib import shutil import threading from pathlib import Path from time import sleep from typing import Any from overrides import override from solidlsp import ls_types from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection from solidlsp.language_servers.typescript_language_server import ( TypeScriptLanguageServer, prefer_non_node_modules_definition, ) from solidlsp.ls import LSPFileBuffer, SolidLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.ls_exceptions import SolidLSPException from solidlsp.ls_types import Location from solidlsp.ls_utils import PathUtils from solidlsp.lsp_protocol_handler import lsp_types from solidlsp.lsp_protocol_handler.lsp_types import DocumentSymbol, ExecuteCommandParams, InitializeParams, SymbolInformation from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) class VueTypeScriptServer(TypeScriptLanguageServer): """TypeScript LS configured with @vue/typescript-plugin for Vue file support.""" @classmethod @override def get_language_enum_instance(cls) -> Language: """Return TYPESCRIPT since this is a TypeScript language server variant. Note: VueTypeScriptServer is a companion server that uses TypeScript's language server with the Vue TypeScript plugin. It reports as TYPESCRIPT to maintain compatibility with the TypeScript language server infrastructure. """ return Language.TYPESCRIPT class DependencyProvider(TypeScriptLanguageServer.DependencyProvider): override_ts_ls_executable: str | None = None def _get_or_install_core_dependency(self) -> str: if self.override_ts_ls_executable is not None: return self.override_ts_ls_executable return super()._get_or_install_core_dependency() @override def _get_language_id_for_file(self, relative_file_path: str) -> str: """Return the correct language ID for files. Vue files must be opened with language ID "vue" for the @vue/typescript-plugin to process them correctly. The plugin is configured with "languages": ["vue"] in the initialization options. """ ext = os.path.splitext(relative_file_path)[1].lower() if ext == ".vue": return "vue" elif ext in (".ts", ".tsx", ".mts", ".cts"): return "typescript" elif ext in (".js", ".jsx", ".mjs", ".cjs"): return "javascript" else: return "typescript" def __init__( self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings, vue_plugin_path: str, tsdk_path: str, ts_ls_executable_path: str, ): self._vue_plugin_path = vue_plugin_path self._custom_tsdk_path = tsdk_path VueTypeScriptServer.DependencyProvider.override_ts_ls_executable = ts_ls_executable_path super().__init__(config, repository_root_path, solidlsp_settings) VueTypeScriptServer.DependencyProvider.override_ts_ls_executable = None @override def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: params = super()._get_initialize_params(repository_absolute_path) params["initializationOptions"] = { "plugins": [ { "name": "@vue/typescript-plugin", "location": self._vue_plugin_path, "languages": ["vue"], } ], "tsserver": { "path": self._custom_tsdk_path, }, } if "workspace" in params["capabilities"]: params["capabilities"]["workspace"]["executeCommand"] = {"dynamicRegistration": True} return params @override def _start_server(self) -> None: def workspace_configuration_handler(params: dict) -> list: items = params.get("items", []) return [{} for _ in items] self.server.on_request("workspace/configuration", workspace_configuration_handler) super()._start_server() class VueLanguageServer(SolidLanguageServer): """ Language server for Vue Single File Components using @vue/language-server (Volar) with companion TypeScript LS. You can pass the following entries in ls_specific_settings["vue"]: - vue_language_server_version: Version of @vue/language-server to install (default: "3.1.5") Note: TypeScript versions are configured via ls_specific_settings["typescript"]: - typescript_version: Version of TypeScript to install (default: "5.9.3") - typescript_language_server_version: Version of typescript-language-server to install (default: "5.1.3") """ TS_SERVER_READY_TIMEOUT = 5.0 VUE_SERVER_READY_TIMEOUT = 3.0 def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): vue_lsp_executable_path, self.tsdk_path, self._ts_ls_cmd = self._setup_runtime_dependencies(config, solidlsp_settings) self._vue_ls_dir = os.path.join(self.ls_resources_dir(solidlsp_settings), "vue-lsp") super().__init__( config, repository_root_path, ProcessLaunchInfo(cmd=vue_lsp_executable_path, cwd=repository_root_path), "vue", solidlsp_settings, ) self.server_ready = threading.Event() self.initialize_searcher_command_available = threading.Event() self._ts_server: VueTypeScriptServer | None = None self._ts_server_started = False self._vue_files_indexed = False self._indexed_vue_file_uris: list[str] = [] @override def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in [ "node_modules", "dist", "build", "coverage", ".nuxt", ".output", ] @override def _get_language_id_for_file(self, relative_file_path: str) -> str: ext = os.path.splitext(relative_file_path)[1].lower() if ext == ".vue": return "vue" elif ext in (".ts", ".tsx", ".mts", ".cts"): return "typescript" elif ext in (".js", ".jsx", ".mjs", ".cjs"): return "javascript" else: return "vue" def _is_typescript_file(self, file_path: str) -> bool: ext = os.path.splitext(file_path)[1].lower() return ext in (".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs") def _find_all_vue_files(self) -> list[str]: vue_files = [] repo_path = Path(self.repository_root_path) for vue_file in repo_path.rglob("*.vue"): try: relative_path = str(vue_file.relative_to(repo_path)) if "node_modules" not in relative_path and not relative_path.startswith("."): vue_files.append(relative_path) except Exception as e: log.debug(f"Error processing Vue file {vue_file}: {e}") return vue_files def _ensure_vue_files_indexed_on_ts_server(self) -> None: if self._vue_files_indexed: return assert self._ts_server is not None log.info("Indexing .vue files on TypeScript server for cross-file references") vue_files = self._find_all_vue_files() log.debug(f"Found {len(vue_files)} .vue files to index") # Prepare the TS server to track new $/progress notifications triggered # by the didOpen calls below. Must happen BEFORE opening files to avoid # a race where progress begins and ends before we start waiting. self._ts_server.expect_indexing() for vue_file in vue_files: try: with self._ts_server.open_file(vue_file) as file_buffer: file_buffer.ref_count += 1 self._indexed_vue_file_uris.append(file_buffer.uri) except Exception as e: log.debug(f"Failed to open {vue_file} on TS server: {e}") self._vue_files_indexed = True log.info("Vue file indexing on TypeScript server complete, waiting for TS server to finish processing") self._wait_for_ts_indexing_complete() def _wait_for_ts_indexing_complete(self) -> None: """Wait for the companion TypeScript server to finish processing opened Vue files. Uses the $/progress tracking in TypeScriptLanguageServer: after Vue files are opened, tsserver sends "Initializing JS/TS language features…" progress. We wait for all progress tokens to complete, with a timeout fallback. """ assert self._ts_server is not None timeout = TypeScriptLanguageServer.INDEXING_PROGRESS_TIMEOUT if self._ts_server.wait_for_indexing(timeout=timeout): log.info("TypeScript server finished indexing Vue files (signaled via $/progress)") else: log.warning(f"Timeout ({timeout}s) waiting for TypeScript server to finish indexing Vue files, proceeding anyway") def _send_references_request(self, relative_file_path: str, line: int, column: int) -> list[lsp_types.Location] | None: uri = PathUtils.path_to_uri(os.path.join(self.repository_root_path, relative_file_path)) request_params = { "textDocument": {"uri": uri}, "position": {"line": line, "character": column}, "context": {"includeDeclaration": False}, } return self.server.send.references(request_params) # type: ignore[arg-type] def _send_ts_references_request(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]: assert self._ts_server is not None uri = PathUtils.path_to_uri(os.path.join(self.repository_root_path, relative_file_path)) request_params = { "textDocument": {"uri": uri}, "position": {"line": line, "character": column}, "context": {"includeDeclaration": True}, } with self._ts_server.open_file(relative_file_path): response = self._ts_server.handler.send.references(request_params) # type: ignore[arg-type] result: list[ls_types.Location] = [] if response is not None: for item in response: abs_path = PathUtils.uri_to_path(item["uri"]) if not Path(abs_path).is_relative_to(self.repository_root_path): log.debug(f"Found reference outside repository: {abs_path}, skipping") continue rel_path = Path(abs_path).relative_to(self.repository_root_path) if self.is_ignored_path(str(rel_path)): log.debug(f"Ignoring reference in {rel_path}") continue new_item: dict = {} new_item.update(item) # type: ignore[arg-type] new_item["absolutePath"] = str(abs_path) new_item["relativePath"] = str(rel_path) result.append(ls_types.Location(**new_item)) # type: ignore return result def request_file_references(self, relative_file_path: str) -> list: if not self.server_started: log.error("request_file_references called before Language Server started") raise SolidLSPException("Language Server not started") absolute_file_path = os.path.join(self.repository_root_path, relative_file_path) uri = PathUtils.path_to_uri(absolute_file_path) request_params = {"textDocument": {"uri": uri}} log.info(f"Sending volar/client/findFileReference request for {relative_file_path}") log.info(f"Request URI: {uri}") log.info(f"Request params: {request_params}") try: with self.open_file(relative_file_path): log.debug(f"Sending volar/client/findFileReference for {relative_file_path}") log.debug(f"Request params: {request_params}") response = self.server.send_request("volar/client/findFileReference", request_params) log.debug(f"Received response type: {type(response)}") log.info(f"Received file references response: {response}") log.info(f"Response type: {type(response)}") if response is None: log.debug(f"No file references found for {relative_file_path}") return [] # Response should be an array of Location objects if not isinstance(response, list): log.warning(f"Unexpected response format from volar/client/findFileReference: {type(response)}") return [] ret: list[Location] = [] for item in response: if not isinstance(item, dict) or "uri" not in item: log.debug(f"Skipping invalid location item: {item}") continue abs_path = PathUtils.uri_to_path(item["uri"]) # type: ignore[arg-type] if not Path(abs_path).is_relative_to(self.repository_root_path): log.warning(f"Found file reference outside repository: {abs_path}, skipping") continue rel_path = Path(abs_path).relative_to(self.repository_root_path) if self.is_ignored_path(str(rel_path)): log.debug(f"Ignoring file reference in {rel_path}") continue new_item: dict = {} new_item.update(item) # type: ignore[arg-type] new_item["absolutePath"] = str(abs_path) new_item["relativePath"] = str(rel_path) ret.append(Location(**new_item)) # type: ignore log.debug(f"Found {len(ret)} file references for {relative_file_path}") return ret except Exception as e: log.warning(f"Error requesting file references for {relative_file_path}: {e}") return [] @override def request_references(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]: if not self.server_started: log.error("request_references called before Language Server started") raise SolidLSPException("Language Server not started") if not self._has_waited_for_cross_file_references: sleep(self._get_wait_time_for_cross_file_referencing()) self._has_waited_for_cross_file_references = True self._ensure_vue_files_indexed_on_ts_server() symbol_refs = self._send_ts_references_request(relative_file_path, line=line, column=column) if relative_file_path.endswith(".vue"): log.info(f"Attempting to find file-level references for Vue component {relative_file_path}") file_refs = self.request_file_references(relative_file_path) log.info(f"file_refs result: {len(file_refs)} references found") seen = set() for ref in symbol_refs: key = (ref["uri"], ref["range"]["start"]["line"], ref["range"]["start"]["character"]) seen.add(key) for file_ref in file_refs: key = (file_ref["uri"], file_ref["range"]["start"]["line"], file_ref["range"]["start"]["character"]) if key not in seen: symbol_refs.append(file_ref) seen.add(key) log.info(f"Total references for {relative_file_path}: {len(symbol_refs)} (symbol refs + file refs, deduplicated)") return symbol_refs @override def request_definition(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]: if not self.server_started: log.error("request_definition called before Language Server started") raise SolidLSPException("Language Server not started") assert self._ts_server is not None with self._ts_server.open_file(relative_file_path): return self._ts_server.request_definition(relative_file_path, line, column) @override def request_rename_symbol_edit(self, relative_file_path: str, line: int, column: int, new_name: str) -> ls_types.WorkspaceEdit | None: if not self.server_started: log.error("request_rename_symbol_edit called before Language Server started") raise SolidLSPException("Language Server not started") assert self._ts_server is not None with self._ts_server.open_file(relative_file_path): return self._ts_server.request_rename_symbol_edit(relative_file_path, line, column, new_name) @classmethod def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> tuple[list[str], str, str]: is_node_installed = shutil.which("node") is not None assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." is_npm_installed = shutil.which("npm") is not None assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." # Get TypeScript version settings from TypeScript language server settings typescript_config = solidlsp_settings.get_ls_specific_settings(Language.TYPESCRIPT) typescript_version = typescript_config.get("typescript_version", "5.9.3") typescript_language_server_version = typescript_config.get("typescript_language_server_version", "5.1.3") vue_config = solidlsp_settings.get_ls_specific_settings(Language.VUE) vue_language_server_version = vue_config.get("vue_language_server_version", "3.1.5") deps = RuntimeDependencyCollection( [ RuntimeDependency( id="vue-language-server", description="Vue language server package (Volar)", command=["npm", "install", "--prefix", "./", f"@vue/language-server@{vue_language_server_version}"], platform_id="any", ), RuntimeDependency( id="typescript", description="TypeScript (required for tsdk)", command=["npm", "install", "--prefix", "./", f"typescript@{typescript_version}"], platform_id="any", ), RuntimeDependency( id="typescript-language-server", description="TypeScript language server (for Vue LS 3.x tsserver forwarding)", command=[ "npm", "install", "--prefix", "./", f"typescript-language-server@{typescript_language_server_version}", ], platform_id="any", ), ] ) vue_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "vue-lsp") vue_executable_path = os.path.join(vue_ls_dir, "node_modules", ".bin", "vue-language-server") ts_ls_executable_path = os.path.join(vue_ls_dir, "node_modules", ".bin", "typescript-language-server") if os.name == "nt": vue_executable_path += ".cmd" ts_ls_executable_path += ".cmd" tsdk_path = os.path.join(vue_ls_dir, "node_modules", "typescript", "lib") # Check if installation is needed based on executables AND version version_file = os.path.join(vue_ls_dir, ".installed_version") expected_version = f"{vue_language_server_version}_{typescript_version}_{typescript_language_server_version}" needs_install = False if not os.path.exists(vue_executable_path) or not os.path.exists(ts_ls_executable_path): log.info("Vue/TypeScript Language Server executables not found.") needs_install = True elif os.path.exists(version_file): with open(version_file) as f: installed_version = f.read().strip() if installed_version != expected_version: log.info( f"Vue Language Server version mismatch: installed={installed_version}, expected={expected_version}. Reinstalling..." ) needs_install = True else: # No version file exists, assume old installation needs refresh log.info("Vue Language Server version file not found. Reinstalling to ensure correct version...") needs_install = True if needs_install: log.info("Installing Vue/TypeScript Language Server dependencies...") deps.install(vue_ls_dir) # Write version marker file with open(version_file, "w") as f: f.write(expected_version) log.info("Vue language server dependencies installed successfully") if not os.path.exists(vue_executable_path): raise FileNotFoundError( f"vue-language-server executable not found at {vue_executable_path}, something went wrong with the installation." ) if not os.path.exists(ts_ls_executable_path): raise FileNotFoundError( f"typescript-language-server executable not found at {ts_ls_executable_path}, something went wrong with the installation." ) return [vue_executable_path, "--stdio"], tsdk_path, ts_ls_executable_path def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, "definition": {"dynamicRegistration": True, "linkSupport": True}, "references": {"dynamicRegistration": True}, "documentSymbol": { "dynamicRegistration": True, "hierarchicalDocumentSymbolSupport": True, "symbolKind": {"valueSet": list(range(1, 27))}, }, "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, "signatureHelp": {"dynamicRegistration": True}, "codeAction": {"dynamicRegistration": True}, "rename": {"dynamicRegistration": True, "prepareSupport": True}, }, "workspace": { "workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}, "symbol": {"dynamicRegistration": True}, }, }, "processId": os.getpid(), "rootPath": repository_absolute_path, "rootUri": root_uri, "workspaceFolders": [ { "uri": root_uri, "name": os.path.basename(repository_absolute_path), } ], "initializationOptions": { "vue": { "hybridMode": True, }, "typescript": { "tsdk": self.tsdk_path, }, }, } return initialize_params # type: ignore def _start_typescript_server(self) -> None: try: vue_ts_plugin_path = os.path.join(self._vue_ls_dir, "node_modules", "@vue", "typescript-plugin") ts_config = LanguageServerConfig( code_language=Language.TYPESCRIPT, trace_lsp_communication=False, ) log.info("Creating companion VueTypeScriptServer") self._ts_server = VueTypeScriptServer( config=ts_config, repository_root_path=self.repository_root_path, solidlsp_settings=self._solidlsp_settings, vue_plugin_path=vue_ts_plugin_path, tsdk_path=self.tsdk_path, ts_ls_executable_path=self._ts_ls_cmd, ) log.info("Starting companion TypeScript server") self._ts_server.start() log.info("Waiting for companion TypeScript server to be ready...") if not self._ts_server.server_ready.wait(timeout=self.TS_SERVER_READY_TIMEOUT): log.warning( f"Timeout waiting for companion TypeScript server to be ready after {self.TS_SERVER_READY_TIMEOUT} seconds, proceeding anyway" ) self._ts_server.server_ready.set() self._ts_server_started = True log.info("Companion TypeScript server ready") except Exception as e: log.error(f"Error starting TypeScript server: {e}") self._ts_server = None self._ts_server_started = False raise def _forward_tsserver_request(self, method: str, params: dict) -> Any: if self._ts_server is None: log.error("Cannot forward tsserver request - TypeScript server not started") return None try: execute_params: ExecuteCommandParams = { "command": "typescript.tsserverRequest", "arguments": [method, params, {"isAsync": True, "lowPriority": True}], } result = self._ts_server.handler.send.execute_command(execute_params) log.debug(f"TypeScript server raw response for {method}: {result}") if isinstance(result, dict) and "body" in result: return result["body"] return result except Exception as e: log.error(f"Error forwarding tsserver request {method}: {e}") return None def _cleanup_indexed_vue_files(self) -> None: if not self._indexed_vue_file_uris or self._ts_server is None: return log.debug(f"Cleaning up {len(self._indexed_vue_file_uris)} indexed Vue files") for uri in self._indexed_vue_file_uris: try: if uri in self._ts_server.open_file_buffers: file_buffer = self._ts_server.open_file_buffers[uri] file_buffer.ref_count -= 1 if file_buffer.ref_count == 0: self._ts_server.server.notify.did_close_text_document({"textDocument": {"uri": uri}}) del self._ts_server.open_file_buffers[uri] log.debug(f"Closed indexed Vue file: {uri}") except Exception as e: log.debug(f"Error closing indexed Vue file {uri}: {e}") self._indexed_vue_file_uris.clear() def _stop_typescript_server(self) -> None: if self._ts_server is not None: try: log.info("Stopping companion TypeScript server") self._ts_server.stop() except Exception as e: log.warning(f"Error stopping TypeScript server: {e}") finally: self._ts_server = None self._ts_server_started = False @override def _start_server(self) -> None: self._start_typescript_server() def register_capability_handler(params: dict) -> None: assert "registrations" in params for registration in params["registrations"]: if registration["method"] == "workspace/executeCommand": self.initialize_searcher_command_available.set() return def configuration_handler(params: dict) -> list: items = params.get("items", []) return [{} for _ in items] def do_nothing(params: dict) -> None: return def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") message_text = msg.get("message", "") if "initialized" in message_text.lower() or "ready" in message_text.lower(): log.info("Vue language server ready signal detected") self.server_ready.set() def tsserver_request_notification_handler(params: list) -> None: try: if params and len(params) > 0 and len(params[0]) >= 2: request_id = params[0][0] method = params[0][1] method_params = params[0][2] if len(params[0]) > 2 else {} log.debug(f"Received tsserver/request: id={request_id}, method={method}") if method == "_vue:projectInfo": file_path = method_params.get("file", "") tsconfig_path = self._find_tsconfig_for_file(file_path) result = {"configFileName": tsconfig_path} if tsconfig_path else None response = [[request_id, result]] self.server.notify.send_notification("tsserver/response", response) log.debug(f"Sent tsserver/response for projectInfo: {tsconfig_path}") else: result = self._forward_tsserver_request(method, method_params) response = [[request_id, result]] self.server.notify.send_notification("tsserver/response", response) log.debug(f"Forwarded tsserver/response for {method}: {result}") else: log.warning(f"Unexpected tsserver/request params format: {params}") except Exception as e: log.error(f"Error handling tsserver/request: {e}") self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_request("workspace/configuration", configuration_handler) self.server.on_notification("tsserver/request", tsserver_request_notification_handler) self.server.on_notification("window/logMessage", window_log_message) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) log.info("Starting Vue server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) log.debug(f"Received initialize response from Vue server: {init_response}") assert init_response["capabilities"]["textDocumentSync"] in [1, 2] self.server.notify.initialized({}) log.info("Waiting for Vue language server to be ready...") if not self.server_ready.wait(timeout=self.VUE_SERVER_READY_TIMEOUT): log.info("Timeout waiting for Vue server ready signal, proceeding anyway") self.server_ready.set() else: log.info("Vue server initialization complete") def _find_tsconfig_for_file(self, file_path: str) -> str | None: if not file_path: tsconfig_path = os.path.join(self.repository_root_path, "tsconfig.json") return tsconfig_path if os.path.exists(tsconfig_path) else None current_dir = os.path.dirname(file_path) repo_root = os.path.abspath(self.repository_root_path) while current_dir and current_dir.startswith(repo_root): tsconfig_path = os.path.join(current_dir, "tsconfig.json") if os.path.exists(tsconfig_path): return tsconfig_path parent = os.path.dirname(current_dir) if parent == current_dir: break current_dir = parent tsconfig_path = os.path.join(repo_root, "tsconfig.json") return tsconfig_path if os.path.exists(tsconfig_path) else None @override def _get_wait_time_for_cross_file_referencing(self) -> float: return 5.0 @override def stop(self, shutdown_timeout: float = 5.0) -> None: self._cleanup_indexed_vue_files() self._stop_typescript_server() super().stop(shutdown_timeout) @override def _get_preferred_definition(self, definitions: list[ls_types.Location]) -> ls_types.Location: return prefer_non_node_modules_definition(definitions) @override def _request_document_symbols( self, relative_file_path: str, file_data: LSPFileBuffer | None ) -> list[SymbolInformation] | list[DocumentSymbol] | None: """ Override to filter out shorthand property references in Vue files. In Vue, when using shorthand syntax in defineExpose like `defineExpose({ pressCount })`, the Vue LSP returns both: - The Variable definition (e.g., `const pressCount = ref(0)`) - A Property symbol for the shorthand reference (e.g., `pressCount` in defineExpose) This causes duplicate symbols with the same name, which breaks symbol lookup. We filter out Property symbols that have a matching Variable with the same name at a different location (the definition), keeping only the definition. """ symbols = super()._request_document_symbols(relative_file_path, file_data) if symbols is None or len(symbols) == 0: return symbols # Only process DocumentSymbol format (hierarchical symbols with children) # SymbolInformation format doesn't have the same issue if not isinstance(symbols[0], dict) or "range" not in symbols[0]: return symbols return self._filter_shorthand_property_duplicates(symbols) def _filter_shorthand_property_duplicates( self, symbols: list[DocumentSymbol] | list[SymbolInformation] ) -> list[DocumentSymbol] | list[SymbolInformation]: """ Filter out Property symbols that have a matching Variable symbol with the same name. This handles Vue's shorthand property syntax in defineExpose, where the same identifier appears as both a Variable definition and a Property reference. """ VARIABLE_KIND = 13 # SymbolKind.Variable PROPERTY_KIND = 7 # SymbolKind.Property def filter_symbols(syms: list[dict]) -> list[dict]: # Collect all Variable symbol names with their line numbers variable_names: dict[str, set[int]] = {} for sym in syms: if sym.get("kind") == VARIABLE_KIND: name = sym.get("name", "") line = sym.get("range", {}).get("start", {}).get("line", -1) if name not in variable_names: variable_names[name] = set() variable_names[name].add(line) # Filter: keep symbols that are either: # 1. Not a Property, or # 2. A Property without a matching Variable name at a different location filtered = [] for sym in syms: name = sym.get("name", "") kind = sym.get("kind") line = sym.get("range", {}).get("start", {}).get("line", -1) # If it's a Property with a matching Variable name at a DIFFERENT line, skip it if kind == PROPERTY_KIND and name in variable_names: # Check if there's a Variable definition at a different line var_lines = variable_names[name] if any(var_line != line for var_line in var_lines): # This is a shorthand reference, skip it log.debug( f"Filtering shorthand property reference '{name}' at line {line} " f"(Variable definition exists at line(s) {var_lines})" ) continue # Recursively filter children children = sym.get("children", []) if children: sym = dict(sym) # Create a copy to avoid mutating the original sym["children"] = filter_symbols(children) filtered.append(sym) return filtered return filter_symbols(list(symbols)) # type: ignore ================================================ FILE: src/solidlsp/language_servers/yaml_language_server.py ================================================ """ Provides YAML specific instantiation of the LanguageServer class using yaml-language-server. Contains various configurations and settings specific to YAML files. """ import logging import os import pathlib import shutil from typing import Any from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) class YamlLanguageServer(SolidLanguageServer): """ Provides YAML specific instantiation of the LanguageServer class using yaml-language-server. Contains various configurations and settings specific to YAML files. """ @staticmethod def _determine_log_level(line: str) -> int: """Classify yaml-language-server stderr output to avoid false-positive errors.""" line_lower = line.lower() # Known informational messages from yaml-language-server that aren't critical errors if any( [ "cannot find module" in line_lower and "package.json" in line_lower, # Schema resolution - not critical "no parser" in line_lower, # Parser messages - informational ] ): return logging.DEBUG return SolidLanguageServer._determine_log_level(line) def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a YamlLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ super().__init__( config, repository_root_path, None, "yaml", solidlsp_settings, ) def _create_dependency_provider(self) -> LanguageServerDependencyProvider: return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) class DependencyProvider(LanguageServerDependencyProviderSinglePath): def _get_or_install_core_dependency(self) -> str: """ Setup runtime dependencies for YAML Language Server and return the command to start the server. """ # Verify both node and npm are installed is_node_installed = shutil.which("node") is not None assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." is_npm_installed = shutil.which("npm") is not None assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." deps = RuntimeDependencyCollection( [ RuntimeDependency( id="yaml-language-server", description="yaml-language-server package (Red Hat)", command="npm install --prefix ./ yaml-language-server@1.19.2", platform_id="any", ), ] ) # Install yaml-language-server if not already installed yaml_ls_dir = os.path.join(self._ls_resources_dir, "yaml-lsp") yaml_executable_path = os.path.join(yaml_ls_dir, "node_modules", ".bin", "yaml-language-server") # Handle Windows executable extension if os.name == "nt": yaml_executable_path += ".cmd" if not os.path.exists(yaml_executable_path): log.info(f"YAML Language Server executable not found at {yaml_executable_path}. Installing...") deps.install(yaml_ls_dir) log.info("YAML language server dependencies installed successfully") if not os.path.exists(yaml_executable_path): raise FileNotFoundError( f"yaml-language-server executable not found at {yaml_executable_path}, something went wrong with the installation." ) return yaml_executable_path def _create_launch_command(self, core_path: str) -> list[str]: return [core_path, "--stdio"] @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the YAML Language Server. """ root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, "definition": {"dynamicRegistration": True}, "references": {"dynamicRegistration": True}, "documentSymbol": { "dynamicRegistration": True, "hierarchicalDocumentSymbolSupport": True, "symbolKind": {"valueSet": list(range(1, 27))}, }, "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, "codeAction": {"dynamicRegistration": True}, }, "workspace": { "workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}, "symbol": {"dynamicRegistration": True}, }, }, "processId": os.getpid(), "rootPath": repository_absolute_path, "rootUri": root_uri, "workspaceFolders": [ { "uri": root_uri, "name": os.path.basename(repository_absolute_path), } ], "initializationOptions": { "yaml": { "schemaStore": {"enable": True, "url": "https://www.schemastore.org/api/json/catalog.json"}, "format": {"enable": True}, "validate": True, "hover": True, "completion": True, } }, } return initialize_params # type: ignore def _start_server(self) -> None: """ Starts the YAML Language Server, waits for the server to be ready and yields the LanguageServer instance. """ def register_capability_handler(params: Any) -> None: return def do_nothing(params: Any) -> None: return def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_notification("window/logMessage", window_log_message) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) log.info("Starting YAML server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) log.debug(f"Received initialize response from YAML server: {init_response}") # Verify document symbol support is available if "documentSymbolProvider" in init_response["capabilities"]: log.info("YAML server supports document symbols") else: log.warning("Warning: YAML server does not report document symbol support") self.server.notify.initialized({}) # YAML language server is ready immediately after initialization log.info("YAML server initialization complete") ================================================ FILE: src/solidlsp/language_servers/zls.py ================================================ """ Provides Zig specific instantiation of the LanguageServer class using ZLS (Zig Language Server). """ import logging import os import pathlib import platform import shutil import subprocess from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) class ZigLanguageServer(SolidLanguageServer): """ Provides Zig specific instantiation of the LanguageServer class using ZLS. """ @override def is_ignored_dirname(self, dirname: str) -> bool: # For Zig projects, we should ignore: # - zig-cache: build cache directory # - zig-out: default build output directory # - .zig-cache: alternative cache location # - node_modules: if the project has JavaScript components return super().is_ignored_dirname(dirname) or dirname in ["zig-cache", "zig-out", ".zig-cache", "node_modules", "build", "dist"] @staticmethod def _get_zig_version() -> str | None: """Get the installed Zig version or None if not found.""" try: result = subprocess.run(["zig", "version"], capture_output=True, text=True, check=False) if result.returncode == 0: return result.stdout.strip() except FileNotFoundError: return None return None @staticmethod def _get_zls_version() -> str | None: """Get the installed ZLS version or None if not found.""" try: result = subprocess.run(["zls", "--version"], capture_output=True, text=True, check=False) if result.returncode == 0: return result.stdout.strip() except FileNotFoundError: return None return None @staticmethod def _check_zls_installed() -> bool: """Check if ZLS is installed in the system.""" return shutil.which("zls") is not None @staticmethod def _setup_runtime_dependency() -> bool: """ Check if required Zig runtime dependencies are available. Raises RuntimeError with helpful message if dependencies are missing. """ # Check for Windows and provide error message if platform.system() == "Windows": raise RuntimeError( "Windows is not supported by ZLS in this integration. " "Cross-file references don't work reliably on Windows. Reason unknown." ) zig_version = ZigLanguageServer._get_zig_version() if not zig_version: raise RuntimeError( "Zig is not installed. Please install Zig from https://ziglang.org/download/ and make sure it is added to your PATH." ) if not ZigLanguageServer._check_zls_installed(): zls_version = ZigLanguageServer._get_zls_version() if not zls_version: raise RuntimeError( "Found Zig but ZLS (Zig Language Server) is not installed.\n" "Please install ZLS from https://github.com/zigtools/zls\n" "You can install it via:\n" " - Package managers (brew install zls, scoop install zls, etc.)\n" " - Download pre-built binaries from GitHub releases\n" " - Build from source with: zig build -Doptimize=ReleaseSafe\n\n" "After installation, make sure 'zls' is added to your PATH." ) return True def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): self._setup_runtime_dependency() super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd="zls", cwd=repository_root_path), "zig", solidlsp_settings) self.request_id = 0 @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the Zig Language Server. """ root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, "definition": {"dynamicRegistration": True}, "references": {"dynamicRegistration": True}, "documentSymbol": { "dynamicRegistration": True, "hierarchicalDocumentSymbolSupport": True, "symbolKind": {"valueSet": list(range(1, 27))}, }, "completion": { "dynamicRegistration": True, "completionItem": { "snippetSupport": True, "commitCharactersSupport": True, "documentationFormat": ["markdown", "plaintext"], "deprecatedSupport": True, "preselectSupport": True, }, }, "hover": { "dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"], }, }, "workspace": { "workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}, "configuration": True, }, }, "processId": os.getpid(), "rootPath": repository_absolute_path, "rootUri": root_uri, "workspaceFolders": [ { "uri": root_uri, "name": os.path.basename(repository_absolute_path), } ], "initializationOptions": { # ZLS specific options based on schema.json # Critical paths for ZLS to understand the project "zig_exe_path": shutil.which("zig"), # Path to zig executable "zig_lib_path": None, # Let ZLS auto-detect "build_runner_path": None, # Let ZLS use its built-in runner "global_cache_path": None, # Let ZLS use default cache # Build configuration "enable_build_on_save": True, # Enable to analyze project structure "build_on_save_args": ["build"], # Features "enable_snippets": True, "enable_argument_placeholders": True, "semantic_tokens": "full", "warn_style": False, "highlight_global_var_declarations": False, "skip_std_references": False, "prefer_ast_check_as_child_process": True, "completion_label_details": True, # Inlay hints configuration "inlay_hints_show_variable_type_hints": True, "inlay_hints_show_struct_literal_field_type": True, "inlay_hints_show_parameter_name": True, "inlay_hints_show_builtin": True, "inlay_hints_exclude_single_argument": True, "inlay_hints_hide_redundant_param_names": False, "inlay_hints_hide_redundant_param_names_last_token": False, }, } return initialize_params # type: ignore[return-value] def _start_server(self) -> None: """Start ZLS server process""" def register_capability_handler(params: dict) -> None: return def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") def do_nothing(params: dict) -> None: return self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_notification("window/logMessage", window_log_message) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) log.info("Starting ZLS server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) # Verify server capabilities assert "textDocumentSync" in init_response["capabilities"] assert "definitionProvider" in init_response["capabilities"] assert "documentSymbolProvider" in init_response["capabilities"] assert "referencesProvider" in init_response["capabilities"] self.server.notify.initialized({}) # ZLS server is ready after initialization # (no need to wait for an event) # Open build.zig if it exists to help ZLS understand project structure build_zig_path = os.path.join(self.repository_root_path, "build.zig") if os.path.exists(build_zig_path): try: with open(build_zig_path, encoding="utf-8") as f: content = f.read() uri = pathlib.Path(build_zig_path).as_uri() self.server.notify.did_open_text_document( { "textDocument": { "uri": uri, "languageId": "zig", "version": 1, "text": content, } } ) log.info("Opened build.zig to provide project context to ZLS") except Exception as e: log.warning(f"Failed to open build.zig: {e}") ================================================ FILE: src/solidlsp/ls.py ================================================ import dataclasses import hashlib import json import logging import os import pathlib import shutil import subprocess import threading from abc import ABC, abstractmethod from collections import defaultdict from collections.abc import Hashable, Iterator from contextlib import contextmanager from copy import copy from pathlib import Path, PurePath from time import perf_counter, sleep from typing import Self, Union, cast import pathspec from sensai.util.pickle import getstate, load_pickle from sensai.util.string import ToStringMixin from serena.util.file_system import match_path from serena.util.text_utils import MatchedConsecutiveLines from solidlsp import ls_types from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.ls_exceptions import SolidLSPException from solidlsp.ls_process import LanguageServerProcess from solidlsp.ls_types import UnifiedSymbolInformation from solidlsp.ls_utils import FileUtils, PathUtils, TextUtils from solidlsp.lsp_protocol_handler import lsp_types from solidlsp.lsp_protocol_handler import lsp_types as LSPTypes from solidlsp.lsp_protocol_handler.lsp_constants import LSPConstants from solidlsp.lsp_protocol_handler.lsp_types import ( Definition, DefinitionParams, DocumentSymbol, LocationLink, RenameParams, SymbolInformation, ) from solidlsp.lsp_protocol_handler.server import ( LSPError, ProcessLaunchInfo, StringDict, ) from solidlsp.settings import SolidLSPSettings from solidlsp.util.cache import load_cache, save_cache GenericDocumentSymbol = Union[LSPTypes.DocumentSymbol, LSPTypes.SymbolInformation, ls_types.UnifiedSymbolInformation] log = logging.getLogger(__name__) _debug_enabled = log.isEnabledFor(logging.DEBUG) """Serves as a flag that triggers additional computation when debug logging is enabled.""" @dataclasses.dataclass(kw_only=True) class ReferenceInSymbol: """A symbol retrieved when requesting reference to a symbol, together with the location of the reference""" symbol: ls_types.UnifiedSymbolInformation line: int character: int class LSPFileBuffer: """ This class is used to store the contents of an open LSP file in memory. """ def __init__( self, abs_path: Path, uri: str, encoding: str, version: int, language_id: str, ref_count: int, language_server: "SolidLanguageServer", open_in_ls: bool = True, ) -> None: self.abs_path = abs_path self.language_server = language_server self.uri = uri self._read_file_modified_date: float | None = None self._contents: str | None = None self.version = version self.language_id = language_id self.ref_count = ref_count self.encoding = encoding self._content_hash: str | None = None self._is_open_in_ls = False if open_in_ls: self._open_in_ls() def _open_in_ls(self) -> None: """ Open the file in the language server if it is not already open. """ if self._is_open_in_ls: return self._is_open_in_ls = True self.language_server.server.notify.did_open_text_document( { LSPConstants.TEXT_DOCUMENT: { # type: ignore LSPConstants.URI: self.uri, LSPConstants.LANGUAGE_ID: self.language_id, LSPConstants.VERSION: 0, LSPConstants.TEXT: self.contents, } } ) def close(self) -> None: if self._is_open_in_ls: self.language_server.server.notify.did_close_text_document( { LSPConstants.TEXT_DOCUMENT: { # type: ignore LSPConstants.URI: self.uri, } } ) def ensure_open_in_ls(self) -> None: """Ensure that the file is opened in the language server.""" self._open_in_ls() @property def contents(self) -> str: file_modified_date = self.abs_path.stat().st_mtime # if contents are cached, check if they are stale (file modification since last read) and invalidate if so if self._contents is not None: assert self._read_file_modified_date is not None if file_modified_date > self._read_file_modified_date: self._contents = None if self._contents is None: self._read_file_modified_date = file_modified_date self._contents = FileUtils.read_file(str(self.abs_path), self.encoding) self._content_hash = None return self._contents @contents.setter def contents(self, new_contents: str) -> None: """ Sets new contents for the file buffer (in-memory change only). Persistence of the change to disk must be handled separately. :param new_contents: the new contents to set """ self._contents = new_contents self._content_hash = None @property def content_hash(self) -> str: if self._content_hash is None: self._content_hash = hashlib.md5(self.contents.encode(self.encoding)).hexdigest() return self._content_hash def split_lines(self) -> list[str]: """Splits the contents of the file into lines.""" return self.contents.split("\n") class SymbolBody(ToStringMixin): """ Representation of the body of a symbol, which allows the extraction of the symbol's text from the lines of the file it is defined in. Instances that share the same lines buffer are memory-efficient, using only 4 integers and a reference to the lines buffer from which the text can be extracted, i.e. a core representation of only about 40 bytes per body. """ def __init__(self, lines: list[str], start_line: int, start_col: int, end_line: int, end_col: int) -> None: self._lines = lines self._start_line = start_line self._start_col = start_col self._end_line = end_line self._end_col = end_col def _tostring_excludes(self) -> list[str]: return ["_lines"] def get_text(self) -> str: # extract relevant lines symbol_body = "\n".join(self._lines[self._start_line : self._end_line + 1]) # remove leading content from the first line symbol_body = symbol_body[self._start_col :] # remove trailing content from the last line last_line = self._lines[self._end_line] trailing_length = len(last_line) - self._end_col if trailing_length > 0: symbol_body = symbol_body[: -(len(last_line) - self._end_col)] return symbol_body class SymbolBodyFactory: """ A factory for the creation of SymbolBody instances from symbols dictionaries. Instances created from the same factory instance are memory-efficient, as they share the same lines buffer. """ def __init__(self, file_buffer: LSPFileBuffer): self._lines = file_buffer.split_lines() def create_symbol_body(self, symbol: GenericDocumentSymbol) -> SymbolBody: existing_body = symbol.get("body", None) if existing_body and isinstance(existing_body, SymbolBody): return existing_body assert "location" in symbol start_line = symbol["location"]["range"]["start"]["line"] # type: ignore end_line = symbol["location"]["range"]["end"]["line"] # type: ignore start_col = symbol["location"]["range"]["start"]["character"] # type: ignore end_col = symbol["location"]["range"]["end"]["character"] # type: ignore return SymbolBody(self._lines, start_line, start_col, end_line, end_col) class DocumentSymbols: # IMPORTANT: Instances of this class are persisted in the high-level document symbol cache def __init__(self, root_symbols: list[ls_types.UnifiedSymbolInformation]): self.root_symbols = root_symbols self._all_symbols: list[ls_types.UnifiedSymbolInformation] | None = None def __getstate__(self) -> dict: return getstate(DocumentSymbols, self, transient_properties=["_all_symbols"]) def iter_symbols(self) -> Iterator[ls_types.UnifiedSymbolInformation]: """ Iterate over all symbols in the document symbol tree. Yields symbols in a depth-first manner. """ if self._all_symbols is not None: yield from self._all_symbols return def traverse(s: ls_types.UnifiedSymbolInformation) -> Iterator[ls_types.UnifiedSymbolInformation]: yield s for child in s.get("children", []): yield from traverse(child) for root_symbol in self.root_symbols: yield from traverse(root_symbol) def get_all_symbols_and_roots(self) -> tuple[list[ls_types.UnifiedSymbolInformation], list[ls_types.UnifiedSymbolInformation]]: """ This function returns all symbols in the document as a flat list and the root symbols. It exists to facilitate migration from previous versions, where this was the return interface of the LS method that obtained document symbols. :return: A tuple containing a list of all symbols in the document and a list of root symbols. """ if self._all_symbols is None: self._all_symbols = list(self.iter_symbols()) return self._all_symbols, self.root_symbols class LanguageServerDependencyProvider(ABC): """ Prepares dependencies for a language server (if any), ultimately enabling the launch command to be constructed and optionally providing environment variables that are necessary for the execution. """ def __init__(self, custom_settings: SolidLSPSettings.CustomLSSettings, ls_resources_dir: str): self._custom_settings = custom_settings self._ls_resources_dir = ls_resources_dir @abstractmethod def create_launch_command(self) -> list[str]: """ Creates the launch command for this language server, potentially downloading and installing dependencies beforehand. :return: the launch command as a list containing the executable and its arguments """ def create_launch_command_env(self) -> dict[str, str]: """ Provides environment variables to be set when executing the launch command. This method is intended to be overridden by subclasses that need to set variables. :return: a mapping for variable names to values """ return {} class LanguageServerDependencyProviderSinglePath(LanguageServerDependencyProvider, ABC): """ Special case of a dependency provider, where there is a single core dependency which provides the basis for the launch command. The core dependency's path can be overridden by the user in LS-specific settings (SerenaConfig) via the key "ls_path". If the user provides the key, the specified path is used directly. Otherwise, the provider implementation is called to get or install the core dependency. """ @abstractmethod def _get_or_install_core_dependency(self) -> str: """ Gets the language server's core path, potentially installing dependencies beforehand. :return: the core dependency's path (e.g. executable, jar, etc.) """ def create_launch_command(self) -> list[str]: path = self._custom_settings.get("ls_path", None) if path is not None: core_path = path else: core_path = self._get_or_install_core_dependency() return self._create_launch_command(core_path) @abstractmethod def _create_launch_command(self, core_path: str) -> list[str]: """ :param core_path: path to the core dependency :return: the launch command as a list containing the executable and its arguments """ class SolidLanguageServer(ABC): """ The LanguageServer class provides a language agnostic interface to the Language Server Protocol. It is used to communicate with Language Servers of different programming languages. """ CACHE_FOLDER_NAME = "cache" RAW_DOCUMENT_SYMBOLS_CACHE_VERSION = 1 """ global version identifier for raw symbol caches; an LS-specific version is defined separately and combined with this. This should be incremented whenever there is a change in the way raw document symbols are stored. If the result of a language server changes in a way that affects the raw document symbols, the LS-specific version should be incremented instead. """ RAW_DOCUMENT_SYMBOL_CACHE_FILENAME = "raw_document_symbols.pkl" RAW_DOCUMENT_SYMBOL_CACHE_FILENAME_LEGACY_FALLBACK = "document_symbols_cache_v23-06-25.pkl" DOCUMENT_SYMBOL_CACHE_VERSION = 4 DOCUMENT_SYMBOL_CACHE_FILENAME = "document_symbols.pkl" # To be overridden and extended by subclasses def is_ignored_dirname(self, dirname: str) -> bool: """ A language-specific condition for directories that should always be ignored. For example, venv in Python and node_modules in JS/TS should be ignored always. """ return dirname.startswith(".") @staticmethod def _determine_log_level(line: str) -> int: """ Classify a stderr line from the language server to determine appropriate logging level. Language servers may emit informational messages to stderr that contain words like "error" but are not actual errors. Subclasses can override this method to filter out known false-positive patterns specific to their language server. :param line: The stderr line to classify :return: A logging level (logging.DEBUG, logging.INFO, logging.WARNING, or logging.ERROR) """ line_lower = line.lower() # Default classification: treat lines with "error" or "exception" as ERROR level if "error" in line_lower or "exception" in line_lower or line.startswith("E["): return logging.ERROR else: return logging.INFO @classmethod def get_language_enum_instance(cls) -> Language: return Language.from_ls_class(cls) @classmethod def ls_resources_dir(cls, solidlsp_settings: SolidLSPSettings, mkdir: bool = True) -> str: """ Returns the directory where the language server resources are downloaded. This is used to store language server binaries, configuration files, etc. """ result = os.path.join(solidlsp_settings.ls_resources_dir, cls.__name__) # Migration of previously downloaded LS resources that were downloaded to a subdir of solidlsp instead of to the user's home pre_migration_ls_resources_dir = os.path.join(os.path.dirname(__file__), "language_servers", "static", cls.__name__) if os.path.exists(pre_migration_ls_resources_dir): if os.path.exists(result): # if the directory already exists, we just remove the old resources shutil.rmtree(result, ignore_errors=True) else: # move old resources to the new location shutil.move(pre_migration_ls_resources_dir, result) if mkdir: os.makedirs(result, exist_ok=True) return result @classmethod def create( cls, config: LanguageServerConfig, repository_root_path: str, timeout: float | None = None, solidlsp_settings: SolidLSPSettings | None = None, ) -> "SolidLanguageServer": """ Creates a language specific LanguageServer instance based on the given configuration, and appropriate settings for the programming language. If language is Java, then ensure that jdk-17.0.6 or higher is installed, `java` is in PATH, and JAVA_HOME is set to the installation directory. If language is JS/TS, then ensure that node (v18.16.0 or higher) is installed and in PATH. :param repository_root_path: The root path of the repository. :param config: language server configuration. :param logger: The logger to use. :param timeout: the timeout for requests to the language server. If None, no timeout will be used. :param solidlsp_settings: additional settings :return LanguageServer: A language specific LanguageServer instance. """ ls: SolidLanguageServer if solidlsp_settings is None: solidlsp_settings = SolidLSPSettings() # Ensure repository_root_path is absolute to avoid issues with file URIs repository_root_path = os.path.abspath(repository_root_path) ls_class = config.code_language.get_ls_class() # For now, we assume that all language server implementations have the same signature of the constructor # (which, unfortunately, differs from the signature of the base class). # If this assumption is ever violated, we need branching logic here. ls = ls_class(config, repository_root_path, solidlsp_settings) # type: ignore ls.set_request_timeout(timeout) return ls def __init__( self, config: LanguageServerConfig, repository_root_path: str, process_launch_info: ProcessLaunchInfo | None, language_id: str, solidlsp_settings: SolidLSPSettings, cache_version_raw_document_symbols: Hashable = 1, ): """ Initializes a LanguageServer instance. Do not instantiate this class directly. Use `LanguageServer.create` method instead. :param config: the global SolidLSP configuration. :param repository_root_path: the root path of the repository. :param process_launch_info: (DEPRECATED - implement _create_dependency_provider instead) the command used to start the actual language server. The command must pass appropriate flags to the binary, so that it runs in the stdio mode, as opposed to HTTP, TCP modes supported by some language servers. :param cache_version_raw_document_symbols: the version, for caching, of the raw document symbols coming from this specific language server. This should be incremented by subclasses calling this constructor whenever the format of the raw document symbols changes (typically because the language server improves/fixes its output). """ self._solidlsp_settings = solidlsp_settings lang = self.get_language_enum_instance() self._custom_settings = solidlsp_settings.get_ls_specific_settings(lang) self._ls_resources_dir = self.ls_resources_dir(solidlsp_settings) log.debug(f"Custom config (LS-specific settings) for {lang}: {self._custom_settings}") self._encoding = config.encoding self.repository_root_path: str = repository_root_path log.debug( f"Creating language server instance for {repository_root_path=} with {language_id=} and process launch info: {process_launch_info}" ) self.language_id = language_id self.open_file_buffers: dict[str, LSPFileBuffer] = {} self.language = Language(language_id) # initialise symbol caches self.cache_dir = Path(self._solidlsp_settings.project_data_path) / self.CACHE_FOLDER_NAME / self.language_id self.cache_dir.mkdir(parents=True, exist_ok=True) # * raw document symbols cache self._ls_specific_raw_document_symbols_cache_version = cache_version_raw_document_symbols self._raw_document_symbols_cache: dict[str, tuple[str, list[DocumentSymbol] | list[SymbolInformation] | None]] = {} """maps relative file paths to a tuple of (file_content_hash, raw_root_symbols)""" self._raw_document_symbols_cache_is_modified: bool = False self._load_raw_document_symbols_cache() # * high-level document symbols cache self._document_symbols_cache: dict[str, tuple[str, DocumentSymbols]] = {} """maps relative file paths to a tuple of (file_content_hash, document_symbols)""" self._document_symbols_cache_is_modified: bool = False self._load_document_symbols_cache() self.server_started = False if config.trace_lsp_communication: def logging_fn(source: str, target: str, msg: StringDict | str) -> None: log.debug(f"LSP: {source} -> {target}: {msg!s}") else: logging_fn = None # type: ignore # create the LanguageServerHandler, which provides the functionality to start the language server and communicate with it, # preparing the launch command beforehand self._dependency_provider: LanguageServerDependencyProvider | None = None if process_launch_info is None: self._dependency_provider = self._create_dependency_provider() process_launch_info = self._create_process_launch_info() log.debug(f"Creating language server instance with {language_id=} and process launch info: {process_launch_info}") self.server = LanguageServerProcess( process_launch_info, language=self.language, determine_log_level=self._determine_log_level, logger=logging_fn, start_independent_lsp_process=config.start_independent_lsp_process, ) # Set up the pathspec matcher for the ignored paths # for all absolute paths in ignored_paths, convert them to relative paths processed_patterns = [] for pattern in set(config.ignored_paths): # Normalize separators (pathspec expects forward slashes) pattern = pattern.replace(os.path.sep, "/") processed_patterns.append(pattern) log.debug(f"Processing {len(processed_patterns)} ignored paths from the config") # Create a pathspec matcher from the processed patterns self._ignore_spec = pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, processed_patterns) self._request_timeout: float | None = None self._has_waited_for_cross_file_references = False def _create_dependency_provider(self) -> LanguageServerDependencyProvider: """ Creates the dependency provider for this language server. Subclasses should override this method to provide their specific dependency provider. This method is only called if process_launch_info is not passed to __init__. """ raise NotImplementedError( f"{self.__class__.__name__} must implement _create_dependency_provider() or pass process_launch_info to __init__()" ) def _create_process_launch_info(self) -> ProcessLaunchInfo: assert self._dependency_provider is not None cmd = self._dependency_provider.create_launch_command() env = self._dependency_provider.create_launch_command_env() return ProcessLaunchInfo(cmd=cmd, cwd=self.repository_root_path, env=env) def _get_wait_time_for_cross_file_referencing(self) -> float: """Meant to be overridden by subclasses for LS that don't have a reliable "finished initializing" signal. LS may return incomplete results on calls to `request_references` (only references found in the same file), if the LS is not fully initialized yet. """ return 2 def set_request_timeout(self, timeout: float | None) -> None: """ :param timeout: the timeout, in seconds, for requests to the language server. """ self.server.set_request_timeout(timeout) def get_ignore_spec(self) -> pathspec.PathSpec: """ Returns the pathspec matcher for the paths that were configured to be ignored through the language server configuration. This is a subset of the full language-specific ignore spec that determines which files are relevant for the language server. This matcher is useful for operations outside of the language server, such as when searching for relevant non-language files in the project. """ return self._ignore_spec def is_ignored_path(self, relative_path: str, ignore_unsupported_files: bool = True) -> bool: """ Determine if a path should be ignored based on file type and ignore patterns. :param relative_path: Relative path to check :param ignore_unsupported_files: whether files that are not supported source files should be ignored :return: True if the path should be ignored, False otherwise """ abs_path = os.path.join(self.repository_root_path, relative_path) if not os.path.exists(abs_path): raise FileNotFoundError(f"File {abs_path} not found, the ignore check cannot be performed") # Check file extension if it's a file is_file = os.path.isfile(abs_path) if is_file and ignore_unsupported_files: fn_matcher = self.language.get_source_fn_matcher() if not fn_matcher.is_relevant_filename(abs_path): return True # Create normalized path for consistent handling rel_path = Path(relative_path) # Check each part of the path against always fulfilled ignore conditions dir_parts = rel_path.parts if is_file: dir_parts = dir_parts[:-1] for part in dir_parts: if not part: # Skip empty parts (e.g., from leading '/') continue if self.is_ignored_dirname(part): return True return match_path(relative_path, self.get_ignore_spec(), root_path=self.repository_root_path) def _shutdown(self, timeout: float = 5.0) -> None: """ A robust shutdown process designed to terminate cleanly on all platforms, including Windows, by explicitly closing all I/O pipes. """ if not self.server.is_running(): log.debug("Server process not running, skipping shutdown.") return log.info(f"Initiating final robust shutdown with a {timeout}s timeout...") process = self.server.process if process is None: log.debug("Server process is None, cannot shutdown.") return # --- Main Shutdown Logic --- # Stage 1: Graceful Termination Request # Send LSP shutdown and close stdin to signal no more input. try: log.debug("Sending LSP shutdown request...") # Use a thread to timeout the LSP shutdown call since it can hang shutdown_thread = threading.Thread(target=self.server.shutdown) shutdown_thread.daemon = True shutdown_thread.start() shutdown_thread.join(timeout=2.0) # 2 second timeout for LSP shutdown if shutdown_thread.is_alive(): log.debug("LSP shutdown request timed out, proceeding to terminate...") else: log.debug("LSP shutdown request completed.") if process.stdin and not process.stdin.closed: process.stdin.close() log.debug("Stage 1 shutdown complete.") except Exception as e: log.debug(f"Exception during graceful shutdown: {e}") # Ignore errors here, we are proceeding to terminate anyway. # Stage 2: Terminate and Wait for Process to Exit log.debug(f"Terminating process {process.pid}, current status: {process.poll()}") process.terminate() # Stage 3: Wait for process termination with timeout try: log.debug(f"Waiting for process {process.pid} to terminate...") exit_code = process.wait(timeout=timeout) log.info(f"Language server process terminated successfully with exit code {exit_code}.") except subprocess.TimeoutExpired: # If termination failed, forcefully kill the process log.warning(f"Process {process.pid} termination timed out, killing process forcefully...") process.kill() try: exit_code = process.wait(timeout=2.0) log.info(f"Language server process killed successfully with exit code {exit_code}.") except subprocess.TimeoutExpired: log.error(f"Process {process.pid} could not be killed within timeout.") except Exception as e: log.error(f"Error during process shutdown: {e}") @contextmanager def start_server(self) -> Iterator["SolidLanguageServer"]: self.start() yield self self.stop() def _start_server_process(self) -> None: self.server_started = True self._start_server() @abstractmethod def _start_server(self) -> None: pass def _get_language_id_for_file(self, relative_file_path: str) -> str: """Return the language ID for a file. Override in subclasses to return file-specific language IDs. Default implementation returns self.language_id. """ return self.language_id @contextmanager def open_file(self, relative_file_path: str, open_in_ls: bool = True) -> Iterator[LSPFileBuffer]: """ Open a file in the Language Server. This is required before making any requests to the Language Server. :param relative_file_path: The relative path of the file to open. :param open_in_ls: whether to open the file in the language server, sending the didOpen notification. Set this to False to read the local file buffer without notifying the LS; the file can be opened in the LS later by calling the `ensure_open_in_ls` method on the returned LSPFileBuffer. """ if not self.server_started: log.error("open_file called before Language Server started") raise SolidLSPException("Language Server not started") absolute_file_path = Path(self.repository_root_path, relative_file_path) uri = absolute_file_path.as_uri() if uri in self.open_file_buffers: fb = self.open_file_buffers[uri] assert fb.uri == uri assert fb.ref_count >= 1 fb.ref_count += 1 if open_in_ls: fb.ensure_open_in_ls() yield fb fb.ref_count -= 1 else: version = 0 language_id = self._get_language_id_for_file(relative_file_path) fb = LSPFileBuffer( abs_path=absolute_file_path, uri=uri, encoding=self._encoding, version=version, language_id=language_id, ref_count=1, language_server=self, open_in_ls=open_in_ls, ) self.open_file_buffers[uri] = fb yield fb fb.ref_count -= 1 if self.open_file_buffers[uri].ref_count == 0: self.open_file_buffers[uri].close() del self.open_file_buffers[uri] @contextmanager def _open_file_context( self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None, open_in_ls: bool = True ) -> Iterator[LSPFileBuffer]: """ Internal context manager to open a file, optionally reusing an existing file buffer. :param relative_file_path: the relative path of the file to open. :param file_buffer: an optional existing file buffer to reuse. :param open_in_ls: whether to open the file in the language server, sending the didOpen notification. Set this to False to read the local file buffer without notifying the LS; the file can be opened in the LS later by calling the `ensure_open_in_ls` method on the returned LSPFileBuffer. """ if file_buffer is not None: expected_uri = pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)).as_uri() assert file_buffer.uri == expected_uri, f"Inconsistency between provided {file_buffer.uri=} and {expected_uri=}" if open_in_ls: file_buffer.ensure_open_in_ls() yield file_buffer else: with self.open_file(relative_file_path, open_in_ls=open_in_ls) as fb: yield fb def insert_text_at_position(self, relative_file_path: str, line: int, column: int, text_to_be_inserted: str) -> ls_types.Position: """ Insert text at the given line and column in the given file and return the updated cursor position after inserting the text. :param relative_file_path: The relative path of the file to open. :param line: The line number at which text should be inserted. :param column: The column number at which text should be inserted. :param text_to_be_inserted: The text to insert. """ if not self.server_started: log.error("insert_text_at_position called before Language Server started") raise SolidLSPException("Language Server not started") absolute_file_path = str(PurePath(self.repository_root_path, relative_file_path)) uri = pathlib.Path(absolute_file_path).as_uri() # Ensure the file is open assert uri in self.open_file_buffers file_buffer = self.open_file_buffers[uri] file_buffer.version += 1 new_contents, new_l, new_c = TextUtils.insert_text_at_position(file_buffer.contents, line, column, text_to_be_inserted) file_buffer.contents = new_contents self.server.notify.did_change_text_document( { LSPConstants.TEXT_DOCUMENT: { # type: ignore LSPConstants.VERSION: file_buffer.version, LSPConstants.URI: file_buffer.uri, }, LSPConstants.CONTENT_CHANGES: [ { LSPConstants.RANGE: { "start": {"line": line, "character": column}, "end": {"line": line, "character": column}, }, "text": text_to_be_inserted, } ], } ) return ls_types.Position(line=new_l, character=new_c) def delete_text_between_positions( self, relative_file_path: str, start: ls_types.Position, end: ls_types.Position, ) -> str: """ Delete text between the given start and end positions in the given file and return the deleted text. """ if not self.server_started: log.error("insert_text_at_position called before Language Server started") raise SolidLSPException("Language Server not started") absolute_file_path = str(PurePath(self.repository_root_path, relative_file_path)) uri = pathlib.Path(absolute_file_path).as_uri() # Ensure the file is open assert uri in self.open_file_buffers file_buffer = self.open_file_buffers[uri] file_buffer.version += 1 new_contents, deleted_text = TextUtils.delete_text_between_positions( file_buffer.contents, start_line=start["line"], start_col=start["character"], end_line=end["line"], end_col=end["character"] ) file_buffer.contents = new_contents self.server.notify.did_change_text_document( { LSPConstants.TEXT_DOCUMENT: { # type: ignore LSPConstants.VERSION: file_buffer.version, LSPConstants.URI: file_buffer.uri, }, LSPConstants.CONTENT_CHANGES: [{LSPConstants.RANGE: {"start": start, "end": end}, "text": ""}], } ) return deleted_text def _send_definition_request(self, definition_params: DefinitionParams) -> Definition | list[LocationLink] | None: return self.server.send.definition(definition_params) def request_definition(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]: """ Raise a [textDocument/definition](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_definition) request to the Language Server for the symbol at the given line and column in the given file. Wait for the response and return the result. :param relative_file_path: The relative path of the file that has the symbol for which definition should be looked up :param line: The line number of the symbol :param column: The column number of the symbol :return: the list of locations where the symbol is defined """ if not self.server_started: log.error("request_definition called before language server started") raise SolidLSPException("Language Server not started") if not self._has_waited_for_cross_file_references: # Some LS require waiting for a while before they can return cross-file definitions. # This is a workaround for such LS that don't have a reliable "finished initializing" signal. sleep(self._get_wait_time_for_cross_file_referencing()) self._has_waited_for_cross_file_references = True with self.open_file(relative_file_path): # sending request to the language server and waiting for response definition_params = cast( DefinitionParams, { LSPConstants.TEXT_DOCUMENT: { LSPConstants.URI: pathlib.Path(str(PurePath(self.repository_root_path, relative_file_path))).as_uri() }, LSPConstants.POSITION: { LSPConstants.LINE: line, LSPConstants.CHARACTER: column, }, }, ) response = self._send_definition_request(definition_params) ret: list[ls_types.Location] = [] if isinstance(response, list): # response is either of type Location[] or LocationLink[] for item in response: assert isinstance(item, dict) if LSPConstants.URI in item and LSPConstants.RANGE in item: new_item: dict = {} new_item.update(item) new_item["absolutePath"] = PathUtils.uri_to_path(new_item["uri"]) new_item["relativePath"] = PathUtils.get_relative_path(new_item["absolutePath"], self.repository_root_path) ret.append(ls_types.Location(**new_item)) # type: ignore elif LSPConstants.TARGET_URI in item and LSPConstants.TARGET_RANGE in item and LSPConstants.TARGET_SELECTION_RANGE in item: new_item: dict = {} # type: ignore new_item["uri"] = item[LSPConstants.TARGET_URI] # type: ignore new_item["absolutePath"] = PathUtils.uri_to_path(new_item["uri"]) new_item["relativePath"] = PathUtils.get_relative_path(new_item["absolutePath"], self.repository_root_path) new_item["range"] = item[LSPConstants.TARGET_SELECTION_RANGE] # type: ignore ret.append(ls_types.Location(**new_item)) # type: ignore else: assert False, f"Unexpected response from Language Server: {item}" elif isinstance(response, dict): # response is of type Location assert LSPConstants.URI in response assert LSPConstants.RANGE in response new_item: dict = {} # type: ignore new_item.update(response) new_item["absolutePath"] = PathUtils.uri_to_path(new_item["uri"]) new_item["relativePath"] = PathUtils.get_relative_path(new_item["absolutePath"], self.repository_root_path) ret.append(ls_types.Location(**new_item)) # type: ignore elif response is None: # Some language servers return None when they cannot find a definition # This is expected for certain symbol types like generics or types with incomplete information log.warning(f"Language server returned None for definition request at {relative_file_path}:{line}:{column}") else: assert False, f"Unexpected response from Language Server: {response}" return ret # Some LS cause problems with this, so the call is isolated from the rest to allow overriding in subclasses def _send_references_request(self, relative_file_path: str, line: int, column: int) -> list[lsp_types.Location] | None: return self.server.send.references( { "textDocument": {"uri": PathUtils.path_to_uri(os.path.join(self.repository_root_path, relative_file_path))}, "position": {"line": line, "character": column}, "context": {"includeDeclaration": False}, } ) def request_references(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]: """ Raise a [textDocument/references](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_references) request to the Language Server to find references to the symbol at the given line and column in the given file. Wait for the response and return the result. Filters out references located in ignored directories. :param relative_file_path: The relative path of the file that has the symbol for which references should be looked up :param line: The line number of the symbol :param column: The column number of the symbol :return: A list of locations where the symbol is referenced (excluding ignored directories) """ if not self.server_started: log.error("request_references called before Language Server started") raise SolidLSPException("Language Server not started") with self.open_file(relative_file_path): if not self._has_waited_for_cross_file_references: # Some LS require waiting for a while before they can return cross-file references. # This is a workaround for such LS that don't have a reliable "finished initializing" signal. # The waiting has to happen after at least one file was opened in the ls sleep(self._get_wait_time_for_cross_file_referencing()) self._has_waited_for_cross_file_references = True t0 = perf_counter() if _debug_enabled else 0.0 try: response = self._send_references_request(relative_file_path, line=line, column=column) except Exception as e: # Catch LSP internal error (-32603) and raise a more informative exception if isinstance(e, LSPError) and getattr(e, "code", None) == -32603: raise RuntimeError( f"LSP internal error (-32603) when requesting references for {relative_file_path}:{line}:{column}. " "This often occurs when requesting references for a symbol not referenced in the expected way. " ) from e raise if response is None: if _debug_enabled: elapsed_ms = (perf_counter() - t0) * 1000 log.debug("perf: request_references path=%s elapsed_ms=%.2f count=0", relative_file_path, elapsed_ms) return [] ret: list[ls_types.Location] = [] assert isinstance(response, list), f"Unexpected response from Language Server (expected list, got {type(response)}): {response}" for item in response: assert isinstance(item, dict), f"Unexpected response from Language Server (expected dict, got {type(item)}): {item}" assert LSPConstants.URI in item assert LSPConstants.RANGE in item abs_path = PathUtils.uri_to_path(item[LSPConstants.URI]) # type: ignore if not Path(abs_path).is_relative_to(self.repository_root_path): log.warning( "Found a reference in a path outside the repository, probably the LS is parsing things in installed packages or in the standardlib! " f"Path: {abs_path}. This is a bug but we currently simply skip these references." ) continue rel_path = Path(abs_path).relative_to(self.repository_root_path) if self.is_ignored_path(str(rel_path)): log.debug("Ignoring reference in %s since it should be ignored", rel_path) continue new_item: dict = {} new_item.update(item) new_item["absolutePath"] = str(abs_path) new_item["relativePath"] = str(rel_path) ret.append(ls_types.Location(**new_item)) # type: ignore if _debug_enabled: elapsed_ms = (perf_counter() - t0) * 1000 unique_files = len({r["relativePath"] for r in ret}) log.debug( "perf: request_references path=%s elapsed_ms=%.2f count=%d unique_files=%d", relative_file_path, elapsed_ms, len(ret), unique_files, ) return ret def request_text_document_diagnostics(self, relative_file_path: str) -> list[ls_types.Diagnostic]: """ Raise a [textDocument/diagnostic](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_diagnostic) request to the Language Server to find diagnostics for the given file. Wait for the response and return the result. :param relative_file_path: The relative path of the file to retrieve diagnostics for :return: A list of diagnostics for the file """ if not self.server_started: log.error("request_text_document_diagnostics called before Language Server started") raise SolidLSPException("Language Server not started") with self.open_file(relative_file_path): response = self.server.send.text_document_diagnostic( { LSPConstants.TEXT_DOCUMENT: { # type: ignore LSPConstants.URI: pathlib.Path(str(PurePath(self.repository_root_path, relative_file_path))).as_uri() } } ) if response is None: return [] # type: ignore assert isinstance(response, dict), f"Unexpected response from Language Server (expected list, got {type(response)}): {response}" ret: list[ls_types.Diagnostic] = [] for item in response["items"]: # type: ignore new_item: ls_types.Diagnostic = { "uri": pathlib.Path(str(PurePath(self.repository_root_path, relative_file_path))).as_uri(), "severity": item["severity"], "message": item["message"], "range": item["range"], "code": item["code"], # type: ignore } ret.append(ls_types.Diagnostic(**new_item)) return ret def retrieve_full_file_content(self, file_path: str) -> str: """ Retrieve the full content of the given file. """ if os.path.isabs(file_path): file_path = os.path.relpath(file_path, self.repository_root_path) with self.open_file(file_path) as file_data: return file_data.contents def retrieve_content_around_line( self, relative_file_path: str, line: int, context_lines_before: int = 0, context_lines_after: int = 0 ) -> MatchedConsecutiveLines: """ Retrieve the content of the given file around the given line. :param relative_file_path: The relative path of the file to retrieve the content from :param line: The line number to retrieve the content around :param context_lines_before: The number of lines to retrieve before the given line :param context_lines_after: The number of lines to retrieve after the given line :return MatchedConsecutiveLines: A container with the desired lines. """ with self.open_file(relative_file_path) as file_data: file_contents = file_data.contents return MatchedConsecutiveLines.from_file_contents( file_contents, line=line, context_lines_before=context_lines_before, context_lines_after=context_lines_after, source_file_path=relative_file_path, ) def request_completions( self, relative_file_path: str, line: int, column: int, allow_incomplete: bool = False ) -> list[ls_types.CompletionItem]: """ Raise a [textDocument/completion](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_completion) request to the Language Server to find completions at the given line and column in the given file. Wait for the response and return the result. :param relative_file_path: The relative path of the file that has the symbol for which completions should be looked up :param line: The line number of the symbol :param column: The column number of the symbol :return: A list of completions """ with self.open_file(relative_file_path): open_file_buffer = self.open_file_buffers[pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)).as_uri()] completion_params: LSPTypes.CompletionParams = { "position": {"line": line, "character": column}, "textDocument": {"uri": open_file_buffer.uri}, "context": {"triggerKind": LSPTypes.CompletionTriggerKind.Invoked}, } response: list[LSPTypes.CompletionItem] | LSPTypes.CompletionList | None = None for _ in range(30): response = self.server.send.completion(completion_params) if isinstance(response, list): response = {"items": response, "isIncomplete": False} if response is None or not response["isIncomplete"]: # type: ignore break # TODO: Understand how to appropriately handle `isIncomplete` if response is None or (response["isIncomplete"] and not allow_incomplete): # type: ignore return [] if "items" in response: response = response["items"] # type: ignore response = cast(list[LSPTypes.CompletionItem], response) # TODO: Handle the case when the completion is a keyword items = [item for item in response if item["kind"] != LSPTypes.CompletionItemKind.Keyword] completions_list: list[ls_types.CompletionItem] = [] for item in items: assert "insertText" in item or "textEdit" in item assert "kind" in item completion_item = {} if "detail" in item: completion_item["detail"] = item["detail"] if "label" in item: completion_item["completionText"] = item["label"] completion_item["kind"] = item["kind"] # type: ignore elif "insertText" in item: # type: ignore completion_item["completionText"] = item["insertText"] completion_item["kind"] = item["kind"] elif "textEdit" in item and "newText" in item["textEdit"]: completion_item["completionText"] = item["textEdit"]["newText"] completion_item["kind"] = item["kind"] elif "textEdit" in item and "range" in item["textEdit"]: new_dot_lineno, new_dot_colno = ( completion_params["position"]["line"], completion_params["position"]["character"], ) assert all( ( item["textEdit"]["range"]["start"]["line"] == new_dot_lineno, item["textEdit"]["range"]["start"]["character"] == new_dot_colno, item["textEdit"]["range"]["start"]["line"] == item["textEdit"]["range"]["end"]["line"], item["textEdit"]["range"]["start"]["character"] == item["textEdit"]["range"]["end"]["character"], ) ) completion_item["completionText"] = item["textEdit"]["newText"] completion_item["kind"] = item["kind"] elif "textEdit" in item and "insert" in item["textEdit"]: assert False else: assert False completion_item = ls_types.CompletionItem(**completion_item) # type: ignore completions_list.append(completion_item) return [json.loads(json_repr) for json_repr in set(json.dumps(item, sort_keys=True) for item in completions_list)] def _request_document_symbols( self, relative_file_path: str, file_data: LSPFileBuffer | None ) -> list[SymbolInformation] | list[DocumentSymbol] | None: """ Sends a [documentSymbol](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_documentSymbol) request to the language server to find symbols in the given file - or returns a cached result if available. :param relative_file_path: the relative path of the file that has the symbols. :param file_data: the file data buffer, if already opened. If None, the file will be opened in this method. :return: the list of root symbols in the file. """ def get_cached_raw_document_symbols(cache_key: str, fd: LSPFileBuffer) -> list[SymbolInformation] | list[DocumentSymbol] | None: file_hash_and_result = self._raw_document_symbols_cache.get(cache_key) if file_hash_and_result is None: log.debug("No cache hit for raw document symbols in %s", relative_file_path) log.debug("perf: raw_document_symbols_cache MISS path=%s", relative_file_path) return None file_hash, result = file_hash_and_result if file_hash == fd.content_hash: log.debug("Returning cached raw document symbols for %s", relative_file_path) log.debug("perf: raw_document_symbols_cache HIT path=%s", relative_file_path) return result log.debug("Document content for %s has changed (raw symbol cache is not up-to-date)", relative_file_path) log.debug("perf: raw_document_symbols_cache STALE path=%s", relative_file_path) return None def get_raw_document_symbols(fd: LSPFileBuffer) -> list[SymbolInformation] | list[DocumentSymbol] | None: # check for cached result cache_key = relative_file_path response = get_cached_raw_document_symbols(cache_key, fd) if response is not None: return response # no cached result, query language server log.debug(f"Requesting document symbols for {relative_file_path} from the Language Server") response = self.server.send.document_symbol( {"textDocument": {"uri": pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)).as_uri()}} ) # update cache self._raw_document_symbols_cache[cache_key] = (fd.content_hash, response) self._raw_document_symbols_cache_is_modified = True return response with self._open_file_context(relative_file_path, file_buffer=file_data) as fd: return get_raw_document_symbols(fd) def request_document_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None) -> DocumentSymbols: """ Retrieves the collection of symbols in the given file :param relative_file_path: The relative path of the file that has the symbols :param file_buffer: an optional file buffer if the file is already opened. :return: the collection of symbols in the file. All contained symbols will have a location, children, and a parent attribute, where the parent attribute is None for root symbols. Note that this is slightly different from the call to request_full_symbol_tree, where the parent attribute will be the file symbol which in turn may have a package symbol as parent. If you need a symbol tree that contains file symbols as well, you should use `request_full_symbol_tree` instead. """ with self._open_file_context(relative_file_path, file_buffer, open_in_ls=False) as file_data: # check if the desired result is cached cache_key = relative_file_path file_hash_and_result = self._document_symbols_cache.get(cache_key) if file_hash_and_result is None: log.debug("No cache hit for document symbols in %s", relative_file_path) log.debug("perf: document_symbols_cache MISS path=%s", relative_file_path) else: file_hash, document_symbols = file_hash_and_result if file_hash == file_data.content_hash: log.debug("Returning cached document symbols for %s", relative_file_path) log.debug("perf: document_symbols_cache HIT path=%s", relative_file_path) return document_symbols log.debug("Cached document symbol content for %s has changed", relative_file_path) log.debug("perf: document_symbols_cache STALE path=%s", relative_file_path) # no cached result: request the root symbols from the language server root_symbols = self._request_document_symbols(relative_file_path, file_data) if root_symbols is None: log.warning( f"Received None response from the Language Server for document symbols in {relative_file_path}. " f"This means the language server can't understand this file (possibly due to syntax errors). It may also be due to a bug or misconfiguration of the LS. " f"Returning empty list", ) return DocumentSymbols([]) assert isinstance(root_symbols, list), f"Unexpected response from Language Server: {root_symbols}" log.debug("Received %d root symbols for %s from the language server", len(root_symbols), relative_file_path) body_factory = SymbolBodyFactory(file_data) def convert_to_unified_symbol(original_symbol_dict: GenericDocumentSymbol) -> ls_types.UnifiedSymbolInformation: """ Converts the given symbol dictionary to the unified representation, ensuring that all required fields are present (except 'children' which is handled separately). :param original_symbol_dict: the item to augment :return: the augmented item (new object) """ # noinspection PyInvalidCast item = cast(ls_types.UnifiedSymbolInformation, dict(original_symbol_dict)) absolute_path = os.path.join(self.repository_root_path, relative_file_path) # handle missing location and path entries if "location" not in item: uri = pathlib.Path(absolute_path).as_uri() assert "range" in item tree_location = ls_types.Location( uri=uri, range=item["range"], absolutePath=absolute_path, relativePath=relative_file_path, ) item["location"] = tree_location location = item["location"] if "absolutePath" not in location: location["absolutePath"] = absolute_path # type: ignore if "relativePath" not in location: location["relativePath"] = relative_file_path # type: ignore item["body"] = self.create_symbol_body(item, factory=body_factory) # handle missing selectionRange if "selectionRange" not in item: if "range" in item: item["selectionRange"] = item["range"] else: item["selectionRange"] = item["location"]["range"] return item def convert_symbols_with_common_parent( symbols: list[DocumentSymbol] | list[SymbolInformation] | list[UnifiedSymbolInformation], parent: ls_types.UnifiedSymbolInformation | None, ) -> list[ls_types.UnifiedSymbolInformation]: """ Converts the given symbols into UnifiedSymbolInformation with proper parent-child relationships, adding overload indices for symbols with the same name under the same parent. """ total_name_counts: dict[str, int] = defaultdict(lambda: 0) for symbol in symbols: total_name_counts[symbol["name"]] += 1 name_counts: dict[str, int] = defaultdict(lambda: 0) unified_symbols = [] for symbol in symbols: usymbol = convert_to_unified_symbol(symbol) if total_name_counts[usymbol["name"]] > 1: usymbol["overload_idx"] = name_counts[usymbol["name"]] name_counts[usymbol["name"]] += 1 usymbol["parent"] = parent if "children" in usymbol: usymbol["children"] = convert_symbols_with_common_parent(usymbol["children"], usymbol) # type: ignore else: usymbol["children"] = [] # type: ignore unified_symbols.append(usymbol) return unified_symbols unified_root_symbols = convert_symbols_with_common_parent(root_symbols, None) document_symbols = DocumentSymbols(unified_root_symbols) # update cache log.debug("Updating cached document symbols for %s", relative_file_path) self._document_symbols_cache[cache_key] = (file_data.content_hash, document_symbols) self._document_symbols_cache_is_modified = True return document_symbols def request_full_symbol_tree(self, within_relative_path: str | None = None) -> list[ls_types.UnifiedSymbolInformation]: """ Will go through all files in the project or within a relative path and build a tree of symbols. Note: this may be slow the first time it is called, especially if `within_relative_path` is not used to restrict the search. For each file, a symbol of kind File (2) will be created. For directories, a symbol of kind Package (4) will be created. All symbols will have a children attribute, thereby representing the tree structure of all symbols in the project that are within the repository. All symbols except the root packages will have a parent attribute. Will ignore directories starting with '.', language-specific defaults and user-configured directories (e.g. from .gitignore). :param within_relative_path: pass a relative path to only consider symbols within this path. If a file is passed, only the symbols within this file will be considered. If a directory is passed, all files within this directory will be considered. :return: A list of root symbols representing the top-level packages/modules in the project. """ if within_relative_path is not None: within_abs_path = os.path.join(self.repository_root_path, within_relative_path) if not os.path.exists(within_abs_path): raise FileNotFoundError(f"File or directory not found: {within_abs_path}") if os.path.isfile(within_abs_path): if self.is_ignored_path(within_relative_path): log.error("You passed a file explicitly, but it is ignored. This is probably an error. File: %s", within_relative_path) return [] else: root_nodes = self.request_document_symbols(within_relative_path).root_symbols return root_nodes # Helper function to recursively process directories def process_directory(rel_dir_path: str) -> list[ls_types.UnifiedSymbolInformation]: abs_dir_path = self.repository_root_path if rel_dir_path == "." else os.path.join(self.repository_root_path, rel_dir_path) abs_dir_path = os.path.realpath(abs_dir_path) if self.is_ignored_path(str(Path(abs_dir_path).relative_to(self.repository_root_path))): log.debug("Skipping directory: %s (because it should be ignored)", rel_dir_path) return [] result = [] try: contained_dir_or_file_names = os.listdir(abs_dir_path) except OSError: return [] # Create package symbol for directory package_symbol = ls_types.UnifiedSymbolInformation( # type: ignore name=os.path.basename(abs_dir_path), kind=ls_types.SymbolKind.Package, location=ls_types.Location( uri=str(pathlib.Path(abs_dir_path).as_uri()), range={"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}}, absolutePath=str(abs_dir_path), relativePath=str(Path(abs_dir_path).resolve().relative_to(self.repository_root_path)), ), children=[], ) result.append(package_symbol) for contained_dir_or_file_name in contained_dir_or_file_names: contained_dir_or_file_abs_path = os.path.join(abs_dir_path, contained_dir_or_file_name) # obtain relative path try: contained_dir_or_file_rel_path = str( Path(contained_dir_or_file_abs_path).resolve().relative_to(self.repository_root_path) ) except ValueError as e: # Typically happens when the path is not under the repository root (e.g., symlink pointing outside) log.warning( "Skipping path %s; likely outside of the repository root %s [cause: %s]", contained_dir_or_file_abs_path, self.repository_root_path, e, ) continue if self.is_ignored_path(contained_dir_or_file_rel_path): log.debug("Skipping item: %s (because it should be ignored)", contained_dir_or_file_rel_path) continue if os.path.isdir(contained_dir_or_file_abs_path): child_symbols = process_directory(contained_dir_or_file_rel_path) package_symbol["children"].extend(child_symbols) for child in child_symbols: child["parent"] = package_symbol elif os.path.isfile(contained_dir_or_file_abs_path): with self._open_file_context(contained_dir_or_file_rel_path, open_in_ls=False) as file_data: document_symbols = self.request_document_symbols(contained_dir_or_file_rel_path, file_data) file_root_nodes = document_symbols.root_symbols # Create file symbol, link with children file_range = self._get_range_from_file_content(file_data.contents) file_symbol = ls_types.UnifiedSymbolInformation( # type: ignore name=os.path.splitext(contained_dir_or_file_name)[0], kind=ls_types.SymbolKind.File, range=file_range, selectionRange=file_range, location=ls_types.Location( uri=str(pathlib.Path(contained_dir_or_file_abs_path).as_uri()), range=file_range, absolutePath=str(contained_dir_or_file_abs_path), relativePath=str(Path(contained_dir_or_file_abs_path).resolve().relative_to(self.repository_root_path)), ), children=file_root_nodes, parent=package_symbol, ) for child in file_root_nodes: child["parent"] = file_symbol # Link file symbol with package package_symbol["children"].append(file_symbol) # TODO: Not sure if this is actually still needed given recent changes to relative path handling def fix_relative_path(nodes: list[ls_types.UnifiedSymbolInformation]) -> None: for node in nodes: if "location" in node and "relativePath" in node["location"]: path = Path(node["location"]["relativePath"]) # type: ignore if path.is_absolute(): try: path = path.relative_to(self.repository_root_path) node["location"]["relativePath"] = str(path) except Exception: pass if "children" in node: fix_relative_path(node["children"]) fix_relative_path(file_root_nodes) return result # Start from the root or the specified directory start_rel_path = within_relative_path or "." return process_directory(start_rel_path) @staticmethod def _get_range_from_file_content(file_content: str) -> ls_types.Range: """ Get the range for the given file. """ lines = file_content.split("\n") end_line = len(lines) end_column = len(lines[-1]) return ls_types.Range(start=ls_types.Position(line=0, character=0), end=ls_types.Position(line=end_line, character=end_column)) def request_dir_overview(self, relative_dir_path: str) -> dict[str, list[UnifiedSymbolInformation]]: """ :return: A mapping of all relative paths analyzed to lists of top-level symbols in the corresponding file. """ symbol_tree = self.request_full_symbol_tree(relative_dir_path) # Initialize result dictionary result: dict[str, list[UnifiedSymbolInformation]] = defaultdict(list) # Helper function to process a symbol and its children def process_symbol(symbol: ls_types.UnifiedSymbolInformation) -> None: if symbol["kind"] == ls_types.SymbolKind.File: # For file symbols, process their children (top-level symbols) for child in symbol["children"]: # Handle cross-platform path resolution (fixes Docker/macOS path issues) absolute_path = Path(child["location"]["absolutePath"]).resolve() repository_root = Path(self.repository_root_path).resolve() # Try pathlib first, fallback to alternative approach if paths are incompatible try: path = absolute_path.relative_to(repository_root) except ValueError: # If paths are from different roots (e.g., /workspaces vs /Users), # use the relativePath from location if available, or extract from absolutePath if "relativePath" in child["location"] and child["location"]["relativePath"]: path = Path(child["location"]["relativePath"]) else: # Extract relative path by finding common structure # Example: /workspaces/.../test_repo/file.py -> test_repo/file.py path_parts = absolute_path.parts # Find the last common part or use a fallback if "test_repo" in path_parts: test_repo_idx = path_parts.index("test_repo") path = Path(*path_parts[test_repo_idx:]) else: # Last resort: use filename only path = Path(absolute_path.name) result[str(path)].append(child) # For package/directory symbols, process their children for child in symbol["children"]: process_symbol(child) # Process each root symbol for root in symbol_tree: process_symbol(root) return result def request_document_overview(self, relative_file_path: str) -> list[UnifiedSymbolInformation]: """ :return: the top-level symbols in the given file. """ return self.request_document_symbols(relative_file_path).root_symbols def request_overview(self, within_relative_path: str) -> dict[str, list[UnifiedSymbolInformation]]: """ An overview of all symbols in the given file or directory. :param within_relative_path: the relative path to the file or directory to get the overview of. :return: A mapping of all relative paths analyzed to lists of top-level symbols in the corresponding file. """ abs_path = (Path(self.repository_root_path) / within_relative_path).resolve() if not abs_path.exists(): raise FileNotFoundError(f"File or directory not found: {abs_path}") if abs_path.is_file(): symbols_overview = self.request_document_overview(within_relative_path) return {within_relative_path: symbols_overview} else: return self.request_dir_overview(within_relative_path) def request_hover( self, relative_file_path: str, line: int, column: int, file_buffer: LSPFileBuffer | None = None ) -> ls_types.Hover | None: """ Raise a [textDocument/hover](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_hover) request to the Language Server to find the hover information at the given line and column in the given file. Wait for the response and return the result. :param relative_file_path: The relative path of the file that has the hover information :param line: The line number of the symbol :param column: The column number of the symbol :param file_buffer: The file buffer to use for the request. If not provided, the file will be read from disk. Can be used for optimizing number of file reads in downstream code """ with self._open_file_context(relative_file_path, file_buffer=file_buffer) as fb: return self._request_hover(fb, line, column) def _request_hover(self, file_buffer: LSPFileBuffer, line: int, column: int) -> ls_types.Hover | None: """ Performs the actual hover request. """ response = self.server.send.hover( { "textDocument": {"uri": file_buffer.uri}, "position": { "line": line, "character": column, }, } ) if response is None: return None assert isinstance(response, dict) contents = response.get("contents") if not contents: return None if isinstance(contents, dict) and not contents.get("value"): return None return ls_types.Hover(**response) # type: ignore def request_signature_help(self, relative_file_path: str, line: int, column: int) -> ls_types.SignatureHelp | None: """ Raise a [textDocument/signatureHelp](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_signatureHelp) request to the Language Server to find the signature help at the given line and column in the given file. Note: contrary to `hover`, this only returns something on the position of a *call* and not on a symbol definition. This means for Serena's purposes, this method is not particularly useful. The result is also fairly verbose (but well structured). :param relative_file_path: The relative path of the file that has the signature help :param line: The line number of the symbol :param column: The column number of the symbol :return None """ with self.open_file(relative_file_path): response = self.server.send.signature_help( { "textDocument": {"uri": pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)).as_uri()}, "position": { "line": line, "character": column, }, } ) if response is None: return None assert isinstance(response, dict) return ls_types.SignatureHelp(**response) # type: ignore def create_symbol_body( self, symbol: ls_types.UnifiedSymbolInformation | LSPTypes.SymbolInformation, factory: SymbolBodyFactory | None = None, ) -> SymbolBody: if factory is None: assert "relativePath" in symbol["location"] with self._open_file_context(symbol["location"]["relativePath"]) as f: # type: ignore factory = SymbolBodyFactory(f) return factory.create_symbol_body(symbol) def request_referencing_symbols( self, relative_file_path: str, line: int, column: int, include_imports: bool = True, include_self: bool = False, include_body: bool = False, include_file_symbols: bool = False, ) -> list[ReferenceInSymbol]: """ Finds all symbols that reference the symbol at the given location. This is similar to request_references but filters to only include symbols (functions, methods, classes, etc.) that reference the target symbol. :param relative_file_path: The relative path to the file. :param line: The 0-indexed line number. :param column: The 0-indexed column number. :param include_imports: whether to also include imports as references. Unfortunately, the LSP does not have an import type, so the references corresponding to imports will not be easily distinguishable from definitions. :param include_self: whether to include the references that is the "input symbol" itself. Only has an effect if the relative_file_path, line and column point to a symbol, for example a definition. :param include_body: whether to include the body of the symbols in the result. :param include_file_symbols: whether to include references that are file symbols. This is often a fallback mechanism for when the reference cannot be resolved to a symbol. :return: List of objects containing the symbol and the location of the reference. """ if not self.server_started: log.error("request_referencing_symbols called before Language Server started") raise SolidLSPException("Language Server not started") # First, get all references to the symbol references = self.request_references(relative_file_path, line, column) if not references: return [] debug_enabled = log.isEnabledFor(logging.DEBUG) t0_loop = perf_counter() if debug_enabled else 0.0 # For each reference, find the containing symbol result = [] incoming_symbol = None for ref in references: ref_path = ref["relativePath"] assert ref_path is not None ref_line = ref["range"]["start"]["line"] ref_col = ref["range"]["start"]["character"] with self.open_file(ref_path) as file_data: body_factory = SymbolBodyFactory(file_data) # Get the containing symbol for this reference containing_symbol = self.request_containing_symbol( ref_path, ref_line, ref_col, include_body=include_body, body_factory=body_factory ) if containing_symbol is None: # TODO: HORRIBLE HACK! I don't know how to do it better for now... # THIS IS BOUND TO BREAK IN MANY CASES! IT IS ALSO SPECIFIC TO PYTHON! # Background: # When a variable is used to change something, like # # instance = MyClass() # instance.status = "new status" # # we can't find the containing symbol for the reference to `status` # since there is no container on the line of the reference # The hack is to try to find a variable symbol in the containing module # by using the text of the reference to find the variable name (In a very heuristic way) # and then look for a symbol with that name and kind Variable ref_text = file_data.contents.split("\n")[ref_line] if "." in ref_text: containing_symbol_name = ref_text.split(".")[0] document_symbols = self.request_document_symbols(ref_path) for symbol in document_symbols.iter_symbols(): if symbol["name"] == containing_symbol_name and symbol["kind"] == ls_types.SymbolKind.Variable: containing_symbol = copy(symbol) containing_symbol["location"] = ref containing_symbol["range"] = ref["range"] break # We failed retrieving the symbol, falling back to creating a file symbol if containing_symbol is None and include_file_symbols: log.warning(f"Could not find containing symbol for {ref_path}:{ref_line}:{ref_col}. Returning file symbol instead") fileRange = self._get_range_from_file_content(file_data.contents) location = ls_types.Location( uri=str(pathlib.Path(os.path.join(self.repository_root_path, ref_path)).as_uri()), range=fileRange, absolutePath=str(os.path.join(self.repository_root_path, ref_path)), relativePath=ref_path, ) name = os.path.splitext(os.path.basename(ref_path))[0] containing_symbol = ls_types.UnifiedSymbolInformation( kind=ls_types.SymbolKind.File, range=fileRange, selectionRange=fileRange, location=location, name=name, children=[], ) if include_body: containing_symbol["body"] = self.create_symbol_body(containing_symbol, factory=body_factory) if containing_symbol is None or (not include_file_symbols and containing_symbol["kind"] == ls_types.SymbolKind.File): continue assert "location" in containing_symbol assert "selectionRange" in containing_symbol # Checking for self-reference if ( containing_symbol["location"]["relativePath"] == relative_file_path and containing_symbol["selectionRange"]["start"]["line"] == ref_line and containing_symbol["selectionRange"]["start"]["character"] == ref_col ): incoming_symbol = containing_symbol if include_self: result.append(ReferenceInSymbol(symbol=containing_symbol, line=ref_line, character=ref_col)) continue log.debug(f"Found self-reference for {incoming_symbol['name']}, skipping it since {include_self=}") continue # checking whether reference is an import # This is neither really safe nor elegant, but if we don't do it, # there is no way to distinguish between definitions and imports as import is not a symbol-type # and we get the type referenced symbol resulting from imports... if ( not include_imports and incoming_symbol is not None and containing_symbol["name"] == incoming_symbol["name"] and containing_symbol["kind"] == incoming_symbol["kind"] ): log.debug( f"Found import of referenced symbol {incoming_symbol['name']}" f"in {containing_symbol['location']['relativePath']}, skipping" ) continue result.append(ReferenceInSymbol(symbol=containing_symbol, line=ref_line, character=ref_col)) if debug_enabled: loop_elapsed_ms = (perf_counter() - t0_loop) * 1000 unique_files = len({r.symbol["location"]["relativePath"] for r in result}) log.debug( "perf: request_referencing_symbols path=%s loop_elapsed_ms=%.2f ref_count=%d result_count=%d unique_files=%d", relative_file_path, loop_elapsed_ms, len(references), len(result), unique_files, ) return result def request_containing_symbol( self, relative_file_path: str, line: int, column: int | None = None, strict: bool = False, include_body: bool = False, body_factory: SymbolBodyFactory | None = None, ) -> ls_types.UnifiedSymbolInformation | None: """ Finds the first symbol containing the position for the given file. For Python, container symbols are considered to be those with kinds corresponding to functions, methods, or classes (typically: Function (12), Method (6), Class (5)). The method operates as follows: - Request the document symbols for the file. - Filter symbols to those that start at or before the given line. - From these, first look for symbols whose range contains the (line, column). - If one or more symbols contain the position, return the one with the greatest starting position (i.e. the innermost container). - If none (strictly) contain the position, return the symbol with the greatest starting position among those above the given line. - If no container candidates are found, return None. :param relative_file_path: The relative path to the Python file. :param line: The 0-indexed line number. :param column: The 0-indexed column (also called character). If not passed, the lookup will be based only on the line. :param strict: If True, the position must be strictly within the range of the symbol. Setting to True is useful for example for finding the parent of a symbol, as with strict=False, and the line pointing to a symbol itself, the containing symbol will be the symbol itself (and not the parent). :param include_body: Whether to include the body of the symbol in the result. :return: The container symbol (if found) or None. """ # checking if the line is empty, unfortunately ugly and duplicating code, but I don't want to refactor with self.open_file(relative_file_path): absolute_file_path = str(PurePath(self.repository_root_path, relative_file_path)) content = FileUtils.read_file(absolute_file_path, self._encoding) if content.split("\n")[line].strip() == "": log.error(f"Passing empty lines to request_container_symbol is currently not supported, {relative_file_path=}, {line=}") return None document_symbols = self.request_document_symbols(relative_file_path) # make jedi and pyright api compatible # the former has no location, the later has no range # we will just always add location of the desired format to all symbols for symbol in document_symbols.iter_symbols(): if "location" not in symbol: range = symbol["range"] location = ls_types.Location( uri=f"file:/{absolute_file_path}", range=range, absolutePath=absolute_file_path, relativePath=relative_file_path, ) symbol["location"] = location else: location = symbol["location"] assert "range" in location location["absolutePath"] = absolute_file_path location["relativePath"] = relative_file_path location["uri"] = Path(absolute_file_path).as_uri() # Allowed container kinds, currently only for Python container_symbol_kinds = {ls_types.SymbolKind.Method, ls_types.SymbolKind.Function, ls_types.SymbolKind.Class} def is_position_in_range(line: int, range_d: ls_types.Range) -> bool: start = range_d["start"] end = range_d["end"] column_condition = True if strict: line_condition = end["line"] >= line > start["line"] if column is not None and line == start["line"]: column_condition = column > start["character"] else: line_condition = end["line"] >= line >= start["line"] if column is not None and line == start["line"]: column_condition = column >= start["character"] return line_condition and column_condition # Only consider containers that are not one-liners (otherwise we may get imports) candidate_containers = [ s for s in document_symbols.iter_symbols() if s["kind"] in container_symbol_kinds and s["location"]["range"]["start"]["line"] != s["location"]["range"]["end"]["line"] ] var_containers = [s for s in document_symbols.iter_symbols() if s["kind"] == ls_types.SymbolKind.Variable] candidate_containers.extend(var_containers) if not candidate_containers: return None # From the candidates, find those whose range contains the given position. containing_symbols = [] for symbol in candidate_containers: s_range = symbol["location"]["range"] if not is_position_in_range(line, s_range): continue containing_symbols.append(symbol) if containing_symbols: # Return the one with the greatest starting position (i.e. the innermost container). containing_symbol = max(containing_symbols, key=lambda s: s["location"]["range"]["start"]["line"]) if include_body: containing_symbol["body"] = self.create_symbol_body(containing_symbol, factory=body_factory) return containing_symbol else: return None def request_container_of_symbol( self, symbol: ls_types.UnifiedSymbolInformation, include_body: bool = False ) -> ls_types.UnifiedSymbolInformation | None: """ Finds the container of the given symbol if there is one. If the parent attribute is present, the parent is returned without further searching. :param symbol: The symbol to find the container of. :param include_body: whether to include the body of the symbol in the result. :return: The container of the given symbol or None if no container is found. """ if "parent" in symbol: return symbol["parent"] assert "location" in symbol, f"Symbol {symbol} has no location and no parent attribute" return self.request_containing_symbol( symbol["location"]["relativePath"], # type: ignore symbol["location"]["range"]["start"]["line"], symbol["location"]["range"]["start"]["character"], strict=True, include_body=include_body, ) def _get_preferred_definition(self, definitions: list[ls_types.Location]) -> ls_types.Location: """ Select the preferred definition from a list of definitions. When multiple definitions are returned (e.g., both source and type definitions), this method determines which one to use. The base implementation simply returns the first definition. Subclasses can override this method to implement language-specific preferences. For example, TypeScript/Vue servers may prefer source files over .d.ts type definition files. :param definitions: A non-empty list of definition locations. :return: The preferred definition location. """ return definitions[0] def request_defining_symbol( self, relative_file_path: str, line: int, column: int, include_body: bool = False, ) -> ls_types.UnifiedSymbolInformation | None: """ Finds the symbol that defines the symbol at the given location. This method first finds the definition of the symbol at the given position, then retrieves the full symbol information for that definition. :param relative_file_path: The relative path to the file. :param line: The 0-indexed line number. :param column: The 0-indexed column number. :param include_body: whether to include the body of the symbol in the result. :return: The symbol information for the definition, or None if not found. """ if not self.server_started: log.error("request_defining_symbol called before language server started") raise SolidLSPException("Language Server not started") # Get the definition location(s) definitions = self.request_definition(relative_file_path, line, column) if not definitions: return None # Select the preferred definition (subclasses can override _get_preferred_definition) definition = self._get_preferred_definition(definitions) def_path = definition["relativePath"] assert def_path is not None def_line = definition["range"]["start"]["line"] def_col = definition["range"]["start"]["character"] # Find the symbol at or containing this location defining_symbol = self.request_containing_symbol(def_path, def_line, def_col, strict=False, include_body=include_body) return defining_symbol def _document_symbols_cache_fingerprint(self) -> Hashable | None: """ Returns a fingerprint of any language server-specific aspects that result in changes to the high-level document symbol information. Language servers must implement this method/change the return value * whenever they change the `request_document_symbols` implementation to modify the returned content * are reconfigured in a way that affects the returned contents (e.g. context-specific configuration such as build flags or environment variables); configuration options can, in such cases, be hashed together to produce a single fingerprint value. Whenever the value changes, the document symbols cache will be invalidated and re-populated. The value must be hashable and safe for inclusion in cache version tuples. E.g. use an integer, a string or a tuple of integers/strings. Returns None if no context-specific fingerprint is needed. """ return None def _document_symbols_cache_version(self) -> Hashable: """ Return the version for the document symbols cache. Incorporates cache context fingerprint if provided by the language server. """ fingerprint = self._document_symbols_cache_fingerprint() if fingerprint is not None: return (self.DOCUMENT_SYMBOL_CACHE_VERSION, fingerprint) return self.DOCUMENT_SYMBOL_CACHE_VERSION def _save_raw_document_symbols_cache(self) -> None: cache_file = self.cache_dir / self.RAW_DOCUMENT_SYMBOL_CACHE_FILENAME if not self._raw_document_symbols_cache_is_modified: log.debug("No changes to raw document symbols cache, skipping save") return log.info("Saving updated raw document symbols cache to %s", cache_file) try: save_cache(str(cache_file), self._raw_document_symbols_cache_version(), self._raw_document_symbols_cache) self._raw_document_symbols_cache_is_modified = False except Exception as e: log.error( "Failed to save raw document symbols cache to %s: %s. Note: this may have resulted in a corrupted cache file.", cache_file, e, ) def _raw_document_symbols_cache_version(self) -> tuple[Hashable, ...]: base_version: tuple[Hashable, ...] = (self.RAW_DOCUMENT_SYMBOLS_CACHE_VERSION, self._ls_specific_raw_document_symbols_cache_version) fingerprint = self._document_symbols_cache_fingerprint() if fingerprint is not None: return (*base_version, fingerprint) return base_version def _load_raw_document_symbols_cache(self) -> None: cache_file = self.cache_dir / self.RAW_DOCUMENT_SYMBOL_CACHE_FILENAME if not cache_file.exists(): # check for legacy cache to load to migrate legacy_cache_file = self.cache_dir / self.RAW_DOCUMENT_SYMBOL_CACHE_FILENAME_LEGACY_FALLBACK if legacy_cache_file.exists(): try: legacy_cache: dict[ str, tuple[str, tuple[list[ls_types.UnifiedSymbolInformation], list[ls_types.UnifiedSymbolInformation]]] ] = load_pickle(legacy_cache_file) log.info("Migrating legacy document symbols cache with %d entries", len(legacy_cache)) num_symbols_migrated = 0 migrated_cache = {} for cache_key, (file_hash, (all_symbols, root_symbols)) in legacy_cache.items(): if cache_key.endswith("-True"): # include_body=True new_cache_key = cache_key[:-5] migrated_cache[new_cache_key] = (file_hash, root_symbols) num_symbols_migrated += len(all_symbols) log.info("Migrated %d document symbols from legacy cache", num_symbols_migrated) self._raw_document_symbols_cache = migrated_cache # type: ignore self._raw_document_symbols_cache_is_modified = True self._save_raw_document_symbols_cache() legacy_cache_file.unlink() return except Exception as e: log.error("Error during cache migration: %s", e) return # load existing cache (if any) if cache_file.exists(): log.info("Loading document symbols cache from %s", cache_file) try: saved_cache = load_cache(str(cache_file), self._raw_document_symbols_cache_version()) if saved_cache is not None: self._raw_document_symbols_cache = saved_cache log.info(f"Loaded {len(self._raw_document_symbols_cache)} entries from raw document symbols cache.") except Exception as e: # cache can become corrupt, so just skip loading it log.warning( "Failed to load raw document symbols cache from %s (%s); Ignoring cache.", cache_file, e, ) def _save_document_symbols_cache(self) -> None: cache_file = self.cache_dir / self.DOCUMENT_SYMBOL_CACHE_FILENAME if not self._document_symbols_cache_is_modified: log.debug("No changes to document symbols cache, skipping save") return log.info("Saving updated document symbols cache to %s", cache_file) try: save_cache(str(cache_file), self._document_symbols_cache_version(), self._document_symbols_cache) self._document_symbols_cache_is_modified = False except Exception as e: log.error( "Failed to save document symbols cache to %s: %s. Note: this may have resulted in a corrupted cache file.", cache_file, e, ) def _load_document_symbols_cache(self) -> None: cache_file = self.cache_dir / self.DOCUMENT_SYMBOL_CACHE_FILENAME if cache_file.exists(): log.info("Loading document symbols cache from %s", cache_file) try: saved_cache = load_cache(str(cache_file), self._document_symbols_cache_version()) if saved_cache is not None: self._document_symbols_cache = saved_cache log.info(f"Loaded {len(self._document_symbols_cache)} entries from document symbols cache.") except Exception as e: # cache can become corrupt, so just skip loading it log.warning( "Failed to load document symbols cache from %s (%s); Ignoring cache.", cache_file, e, ) def save_cache(self) -> None: self._save_raw_document_symbols_cache() self._save_document_symbols_cache() def request_workspace_symbol(self, query: str) -> list[ls_types.UnifiedSymbolInformation] | None: """ Raise a [workspace/symbol](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_symbol) request to the Language Server to find symbols across the whole workspace. Wait for the response and return the result. :param query: The query string to filter symbols by :return: A list of matching symbols """ response = self.server.send.workspace_symbol({"query": query}) if response is None: return None assert isinstance(response, list) ret: list[ls_types.UnifiedSymbolInformation] = [] for item in response: assert isinstance(item, dict) assert LSPConstants.NAME in item assert LSPConstants.KIND in item assert LSPConstants.LOCATION in item ret.append(ls_types.UnifiedSymbolInformation(**item)) # type: ignore return ret def request_rename_symbol_edit( self, relative_file_path: str, line: int, column: int, new_name: str, ) -> ls_types.WorkspaceEdit | None: """ Retrieve a WorkspaceEdit for renaming the symbol at the given location to the new name. Does not apply the edit, just retrieves it. In order to actually rename the symbol, call apply_workspace_edit. :param relative_file_path: The relative path to the file containing the symbol :param line: The 0-indexed line number of the symbol :param column: The 0-indexed column number of the symbol :param new_name: The new name for the symbol :return: A WorkspaceEdit containing the changes needed to rename the symbol, or None if rename is not supported """ params = RenameParams( textDocument=ls_types.TextDocumentIdentifier( uri=pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)).as_uri() ), position=ls_types.Position(line=line, character=column), newName=new_name, ) with self.open_file(relative_file_path): return self.server.send.rename(params) def apply_text_edits_to_file(self, relative_path: str, edits: list[ls_types.TextEdit]) -> None: """ Apply a list of text edits to a file. :param relative_path: The relative path of the file to edit :param edits: List of TextEdit dictionaries to apply """ with self.open_file(relative_path): # Sort edits by position (latest first) to avoid position shifts sorted_edits = sorted(edits, key=lambda e: (e["range"]["start"]["line"], e["range"]["start"]["character"]), reverse=True) for edit in sorted_edits: start_pos = ls_types.Position(line=edit["range"]["start"]["line"], character=edit["range"]["start"]["character"]) end_pos = ls_types.Position(line=edit["range"]["end"]["line"], character=edit["range"]["end"]["character"]) # Delete the old text and insert the new text self.delete_text_between_positions(relative_path, start_pos, end_pos) self.insert_text_at_position(relative_path, start_pos["line"], start_pos["character"], edit["newText"]) def start(self) -> "SolidLanguageServer": """ Starts the language server process and connects to it. Call shutdown when ready. :return: self for method chaining """ log.info(f"Starting language server with language {self.language_server.language} for {self.language_server.repository_root_path}") self._start_server_process() return self def stop(self, shutdown_timeout: float = 2.0) -> None: """ Stops the language server process. This function never raises an exception (any exceptions during shutdown are logged). :param shutdown_timeout: time, in seconds, to wait for the server to shutdown gracefully before killing it """ try: self._shutdown(timeout=shutdown_timeout) except Exception as e: log.warning(f"Exception while shutting down language server: {e}") @property def language_server(self) -> Self: return self @property def handler(self) -> LanguageServerProcess: """Access the underlying language server handler. Useful for advanced operations like sending custom commands or registering notification handlers. """ return self.server def is_running(self) -> bool: return self.server.is_running() ================================================ FILE: src/solidlsp/ls_config.py ================================================ """ Configuration objects for language servers """ import fnmatch from collections.abc import Iterable from dataclasses import dataclass, field from enum import Enum from typing import TYPE_CHECKING, Self if TYPE_CHECKING: from solidlsp import SolidLanguageServer class FilenameMatcher: def __init__(self, *patterns: str) -> None: """ :param patterns: fnmatch-compatible patterns """ self.patterns = patterns def is_relevant_filename(self, fn: str) -> bool: for pattern in self.patterns: if fnmatch.fnmatch(fn, pattern): return True return False class Language(str, Enum): """ Enumeration of language servers supported by SolidLSP. """ CSHARP = "csharp" PYTHON = "python" RUST = "rust" JAVA = "java" KOTLIN = "kotlin" TYPESCRIPT = "typescript" GO = "go" RUBY = "ruby" DART = "dart" CPP = "cpp" CPP_CCLS = "cpp_ccls" PHP = "php" R = "r" PERL = "perl" CLOJURE = "clojure" ELIXIR = "elixir" ELM = "elm" TERRAFORM = "terraform" SWIFT = "swift" BASH = "bash" ZIG = "zig" LUA = "lua" LUAU = "luau" """Luau Language Server for Roblox's Luau language (typed Lua 5.1 superset). Uses luau-lsp by JohnnyMorganz. Automatically downloads the binary if not found. Supports .luau files. Configure via .luaurc in the project root. """ NIX = "nix" ERLANG = "erlang" OCAML = "ocaml" AL = "al" FSHARP = "fsharp" REGO = "rego" SCALA = "scala" JULIA = "julia" FORTRAN = "fortran" HASKELL = "haskell" LEAN4 = "lean4" GROOVY = "groovy" VUE = "vue" POWERSHELL = "powershell" PASCAL = "pascal" """Pascal Language Server (pasls) for Free Pascal and Lazarus projects. Automatically downloads pasls binary. Requires FPC for full functionality. Set PP and FPCDIR environment variables for source navigation. """ MATLAB = "matlab" """MATLAB language server using the official MathWorks MATLAB Language Server. Requires MATLAB R2021b or later and Node.js. Set MATLAB_PATH environment variable or configure matlab_path in ls_specific_settings. """ # Experimental or deprecated Language Servers TYPESCRIPT_VTS = "typescript_vts" """Use the typescript language server through the natively bundled vscode extension via https://github.com/yioneko/vtsls""" PYTHON_JEDI = "python_jedi" """Jedi language server for Python (instead of pyright, which is the default)""" CSHARP_OMNISHARP = "csharp_omnisharp" """OmniSharp language server for C# (instead of the default csharp-ls by microsoft). Currently has problems with finding references, and generally seems less stable and performant. """ RUBY_SOLARGRAPH = "ruby_solargraph" """Solargraph language server for Ruby (legacy, experimental). Use Language.RUBY (ruby-lsp) for better performance and modern LSP features. """ PHP_PHPACTOR = "php_phpactor" """Phpactor language server for PHP (instead of Intelephense, which is the default). Requires PHP 8.1+ on the system. Fully open-source (MIT license). """ MARKDOWN = "markdown" """Marksman language server for Markdown (experimental). Must be explicitly specified as the main language, not auto-detected. This is an edge case primarily useful when working on documentation-heavy projects. """ YAML = "yaml" """YAML language server (experimental). Must be explicitly specified as the main language, not auto-detected. """ TOML = "toml" """TOML language server using Taplo. Supports TOML validation, formatting, and schema support. """ HLSL = "hlsl" """Shader language server using shader-language-server (antaalt/shader-sense). Supports .hlsl, .hlsli, .fx, .fxh, .cginc, .compute, .shader, .glsl, .vert, .frag, .geom, .tesc, .tese, .comp, .wgsl files. Automatically downloads shader-language-server binary. """ SYSTEMVERILOG = "systemverilog" """SystemVerilog language server using verible-verilog-ls. Supports .sv, .svh, .v, .vh files. Automatically downloads verible binary. """ SOLIDITY = "solidity" """Solidity language server using the Nomic Foundation Solidity Language Server (@nomicfoundation/solidity-language-server). Supports .sol files. Provides go-to-definition, find references, document symbols, hover, and diagnostics. Requires Node.js and npm. Works best with a foundry.toml or hardhat.config.js in the project root. """ ANSIBLE = "ansible" """Ansible language server (experimental) using @ansible/ansible-language-server. Supports *.yaml and *.yml files (same extensions as YAML, hence experimental). Must be explicitly specified in project.yml. Requires Node.js and npm. Requires ``ansible`` in PATH for full functionality. """ @classmethod def iter_all(cls, include_experimental: bool = False) -> Iterable[Self]: for lang in cls: if include_experimental or not lang.is_experimental(): yield lang def is_experimental(self) -> bool: """ Check if the language server is experimental or deprecated. Note for serena users/developers: Experimental languages are not autodetected and must be explicitly specified in the project.yml configuration. """ return self in { self.ANSIBLE, self.TYPESCRIPT_VTS, self.PYTHON_JEDI, self.CSHARP_OMNISHARP, self.RUBY_SOLARGRAPH, self.PHP_PHPACTOR, self.MARKDOWN, self.YAML, self.TOML, self.GROOVY, self.CPP_CCLS, self.SOLIDITY, } def __str__(self) -> str: return self.value def get_priority(self) -> int: """ :return: priority of the language for breaking ties between languages; higher is more important. """ # experimental languages have the lowest priority if self.is_experimental(): return 0 # We assign lower priority to languages that are supersets of others, such that # the "larger" language is only chosen when it matches more strongly match self: # languages that are supersets of others (Vue is superset of TypeScript/JavaScript) case self.VUE: return 1 # regular languages case _: return 2 def get_source_fn_matcher(self) -> FilenameMatcher: match self: case self.PYTHON | self.PYTHON_JEDI: return FilenameMatcher("*.py", "*.pyi") case self.JAVA: return FilenameMatcher("*.java") case self.TYPESCRIPT | self.TYPESCRIPT_VTS: # see https://github.com/oraios/serena/issues/204 path_patterns = [] for prefix in ["c", "m", ""]: for postfix in ["x", ""]: for base_pattern in ["ts", "js"]: path_patterns.append(f"*.{prefix}{base_pattern}{postfix}") return FilenameMatcher(*path_patterns) case self.CSHARP | self.CSHARP_OMNISHARP: return FilenameMatcher("*.cs") case self.RUST: return FilenameMatcher("*.rs") case self.GO: return FilenameMatcher("*.go") case self.RUBY: return FilenameMatcher("*.rb", "*.erb") case self.RUBY_SOLARGRAPH: return FilenameMatcher("*.rb") case self.CPP | self.CPP_CCLS: return FilenameMatcher("*.cpp", "*.h", "*.hpp", "*.c", "*.hxx", "*.cc", "*.cxx") case self.KOTLIN: return FilenameMatcher("*.kt", "*.kts") case self.DART: return FilenameMatcher("*.dart") case self.PHP | self.PHP_PHPACTOR: return FilenameMatcher("*.php") case self.R: return FilenameMatcher("*.R", "*.r", "*.Rmd", "*.Rnw") case self.PERL: return FilenameMatcher("*.pl", "*.pm", "*.t") case self.CLOJURE: return FilenameMatcher("*.clj", "*.cljs", "*.cljc", "*.edn") # codespell:ignore edn case self.ELIXIR: return FilenameMatcher("*.ex", "*.exs") case self.ELM: return FilenameMatcher("*.elm") case self.TERRAFORM: return FilenameMatcher("*.tf", "*.tfvars", "*.tfstate") case self.SWIFT: return FilenameMatcher("*.swift") case self.BASH: return FilenameMatcher("*.sh", "*.bash") case self.YAML: return FilenameMatcher("*.yaml", "*.yml") case self.TOML: return FilenameMatcher("*.toml") case self.ZIG: return FilenameMatcher("*.zig", "*.zon") case self.LUA: return FilenameMatcher("*.lua") case self.LUAU: return FilenameMatcher("*.luau") case self.NIX: return FilenameMatcher("*.nix") case self.ERLANG: return FilenameMatcher("*.erl", "*.hrl", "*.escript", "*.config", "*.app", "*.app.src") case self.OCAML: return FilenameMatcher("*.ml", "*.mli", "*.re", "*.rei") case self.AL: return FilenameMatcher("*.al", "*.dal") case self.FSHARP: return FilenameMatcher("*.fs", "*.fsx", "*.fsi") case self.REGO: return FilenameMatcher("*.rego") case self.MARKDOWN: return FilenameMatcher("*.md", "*.markdown") case self.SCALA: return FilenameMatcher("*.scala", "*.sbt") case self.JULIA: return FilenameMatcher("*.jl") case self.FORTRAN: return FilenameMatcher( "*.f90", "*.F90", "*.f95", "*.F95", "*.f03", "*.F03", "*.f08", "*.F08", "*.f", "*.F", "*.for", "*.FOR", "*.fpp", "*.FPP" ) case self.HASKELL: return FilenameMatcher("*.hs", "*.lhs") case self.LEAN4: return FilenameMatcher("*.lean") case self.VUE: path_patterns = ["*.vue"] for prefix in ["c", "m", ""]: for postfix in ["x", ""]: for base_pattern in ["ts", "js"]: path_patterns.append(f"*.{prefix}{base_pattern}{postfix}") return FilenameMatcher(*path_patterns) case self.POWERSHELL: return FilenameMatcher("*.ps1", "*.psm1", "*.psd1") case self.PASCAL: return FilenameMatcher("*.pas", "*.pp", "*.lpr", "*.dpr", "*.dpk", "*.inc") case self.GROOVY: return FilenameMatcher("*.groovy", "*.gvy") case self.MATLAB: return FilenameMatcher("*.m", "*.mlx", "*.mlapp") case self.HLSL: return FilenameMatcher( "*.hlsl", "*.hlsli", "*.fx", "*.fxh", "*.cginc", "*.compute", "*.shader", "*.glsl", "*.vert", "*.frag", "*.geom", "*.tesc", "*.tese", "*.comp", "*.wgsl", ) case self.SYSTEMVERILOG: return FilenameMatcher("*.sv", "*.svh", "*.v", "*.vh") case self.SOLIDITY: return FilenameMatcher("*.sol") case self.ANSIBLE: return FilenameMatcher("*.yaml", "*.yml") case _: raise ValueError(f"Unhandled language: {self}") def get_ls_class(self) -> type["SolidLanguageServer"]: match self: case self.PYTHON: from solidlsp.language_servers.pyright_server import PyrightServer return PyrightServer case self.PYTHON_JEDI: from solidlsp.language_servers.jedi_server import JediServer return JediServer case self.JAVA: from solidlsp.language_servers.eclipse_jdtls import EclipseJDTLS return EclipseJDTLS case self.KOTLIN: from solidlsp.language_servers.kotlin_language_server import KotlinLanguageServer return KotlinLanguageServer case self.RUST: from solidlsp.language_servers.rust_analyzer import RustAnalyzer return RustAnalyzer case self.CSHARP: from solidlsp.language_servers.csharp_language_server import CSharpLanguageServer return CSharpLanguageServer case self.CSHARP_OMNISHARP: from solidlsp.language_servers.omnisharp import OmniSharp return OmniSharp case self.TYPESCRIPT: from solidlsp.language_servers.typescript_language_server import TypeScriptLanguageServer return TypeScriptLanguageServer case self.TYPESCRIPT_VTS: from solidlsp.language_servers.vts_language_server import VtsLanguageServer return VtsLanguageServer case self.VUE: from solidlsp.language_servers.vue_language_server import VueLanguageServer return VueLanguageServer case self.GO: from solidlsp.language_servers.gopls import Gopls return Gopls case self.RUBY: from solidlsp.language_servers.ruby_lsp import RubyLsp return RubyLsp case self.RUBY_SOLARGRAPH: from solidlsp.language_servers.solargraph import Solargraph return Solargraph case self.DART: from solidlsp.language_servers.dart_language_server import DartLanguageServer return DartLanguageServer case self.CPP: from solidlsp.language_servers.clangd_language_server import ClangdLanguageServer return ClangdLanguageServer case self.CPP_CCLS: from solidlsp.language_servers.ccls_language_server import CCLS return CCLS case self.PHP: from solidlsp.language_servers.intelephense import Intelephense return Intelephense case self.PHP_PHPACTOR: from solidlsp.language_servers.phpactor import PhpactorServer return PhpactorServer case self.PERL: from solidlsp.language_servers.perl_language_server import PerlLanguageServer return PerlLanguageServer case self.CLOJURE: from solidlsp.language_servers.clojure_lsp import ClojureLSP return ClojureLSP case self.ELIXIR: from solidlsp.language_servers.elixir_tools.elixir_tools import ElixirTools return ElixirTools case self.ELM: from solidlsp.language_servers.elm_language_server import ElmLanguageServer return ElmLanguageServer case self.TERRAFORM: from solidlsp.language_servers.terraform_ls import TerraformLS return TerraformLS case self.SWIFT: from solidlsp.language_servers.sourcekit_lsp import SourceKitLSP return SourceKitLSP case self.BASH: from solidlsp.language_servers.bash_language_server import BashLanguageServer return BashLanguageServer case self.YAML: from solidlsp.language_servers.yaml_language_server import YamlLanguageServer return YamlLanguageServer case self.TOML: from solidlsp.language_servers.taplo_server import TaploServer return TaploServer case self.ZIG: from solidlsp.language_servers.zls import ZigLanguageServer return ZigLanguageServer case self.NIX: from solidlsp.language_servers.nixd_ls import NixLanguageServer # type: ignore return NixLanguageServer case self.LUA: from solidlsp.language_servers.lua_ls import LuaLanguageServer return LuaLanguageServer case self.LUAU: from solidlsp.language_servers.luau_lsp import LuauLanguageServer return LuauLanguageServer case self.ERLANG: from solidlsp.language_servers.erlang_language_server import ErlangLanguageServer return ErlangLanguageServer case self.OCAML: from solidlsp.language_servers.ocaml_lsp_server import OcamlLanguageServer return OcamlLanguageServer case self.AL: from solidlsp.language_servers.al_language_server import ALLanguageServer return ALLanguageServer case self.REGO: from solidlsp.language_servers.regal_server import RegalLanguageServer return RegalLanguageServer case self.MARKDOWN: from solidlsp.language_servers.marksman import Marksman return Marksman case self.R: from solidlsp.language_servers.r_language_server import RLanguageServer return RLanguageServer case self.SCALA: from solidlsp.language_servers.scala_language_server import ScalaLanguageServer return ScalaLanguageServer case self.JULIA: from solidlsp.language_servers.julia_server import JuliaLanguageServer return JuliaLanguageServer case self.FORTRAN: from solidlsp.language_servers.fortran_language_server import FortranLanguageServer return FortranLanguageServer case self.HASKELL: from solidlsp.language_servers.haskell_language_server import HaskellLanguageServer return HaskellLanguageServer case self.LEAN4: from solidlsp.language_servers.lean4_language_server import Lean4LanguageServer return Lean4LanguageServer case self.FSHARP: from solidlsp.language_servers.fsharp_language_server import FSharpLanguageServer return FSharpLanguageServer case self.POWERSHELL: from solidlsp.language_servers.powershell_language_server import PowerShellLanguageServer return PowerShellLanguageServer case self.PASCAL: from solidlsp.language_servers.pascal_server import PascalLanguageServer return PascalLanguageServer case self.GROOVY: from solidlsp.language_servers.groovy_language_server import GroovyLanguageServer return GroovyLanguageServer case self.MATLAB: from solidlsp.language_servers.matlab_language_server import MatlabLanguageServer return MatlabLanguageServer case self.HLSL: from solidlsp.language_servers.hlsl_language_server import HlslLanguageServer return HlslLanguageServer case self.SYSTEMVERILOG: from solidlsp.language_servers.systemverilog_server import SystemVerilogLanguageServer return SystemVerilogLanguageServer case self.SOLIDITY: from solidlsp.language_servers.solidity_language_server import SolidityLanguageServer return SolidityLanguageServer case self.ANSIBLE: from solidlsp.language_servers.ansible_language_server import AnsibleLanguageServer return AnsibleLanguageServer case _: raise ValueError(f"Unhandled language: {self}") @classmethod def from_ls_class(cls, ls_class: type["SolidLanguageServer"]) -> Self: """ Get the Language enum value from a SolidLanguageServer class. :param ls_class: The SolidLanguageServer class to find the corresponding Language for :return: The Language enum value :raises ValueError: If the language server class is not supported """ for enum_instance in cls: if enum_instance.get_ls_class() == ls_class: return enum_instance raise ValueError(f"Unhandled language server class: {ls_class}") @dataclass class LanguageServerConfig: """ Configuration parameters """ code_language: Language trace_lsp_communication: bool = False start_independent_lsp_process: bool = True ignored_paths: list[str] = field(default_factory=list) """Paths, dirs or glob-like patterns. The matching will follow the same logic as for .gitignore entries""" encoding: str = "utf-8" """File encoding to use when reading source files""" @classmethod def from_dict(cls, env: dict) -> Self: import inspect return cls(**{k: v for k, v in env.items() if k in inspect.signature(cls).parameters}) ================================================ FILE: src/solidlsp/ls_exceptions.py ================================================ """ This module contains the exceptions raised by the framework. """ from solidlsp.ls_config import Language class SolidLSPException(Exception): def __init__(self, message: str, cause: Exception | None = None) -> None: """ Initializes the exception with the given message. :param message: the message describing the exception :param cause: the original exception that caused this exception, if any. For exceptions raised during request handling, this is typically * an LSPError for errors returned by the LSP server * LanguageServerTerminatedException for errors due to the language server having terminated. """ self.cause = cause super().__init__(message) def is_language_server_terminated(self) -> bool: """ :return: True if the exception is caused by the language server having terminated as indicated by the causing exception being an instance of LanguageServerTerminatedException. """ from .ls_process import LanguageServerTerminatedException return isinstance(self.cause, LanguageServerTerminatedException) def get_affected_language(self) -> Language | None: """ :return: the affected language for the case where the exception is caused by the language server having terminated """ from .ls_process import LanguageServerTerminatedException if isinstance(self.cause, LanguageServerTerminatedException): return self.cause.language return None def __str__(self) -> str: """ Returns a string representation of the exception. """ s = super().__str__() if self.cause: if "\n" in s: s += "\n" else: s += " " s += f"(caused by {self.cause})" return s class MetalsStaleLockError(SolidLSPException): """ Raised when a stale Metals H2 database lock is detected and the user has configured fail-on-stale-lock behavior. A stale lock occurs when a previous Metals process crashed without cleaning up its lock file, which can prevent proper AUTO_SERVER coordination with new instances. """ def __init__(self, lock_path: str, message: str | None = None) -> None: self.lock_path = lock_path if message is None: message = ( f"Stale Metals lock file detected at {lock_path}. " "A previous Metals process may have crashed. " "To resolve: remove the lock file manually, or set " "on_stale_lock='auto-clean' in ls_specific_settings.scala." ) super().__init__(message) ================================================ FILE: src/solidlsp/ls_process.py ================================================ import asyncio import json import logging import os import platform import subprocess import threading import time from collections.abc import Callable from dataclasses import dataclass from queue import Empty, Queue from typing import Any import psutil from sensai.util.string import ToStringMixin from solidlsp.ls_config import Language from solidlsp.ls_exceptions import SolidLSPException from solidlsp.ls_request import LanguageServerRequest from solidlsp.lsp_protocol_handler.lsp_requests import LspNotification from solidlsp.lsp_protocol_handler.lsp_types import ErrorCodes from solidlsp.lsp_protocol_handler.server import ( ENCODING, LSPError, PayloadLike, ProcessLaunchInfo, StringDict, content_length, create_message, make_error_response, make_notification, make_request, make_response, ) from solidlsp.util.subprocess_util import quote_arg, subprocess_kwargs log = logging.getLogger(__name__) class LanguageServerTerminatedException(Exception): """ Exception raised when the language server process has terminated unexpectedly. """ def __init__(self, message: str, language: Language, cause: Exception | None = None) -> None: super().__init__(message) self.message = message self.language = language self.cause = cause def __str__(self) -> str: return f"LanguageServerTerminatedException: {self.message}" + (f"; Cause: {self.cause}" if self.cause else "") class Request(ToStringMixin): @dataclass class Result: payload: PayloadLike | None = None error: Exception | None = None def is_error(self) -> bool: return self.error is not None def __init__(self, request_id: int, method: str) -> None: self._request_id = request_id self._method = method self._status = "pending" self._result_queue: Queue[Request.Result] = Queue() def _tostring_includes(self) -> list[str]: return ["_request_id", "_status", "_method"] def on_result(self, params: PayloadLike) -> None: self._status = "completed" self._result_queue.put(Request.Result(payload=params)) def on_error(self, err: Exception) -> None: """ :param err: the error that occurred while processing the request (typically an LSPError for errors returned by the LS or LanguageServerTerminatedException if the error is due to the language server process terminating unexpectedly). """ self._status = "error" self._result_queue.put(Request.Result(error=err)) def get_result(self, timeout: float | None = None) -> Result: try: return self._result_queue.get(timeout=timeout) except Empty as e: if timeout is not None: raise TimeoutError(f"Request timed out ({timeout=})") from e raise e class LanguageServerProcess: """ Represents a language server process and provides methods for communicating with it using the Language Server Protocol (LSP). It provides methods for sending requests, responses, and notifications to the server and for registering handlers for requests and notifications from the server. Uses JSON-RPC 2.0 for communication with the server over stdin/stdout. Attributes: send: A LspRequest object that can be used to send requests to the server and await for the responses. notify: A LspNotification object that can be used to send notifications to the server. cmd: A string that represents the command to launch the language server process. process: A subprocess.Popen object that represents the language server process. request_id: An integer that represents the next available request id for the client. _pending_requests: A dictionary that maps request ids to Request objects that store the results or errors of the requests. on_request_handlers: A dictionary that maps method names to callback functions that handle requests from the server. on_notification_handlers: A dictionary that maps method names to callback functions that handle notifications from the server. _trace_log_fn: An optional function that takes two strings (source and destination) and a payload dictionary, and logs the communication between the client and the server. tasks: A dictionary that maps task ids to asyncio.Task objects that represent the asynchronous tasks created by the handler. task_counter: An integer that represents the next available task id for the handler. loop: An asyncio.AbstractEventLoop object that represents the event loop used by the handler. start_independent_lsp_process: An optional boolean flag that indicates whether to start the language server process in an independent process group. Default is `True`. Setting it to `False` means that the language server process will be in the same process group as the the current process, and any SIGINT and SIGTERM signals will be sent to both processes. """ def __init__( self, process_launch_info: ProcessLaunchInfo, language: Language, determine_log_level: Callable[[str], int], logger: Callable[[str, str, StringDict | str], None] | None = None, start_independent_lsp_process: bool = True, request_timeout: float | None = None, ) -> None: self.language = language self._determine_log_level = determine_log_level self.send = LanguageServerRequest(self) self.notify = LspNotification(self.send_notification) self.process_launch_info = process_launch_info self.process: subprocess.Popen[bytes] | None = None self._is_shutting_down = False self.request_id = 1 self._pending_requests: dict[Any, Request] = {} self.on_request_handlers: dict[str, Callable[[Any], Any]] = {} self.on_notification_handlers: dict[str, Callable[[Any], None]] = {} self._trace_log_fn = logger self.tasks: dict[int, Any] = {} self.task_counter = 0 self.loop = None self.start_independent_lsp_process = start_independent_lsp_process self._request_timeout = request_timeout # Add thread locks for shared resources to prevent race conditions self._stdin_lock = threading.Lock() self._request_id_lock = threading.Lock() self._response_handlers_lock = threading.Lock() self._tasks_lock = threading.Lock() def set_request_timeout(self, timeout: float | None) -> None: """ :param timeout: the timeout, in seconds, for all requests sent to the language server. """ self._request_timeout = timeout def is_running(self) -> bool: """ Checks if the language server process is currently running. """ return self.process is not None and self.process.returncode is None def start(self) -> None: """ Starts the language server process and creates a task to continuously read from its stdout to handle communications from the server to the client """ child_proc_env = os.environ.copy() child_proc_env.update(self.process_launch_info.env) cmd = self.process_launch_info.cmd is_windows = platform.system() == "Windows" if not isinstance(cmd, str) and not is_windows: # Since we are using the shell, we need to convert the command list to a single string # on Linux/macOS cmd = " ".join(map(quote_arg, cmd)) log.info("Starting language server process via command: %s", self.process_launch_info.cmd) kwargs = subprocess_kwargs() kwargs["start_new_session"] = self.start_independent_lsp_process self.process = subprocess.Popen( cmd, stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.PIPE, env=child_proc_env, cwd=self.process_launch_info.cwd, shell=True, **kwargs, ) # Check if process terminated immediately if self.process.returncode is not None: log.error("Language server has already terminated/could not be started") # Process has already terminated stderr_data = self.process.stderr.read() if self.process.stderr else b"" error_message = stderr_data.decode("utf-8", errors="replace") raise RuntimeError(f"Process terminated immediately with code {self.process.returncode}. Error: {error_message}") # start threads to read stdout and stderr of the process threading.Thread( target=self._read_ls_process_stdout, name=f"LSP-stdout-reader:{self.language.value}", daemon=True, ).start() threading.Thread( target=self._read_ls_process_stderr, name=f"LSP-stderr-reader:{self.language.value}", daemon=True, ).start() def stop(self) -> None: """ Sends the terminate signal to the language server process and waits for it to exit, with a timeout, killing it if necessary """ process = self.process self.process = None if process: self._cleanup_process(process) def _cleanup_process(self, process: subprocess.Popen[bytes]) -> None: """Clean up a process: close stdin, terminate/kill process, close stdout/stderr.""" # Close stdin first to prevent deadlocks # See: https://bugs.python.org/issue35539 self._safely_close_pipe(process.stdin) # Terminate/kill the process if it's still running if process.returncode is None: self._terminate_or_kill_process(process) # Close stdout and stderr pipes after process has exited # This is essential to prevent "I/O operation on closed pipe" errors and # "Event loop is closed" errors during garbage collection # See: https://bugs.python.org/issue41320 and https://github.com/python/cpython/issues/88050 self._safely_close_pipe(process.stdout) self._safely_close_pipe(process.stderr) def _safely_close_pipe(self, pipe: Any) -> None: """Safely close a pipe, ignoring any exceptions.""" if pipe: try: pipe.close() except Exception: pass def _terminate_or_kill_process(self, process: subprocess.Popen[bytes]) -> None: """Try to terminate the process gracefully, then forcefully if necessary.""" # First try to terminate the process tree gracefully self._signal_process_tree(process, terminate=True) def _signal_process_tree(self, process: subprocess.Popen[bytes], terminate: bool = True) -> None: """Send signal (terminate or kill) to the process and all its children.""" signal_method = "terminate" if terminate else "kill" # Try to get the parent process parent = None try: parent = psutil.Process(process.pid) except (psutil.NoSuchProcess, psutil.AccessDenied, Exception): pass # If we have the parent process and it's running, signal the entire tree if parent and parent.is_running(): # Signal children first for child in parent.children(recursive=True): try: getattr(child, signal_method)() except (psutil.NoSuchProcess, psutil.AccessDenied, Exception): pass # Then signal the parent try: getattr(parent, signal_method)() except (psutil.NoSuchProcess, psutil.AccessDenied, Exception): pass else: # Fall back to direct process signaling try: getattr(process, signal_method)() except Exception: pass def shutdown(self) -> None: """ Perform the shutdown sequence for the client, including sending the shutdown request to the server and notifying it of exit """ self._is_shutting_down = True log.info("Sending shutdown request to server") self.send.shutdown() log.info("Received shutdown response from server") log.info("Sending exit notification to server") self.notify.exit() log.info("Sent exit notification to server") def _trace(self, src: str, dest: str, message: str | StringDict) -> None: """ Traces LS communication by logging the message with the source and destination of the message """ if self._trace_log_fn is not None: self._trace_log_fn(src, dest, message) def _read_bytes_from_process(self, process, stream, num_bytes) -> bytes: # type: ignore """Read exactly num_bytes from process stdout""" data = b"" while len(data) < num_bytes: chunk = stream.read(num_bytes - len(data)) if not chunk: if process.poll() is not None: raise LanguageServerTerminatedException( f"Process terminated while trying to read response (read {num_bytes} of {len(data)} bytes before termination)", language=self.language, ) # Process still running but no data available yet, retry after a short delay time.sleep(0.01) continue data += chunk return data def _read_ls_process_stdout(self) -> None: """ Continuously read from the language server process stdout and handle the messages invoking the registered response and notification handlers """ exception: Exception | None = None try: while self.process and self.process.stdout: if self.process.poll() is not None: # process has terminated break line = self.process.stdout.readline() if not line: continue try: num_bytes = content_length(line) except ValueError: continue if num_bytes is None: continue while line and line.strip(): line = self.process.stdout.readline() if not line: continue body = self._read_bytes_from_process(self.process, self.process.stdout, num_bytes) self._handle_body(body) except LanguageServerTerminatedException as e: exception = e except (BrokenPipeError, ConnectionResetError) as e: exception = LanguageServerTerminatedException("Language server process terminated while reading stdout", self.language, cause=e) except Exception as e: exception = LanguageServerTerminatedException( "Unexpected error while reading stdout from language server process", self.language, cause=e ) log.info("Language server stdout reader thread has terminated") if not self._is_shutting_down: if exception is None: exception = LanguageServerTerminatedException("Language server stdout read process terminated unexpectedly", self.language) log.error(str(exception)) self._cancel_pending_requests(exception) def _read_ls_process_stderr(self) -> None: """ Continuously read from the language server process stderr and log the messages """ try: while self.process and self.process.stderr: if self.process.poll() is not None: # process has terminated break line = self.process.stderr.readline() if not line: continue line_str = line.decode(ENCODING, errors="replace") level = self._determine_log_level(line_str) log.log(level, line_str) except Exception as e: log.error("Error while reading stderr from language server process: %s", e, exc_info=e) if not self._is_shutting_down: log.error("Language server stderr reader thread terminated unexpectedly") else: log.info("Language server stderr reader thread has terminated") def _handle_body(self, body: bytes) -> None: """ Parse the body text received from the language server process and invoke the appropriate handler """ try: self._receive_payload(json.loads(body)) except OSError as ex: log.error(f"Error processing payload: {ex}", exc_info=ex) except UnicodeDecodeError as ex: log.error(f"Decoding error for encoding={ENCODING}: {ex}") except json.JSONDecodeError as ex: log.error(f"JSON decoding error: {ex}") def _receive_payload(self, payload: StringDict) -> None: """ Determine if the payload received from server is for a request, response, or notification and invoke the appropriate handler """ self._trace("ls", "solidlsp", payload) try: if "method" in payload: if "id" in payload: self._request_handler(payload) else: self._notification_handler(payload) elif "id" in payload: self._response_handler(payload) else: log.error(f"Unknown payload type: {payload}") except Exception as err: log.error(f"Error handling server payload: {err}") def send_notification(self, method: str, params: dict | None = None) -> None: """ Send notification pertaining to the given method to the server with the given parameters """ self._send_payload(make_notification(method, params)) def send_response(self, request_id: Any, params: PayloadLike) -> None: """ Send response to the given request id to the server with the given parameters """ self._send_payload(make_response(request_id, params)) def send_error_response(self, request_id: Any, err: LSPError) -> None: """ Send error response to the given request id to the server with the given error """ self._send_payload(make_error_response(request_id, err)) def _cancel_pending_requests(self, exception: Exception) -> None: """ Cancel all pending requests by setting their results to an error """ with self._response_handlers_lock: log.info("Cancelling %d pending language server requests", len(self._pending_requests)) for request in self._pending_requests.values(): log.info("Cancelling %s", request) request.on_error(exception) self._pending_requests.clear() def send_request(self, method: str, params: dict | None = None) -> PayloadLike: """ Send request to the server, register the request id, and wait for the response """ with self._request_id_lock: request_id = self.request_id self.request_id += 1 request = Request(request_id=request_id, method=method) log.debug("Starting: %s", request) with self._response_handlers_lock: self._pending_requests[request_id] = request self._send_payload(make_request(method, request_id, params)) log.debug("Waiting for response to request %s with params:\n%s", method, params) result = request.get_result(timeout=self._request_timeout) log.debug("Completed: %s", request) if result.is_error(): raise SolidLSPException(f"Error processing request {method} with params:\n{params}", cause=result.error) from result.error log.debug("Returning result:\n%s", result.payload) return result.payload def _send_payload(self, payload: StringDict) -> None: """ Send the payload to the server by writing to its stdin asynchronously. """ if not self.process or not self.process.stdin: return self._trace("solidlsp", "ls", payload) msg = create_message(payload) # Use lock to prevent concurrent writes to stdin that cause buffer corruption with self._stdin_lock: try: self.process.stdin.writelines(msg) self.process.stdin.flush() except (BrokenPipeError, ConnectionResetError, OSError) as e: # Log the error but don't raise to prevent cascading failures log.error(f"Failed to write to stdin: {e}") return def on_request(self, method: str, cb: Callable[[Any], Any]) -> None: """ Register the callback function to handle requests from the server to the client for the given method """ self.on_request_handlers[method] = cb def on_notification(self, method: str, cb: Callable[[Any], None]) -> None: """ Register the callback function to handle notifications from the server to the client for the given method """ self.on_notification_handlers[method] = cb def _response_handler(self, response: StringDict) -> None: """ Handle the response received from the server for a request, using the id to determine the request """ response_id = response["id"] with self._response_handlers_lock: request = self._pending_requests.pop(response_id, None) if request is None and isinstance(response_id, str) and response_id.isdigit(): request = self._pending_requests.pop(int(response_id), None) if request is None: # need to convert response_id to the right type log.debug("Request interrupted by user or not found for ID %s", response_id) return if "result" in response and "error" not in response: request.on_result(response["result"]) elif "result" not in response and "error" in response: request.on_error(LSPError.from_lsp(response["error"])) else: request.on_error(LSPError(ErrorCodes.InvalidRequest, "")) def _request_handler(self, response: StringDict) -> None: """ Handle the request received from the server: call the appropriate callback function and return the result """ method = response.get("method", "") params = response.get("params") request_id = response.get("id") handler = self.on_request_handlers.get(method) if not handler: self.send_error_response( request_id, LSPError( ErrorCodes.MethodNotFound, f"method '{method}' not handled on client.", ), ) return try: self.send_response(request_id, handler(params)) except LSPError as ex: self.send_error_response(request_id, ex) except Exception as ex: self.send_error_response(request_id, LSPError(ErrorCodes.InternalError, str(ex))) def _notification_handler(self, response: StringDict) -> None: """ Handle the notification received from the server: call the appropriate callback function """ method = response.get("method", "") params = response.get("params") handler = self.on_notification_handlers.get(method) if not handler: log.warning("Unhandled method '%s'", method) return try: handler(params) except asyncio.CancelledError: return except Exception as ex: if not self._is_shutting_down: log.error("Error handling notification for method '%s': %s", method, ex, exc_info=ex) ================================================ FILE: src/solidlsp/ls_request.py ================================================ from typing import TYPE_CHECKING, Any, Union from solidlsp.lsp_protocol_handler import lsp_types if TYPE_CHECKING: from .ls_process import LanguageServerProcess class LanguageServerRequest: def __init__(self, handler: "LanguageServerProcess"): self.handler = handler def _send_request(self, method: str, params: Any | None = None) -> Any: return self.handler.send_request(method, params) def implementation(self, params: lsp_types.ImplementationParams) -> Union["lsp_types.Definition", list["lsp_types.LocationLink"], None]: """A request to resolve the implementation locations of a symbol at a given text document position. The request's parameter is of type [TextDocumentPositionParams] (#TextDocumentPositionParams) the response is of type {@link Definition} or a Thenable that resolves to such. """ return self._send_request("textDocument/implementation", params) def type_definition( self, params: lsp_types.TypeDefinitionParams ) -> Union["lsp_types.Definition", list["lsp_types.LocationLink"], None]: """A request to resolve the type definition locations of a symbol at a given text document position. The request's parameter is of type [TextDocumentPositionParams] (#TextDocumentPositionParams) the response is of type {@link Definition} or a Thenable that resolves to such. """ return self._send_request("textDocument/typeDefinition", params) def document_color(self, params: lsp_types.DocumentColorParams) -> list["lsp_types.ColorInformation"]: """A request to list all color symbols found in a given text document. The request's parameter is of type {@link DocumentColorParams} the response is of type {@link ColorInformation ColorInformation[]} or a Thenable that resolves to such. """ return self._send_request("textDocument/documentColor", params) def color_presentation(self, params: lsp_types.ColorPresentationParams) -> list["lsp_types.ColorPresentation"]: """A request to list all presentation for a color. The request's parameter is of type {@link ColorPresentationParams} the response is of type {@link ColorInformation ColorInformation[]} or a Thenable that resolves to such. """ return self._send_request("textDocument/colorPresentation", params) def folding_range(self, params: lsp_types.FoldingRangeParams) -> list["lsp_types.FoldingRange"] | None: """A request to provide folding ranges in a document. The request's parameter is of type {@link FoldingRangeParams}, the response is of type {@link FoldingRangeList} or a Thenable that resolves to such. """ return self._send_request("textDocument/foldingRange", params) def declaration(self, params: lsp_types.DeclarationParams) -> Union["lsp_types.Declaration", list["lsp_types.LocationLink"], None]: """A request to resolve the type definition locations of a symbol at a given text document position. The request's parameter is of type [TextDocumentPositionParams] (#TextDocumentPositionParams) the response is of type {@link Declaration} or a typed array of {@link DeclarationLink} or a Thenable that resolves to such. """ return self._send_request("textDocument/declaration", params) def selection_range(self, params: lsp_types.SelectionRangeParams) -> list["lsp_types.SelectionRange"] | None: """A request to provide selection ranges in a document. The request's parameter is of type {@link SelectionRangeParams}, the response is of type {@link SelectionRange SelectionRange[]} or a Thenable that resolves to such. """ return self._send_request("textDocument/selectionRange", params) def prepare_call_hierarchy(self, params: lsp_types.CallHierarchyPrepareParams) -> list["lsp_types.CallHierarchyItem"] | None: """A request to result a `CallHierarchyItem` in a document at a given position. Can be used as an input to an incoming or outgoing call hierarchy. @since 3.16.0 """ return self._send_request("textDocument/prepareCallHierarchy", params) def incoming_calls(self, params: lsp_types.CallHierarchyIncomingCallsParams) -> list["lsp_types.CallHierarchyIncomingCall"] | None: """A request to resolve the incoming calls for a given `CallHierarchyItem`. @since 3.16.0 """ return self._send_request("callHierarchy/incomingCalls", params) def outgoing_calls(self, params: lsp_types.CallHierarchyOutgoingCallsParams) -> list["lsp_types.CallHierarchyOutgoingCall"] | None: """A request to resolve the outgoing calls for a given `CallHierarchyItem`. @since 3.16.0 """ return self._send_request("callHierarchy/outgoingCalls", params) def semantic_tokens_full(self, params: lsp_types.SemanticTokensParams) -> Union["lsp_types.SemanticTokens", None]: """@since 3.16.0""" return self._send_request("textDocument/semanticTokens/full", params) def semantic_tokens_delta( self, params: lsp_types.SemanticTokensDeltaParams ) -> Union["lsp_types.SemanticTokens", "lsp_types.SemanticTokensDelta", None]: """@since 3.16.0""" return self._send_request("textDocument/semanticTokens/full/delta", params) def semantic_tokens_range(self, params: lsp_types.SemanticTokensRangeParams) -> Union["lsp_types.SemanticTokens", None]: """@since 3.16.0""" return self._send_request("textDocument/semanticTokens/range", params) def linked_editing_range(self, params: lsp_types.LinkedEditingRangeParams) -> Union["lsp_types.LinkedEditingRanges", None]: """A request to provide ranges that can be edited together. @since 3.16.0 """ return self._send_request("textDocument/linkedEditingRange", params) def will_create_files(self, params: lsp_types.CreateFilesParams) -> Union["lsp_types.WorkspaceEdit", None]: """The will create files request is sent from the client to the server before files are actually created as long as the creation is triggered from within the client. @since 3.16.0 """ return self._send_request("workspace/willCreateFiles", params) def will_rename_files(self, params: lsp_types.RenameFilesParams) -> Union["lsp_types.WorkspaceEdit", None]: """The will rename files request is sent from the client to the server before files are actually renamed as long as the rename is triggered from within the client. @since 3.16.0 """ return self._send_request("workspace/willRenameFiles", params) def will_delete_files(self, params: lsp_types.DeleteFilesParams) -> Union["lsp_types.WorkspaceEdit", None]: """The did delete files notification is sent from the client to the server when files were deleted from within the client. @since 3.16.0 """ return self._send_request("workspace/willDeleteFiles", params) def moniker(self, params: lsp_types.MonikerParams) -> list["lsp_types.Moniker"] | None: """A request to get the moniker of a symbol at a given text document position. The request parameter is of type {@link TextDocumentPositionParams}. The response is of type {@link Moniker Moniker[]} or `null`. """ return self._send_request("textDocument/moniker", params) def prepare_type_hierarchy(self, params: lsp_types.TypeHierarchyPrepareParams) -> list["lsp_types.TypeHierarchyItem"] | None: """A request to result a `TypeHierarchyItem` in a document at a given position. Can be used as an input to a subtypes or supertypes type hierarchy. @since 3.17.0 """ return self._send_request("textDocument/prepareTypeHierarchy", params) def type_hierarchy_supertypes(self, params: lsp_types.TypeHierarchySupertypesParams) -> list["lsp_types.TypeHierarchyItem"] | None: """A request to resolve the supertypes for a given `TypeHierarchyItem`. @since 3.17.0 """ return self._send_request("typeHierarchy/supertypes", params) def type_hierarchy_subtypes(self, params: lsp_types.TypeHierarchySubtypesParams) -> list["lsp_types.TypeHierarchyItem"] | None: """A request to resolve the subtypes for a given `TypeHierarchyItem`. @since 3.17.0 """ return self._send_request("typeHierarchy/subtypes", params) def inline_value(self, params: lsp_types.InlineValueParams) -> list["lsp_types.InlineValue"] | None: """A request to provide inline values in a document. The request's parameter is of type {@link InlineValueParams}, the response is of type {@link InlineValue InlineValue[]} or a Thenable that resolves to such. @since 3.17.0 """ return self._send_request("textDocument/inlineValue", params) def inlay_hint(self, params: lsp_types.InlayHintParams) -> list["lsp_types.InlayHint"] | None: """A request to provide inlay hints in a document. The request's parameter is of type {@link InlayHintsParams}, the response is of type {@link InlayHint InlayHint[]} or a Thenable that resolves to such. @since 3.17.0 """ return self._send_request("textDocument/inlayHint", params) def resolve_inlay_hint(self, params: lsp_types.InlayHint) -> "lsp_types.InlayHint": """A request to resolve additional properties for an inlay hint. The request's parameter is of type {@link InlayHint}, the response is of type {@link InlayHint} or a Thenable that resolves to such. @since 3.17.0 """ return self._send_request("inlayHint/resolve", params) def text_document_diagnostic(self, params: lsp_types.DocumentDiagnosticParams) -> "lsp_types.DocumentDiagnosticReport": """The document diagnostic request definition. @since 3.17.0 """ return self._send_request("textDocument/diagnostic", params) def workspace_diagnostic(self, params: lsp_types.WorkspaceDiagnosticParams) -> "lsp_types.WorkspaceDiagnosticReport": """The workspace diagnostic request definition. @since 3.17.0 """ return self._send_request("workspace/diagnostic", params) def initialize(self, params: lsp_types.InitializeParams) -> "lsp_types.InitializeResult": """The initialize request is sent from the client to the server. It is sent once as the request after starting up the server. The requests parameter is of type {@link InitializeParams} the response if of type {@link InitializeResult} of a Thenable that resolves to such. """ return self._send_request("initialize", params) def shutdown(self) -> None: """A shutdown request is sent from the client to the server. It is sent once when the client decides to shutdown the server. The only notification that is sent after a shutdown request is the exit event. """ return self._send_request("shutdown") def will_save_wait_until(self, params: lsp_types.WillSaveTextDocumentParams) -> list["lsp_types.TextEdit"] | None: """A document will save request is sent from the client to the server before the document is actually saved. The request can return an array of TextEdits which will be applied to the text document before it is saved. Please note that clients might drop results if computing the text edits took too long or if a server constantly fails on this request. This is done to keep the save fast and reliable. """ return self._send_request("textDocument/willSaveWaitUntil", params) def completion(self, params: lsp_types.CompletionParams) -> Union[list["lsp_types.CompletionItem"], "lsp_types.CompletionList", None]: """Request to request completion at a given text document position. The request's parameter is of type {@link TextDocumentPosition} the response is of type {@link CompletionItem CompletionItem[]} or {@link CompletionList} or a Thenable that resolves to such. The request can delay the computation of the {@link CompletionItem.detail `detail`} and {@link CompletionItem.documentation `documentation`} properties to the `completionItem/resolve` request. However, properties that are needed for the initial sorting and filtering, like `sortText`, `filterText`, `insertText`, and `textEdit`, must not be changed during resolve. """ return self._send_request("textDocument/completion", params) def resolve_completion_item(self, params: lsp_types.CompletionItem) -> "lsp_types.CompletionItem": """Request to resolve additional information for a given completion item.The request's parameter is of type {@link CompletionItem} the response is of type {@link CompletionItem} or a Thenable that resolves to such. """ return self._send_request("completionItem/resolve", params) def hover(self, params: lsp_types.HoverParams) -> Union["lsp_types.Hover", None]: """Request to request hover information at a given text document position. The request's parameter is of type {@link TextDocumentPosition} the response is of type {@link Hover} or a Thenable that resolves to such. """ return self._send_request("textDocument/hover", params) def signature_help(self, params: lsp_types.SignatureHelpParams) -> Union["lsp_types.SignatureHelp", None]: return self._send_request("textDocument/signatureHelp", params) def definition(self, params: lsp_types.DefinitionParams) -> Union["lsp_types.Definition", list["lsp_types.LocationLink"], None]: """A request to resolve the definition location of a symbol at a given text document position. The request's parameter is of type [TextDocumentPosition] (#TextDocumentPosition) the response is of either type {@link Definition} or a typed array of {@link DefinitionLink} or a Thenable that resolves to such. """ return self._send_request("textDocument/definition", params) def references(self, params: lsp_types.ReferenceParams) -> list["lsp_types.Location"] | None: """A request to resolve project-wide references for the symbol denoted by the given text document position. The request's parameter is of type {@link ReferenceParams} the response is of type {@link Location Location[]} or a Thenable that resolves to such. """ return self._send_request("textDocument/references", params) def document_highlight(self, params: lsp_types.DocumentHighlightParams) -> list["lsp_types.DocumentHighlight"] | None: """Request to resolve a {@link DocumentHighlight} for a given text document position. The request's parameter is of type [TextDocumentPosition] (#TextDocumentPosition) the request response is of type [DocumentHighlight[]] (#DocumentHighlight) or a Thenable that resolves to such. """ return self._send_request("textDocument/documentHighlight", params) def document_symbol( self, params: lsp_types.DocumentSymbolParams ) -> list["lsp_types.SymbolInformation"] | list["lsp_types.DocumentSymbol"] | None: """A request to list all symbols found in a given text document. The request's parameter is of type {@link TextDocumentIdentifier} the response is of type {@link SymbolInformation SymbolInformation[]} or a Thenable that resolves to such. """ return self._send_request("textDocument/documentSymbol", params) def code_action(self, params: lsp_types.CodeActionParams) -> list[Union["lsp_types.Command", "lsp_types.CodeAction"]] | None: """A request to provide commands for the given text document and range.""" return self._send_request("textDocument/codeAction", params) def resolve_code_action(self, params: lsp_types.CodeAction) -> "lsp_types.CodeAction": """Request to resolve additional information for a given code action.The request's parameter is of type {@link CodeAction} the response is of type {@link CodeAction} or a Thenable that resolves to such. """ return self._send_request("codeAction/resolve", params) def workspace_symbol( self, params: lsp_types.WorkspaceSymbolParams ) -> list["lsp_types.SymbolInformation"] | list["lsp_types.WorkspaceSymbol"] | None: """A request to list project-wide symbols matching the query string given by the {@link WorkspaceSymbolParams}. The response is of type {@link SymbolInformation SymbolInformation[]} or a Thenable that resolves to such. @since 3.17.0 - support for WorkspaceSymbol in the returned data. Clients need to advertise support for WorkspaceSymbols via the client capability `workspace.symbol.resolveSupport`. """ return self._send_request("workspace/symbol", params) def resolve_workspace_symbol(self, params: lsp_types.WorkspaceSymbol) -> "lsp_types.WorkspaceSymbol": """A request to resolve the range inside the workspace symbol's location. @since 3.17.0 """ return self._send_request("workspaceSymbol/resolve", params) def code_lens(self, params: lsp_types.CodeLensParams) -> list["lsp_types.CodeLens"] | None: """A request to provide code lens for the given text document.""" return self._send_request("textDocument/codeLens", params) def resolve_code_lens(self, params: lsp_types.CodeLens) -> "lsp_types.CodeLens": """A request to resolve a command for a given code lens.""" return self._send_request("codeLens/resolve", params) def document_link(self, params: lsp_types.DocumentLinkParams) -> list["lsp_types.DocumentLink"] | None: """A request to provide document links""" return self._send_request("textDocument/documentLink", params) def resolve_document_link(self, params: lsp_types.DocumentLink) -> "lsp_types.DocumentLink": """Request to resolve additional information for a given document link. The request's parameter is of type {@link DocumentLink} the response is of type {@link DocumentLink} or a Thenable that resolves to such. """ return self._send_request("documentLink/resolve", params) def formatting(self, params: lsp_types.DocumentFormattingParams) -> list["lsp_types.TextEdit"] | None: """A request to to format a whole document.""" return self._send_request("textDocument/formatting", params) def range_formatting(self, params: lsp_types.DocumentRangeFormattingParams) -> list["lsp_types.TextEdit"] | None: """A request to to format a range in a document.""" return self._send_request("textDocument/rangeFormatting", params) def on_type_formatting(self, params: lsp_types.DocumentOnTypeFormattingParams) -> list["lsp_types.TextEdit"] | None: """A request to format a document on type.""" return self._send_request("textDocument/onTypeFormatting", params) def rename(self, params: lsp_types.RenameParams) -> Union["lsp_types.WorkspaceEdit", None]: """A request to rename a symbol.""" return self._send_request("textDocument/rename", params) def prepare_rename(self, params: lsp_types.PrepareRenameParams) -> Union["lsp_types.PrepareRenameResult", None]: """A request to test and perform the setup necessary for a rename. @since 3.16 - support for default behavior """ return self._send_request("textDocument/prepareRename", params) def execute_command(self, params: lsp_types.ExecuteCommandParams) -> Union["lsp_types.LSPAny", None]: """A request send from the client to the server to execute a command. The request might return a workspace edit which the client will apply to the workspace. """ return self._send_request("workspace/executeCommand", params) ================================================ FILE: src/solidlsp/ls_types.py ================================================ """ Defines wrapper objects around the types returned by LSP to ensure decoupling between LSP versions and SolidLSP """ from __future__ import annotations from enum import Enum, IntEnum from typing import TYPE_CHECKING, NotRequired, Union from typing_extensions import TypedDict from solidlsp.lsp_protocol_handler.lsp_types import DiagnosticSeverity if TYPE_CHECKING: from .ls import SymbolBody URI = str DocumentUri = str Uint = int RegExp = str class Position(TypedDict): r"""Position in a text document expressed as zero-based line and character offset. Prior to 3.17 the offsets were always based on a UTF-16 string representation. So a string of the form `a𐐀b` the character offset of the character `a` is 0, the character offset of `𐐀` is 1 and the character offset of b is 3 since `𐐀` is represented using two code units in UTF-16. Since 3.17 clients and servers can agree on a different string encoding representation (e.g. UTF-8). The client announces it's supported encoding via the client capability [`general.positionEncodings`](#clientCapabilities). The value is an array of position encodings the client supports, with decreasing preference (e.g. the encoding at index `0` is the most preferred one). To stay backwards compatible the only mandatory encoding is UTF-16 represented via the string `utf-16`. The server can pick one of the encodings offered by the client and signals that encoding back to the client via the initialize result's property [`capabilities.positionEncoding`](#serverCapabilities). If the string value `utf-16` is missing from the client's capability `general.positionEncodings` servers can safely assume that the client supports UTF-16. If the server omits the position encoding in its initialize result the encoding defaults to the string value `utf-16`. Implementation considerations: since the conversion from one encoding into another requires the content of the file / line the conversion is best done where the file is read which is usually on the server side. Positions are line end character agnostic. So you can not specify a position that denotes `\r|\n` or `\n|` where `|` represents the character offset. @since 3.17.0 - support for negotiated position encoding. """ line: Uint """ Line position in a document (zero-based). If a line number is greater than the number of lines in a document, it defaults back to the number of lines in the document. If a line number is negative, it defaults to 0. """ character: Uint """ Character offset on a line in a document (zero-based). The meaning of this offset is determined by the negotiated `PositionEncodingKind`. If the character value is greater than the line length it defaults back to the line length. """ class Range(TypedDict): """A range in a text document expressed as (zero-based) start and end positions. If you want to specify a range that contains a line including the line ending character(s) then use an end position denoting the start of the next line. For example: ```ts { start: { line: 5, character: 23 } end : { line 6, character : 0 } } ``` """ start: Position """ The range's start position. """ end: Position """ The range's end position. """ class Location(TypedDict): """Represents a location inside a resource, such as a line inside a text file. """ uri: DocumentUri range: Range absolutePath: str relativePath: str | None class CompletionItemKind(IntEnum): """The kind of a completion entry.""" Text = 1 Method = 2 Function = 3 Constructor = 4 Field = 5 Variable = 6 Class = 7 Interface = 8 Module = 9 Property = 10 Unit = 11 Value = 12 Enum = 13 Keyword = 14 Snippet = 15 Color = 16 File = 17 Reference = 18 Folder = 19 EnumMember = 20 Constant = 21 Struct = 22 Event = 23 Operator = 24 TypeParameter = 25 class CompletionItem(TypedDict): """A completion item represents a text snippet that is proposed to complete text that is being typed. """ completionText: str """ The completionText of this completion item. The completionText property is also by default the text that is inserted when selecting this completion.""" kind: CompletionItemKind """ The kind of this completion item. Based of the kind an icon is chosen by the editor. """ detail: NotRequired[str] """ A human-readable string with additional information about this item, like type or symbol information. """ class SymbolKind(IntEnum): """A symbol kind.""" # TODO: This is a duplicate of SymbolKind in lsp_types. File = 1 Module = 2 Namespace = 3 Package = 4 Class = 5 Method = 6 Property = 7 Field = 8 Constructor = 9 Enum = 10 Interface = 11 Function = 12 Variable = 13 Constant = 14 String = 15 Number = 16 Boolean = 17 Array = 18 Object = 19 Key = 20 Null = 21 EnumMember = 22 Struct = 23 Event = 24 Operator = 25 TypeParameter = 26 class SymbolTag(IntEnum): """Symbol tags are extra annotations that tweak the rendering of a symbol. @since 3.16 """ Deprecated = 1 """ Render a symbol as obsolete, usually using a strike-out. """ class UnifiedSymbolInformation(TypedDict): """ Represents information about programming constructs like variables, classes, interfaces etc. This is a unifying extension of `lsp_types.SymbolInformation` and `lsp_types.DocumentSymbol`, with added fields for SolidLSP/Serena use. """ deprecated: NotRequired[bool] """ Indicates if this symbol is deprecated. @deprecated Use tags instead """ location: NotRequired[Location] """ The location of this symbol. The location's range is used by a tool to reveal the location in the editor. If the symbol is selected in the tool the range's start information is used to position the cursor. So the range usually spans more than the actual symbol's name and does normally include things like visibility modifiers. The range doesn't have to denote a node range in the sense of an abstract syntax tree. It can therefore not be used to re-construct a hierarchy of the symbols. """ name: str """ The name of this symbol. """ kind: SymbolKind """ The kind of this symbol. """ tags: NotRequired[list[SymbolTag]] """ Tags for this symbol. @since 3.16.0 """ containerName: NotRequired[str] """ The name of the symbol containing this symbol. This information is for user interface purposes (e.g. to render a qualifier in the user interface if necessary). It can't be used to re-infer a hierarchy for the document symbols. Note: within Serena, the parent attribute was added and should be used instead. Most LS don't provide containerName. """ detail: NotRequired[str] """ More detail for this symbol, e.g the signature of a function. """ range: NotRequired[Range] """ The range enclosing this symbol not including leading/trailing whitespace but everything else like comments. This information is typically used to determine if the clients cursor is inside the symbol to reveal in the symbol in the UI. """ selectionRange: NotRequired[Range] """ The range that should be selected and revealed when this symbol is being picked, e.g the name of a function. Must be contained by the `range`. """ body: NotRequired["SymbolBody"] """ The body of the symbol. """ children: list[UnifiedSymbolInformation] """ The children of the symbol. Added to be compatible with `lsp_types.DocumentSymbol`, since it is sometimes useful to have the children of the symbol as a user-facing feature.""" parent: NotRequired[UnifiedSymbolInformation | None] """The parent of the symbol, if there is any. Added with Serena, not part of the LSP. All symbols except the root packages will have a parent. """ overload_idx: NotRequired[int] """ The overload index of the symbol, if applicable. If a symbol does not have overloads, this field is omitted. If the symbol is an overloaded function or method (same symbol name with the same parent), this index indicates which overload it is. The index is 0-based. Added for Serena, not part of the LSP. """ class MarkupKind(Enum): """Describes the content type that a client supports in various result literals like `Hover`, `ParameterInfo` or `CompletionItem`. Please note that `MarkupKinds` must not start with a `$`. This kinds are reserved for internal usage. """ PlainText = "plaintext" """ Plain text is supported as a content format """ Markdown = "markdown" """ Markdown is supported as a content format """ class __MarkedString_Type_1(TypedDict): language: str value: str MarkedString = Union[str, "__MarkedString_Type_1"] """ MarkedString can be used to render human readable text. It is either a markdown string or a code-block that provides a language and a code snippet. The language identifier is semantically equal to the optional language identifier in fenced code blocks in GitHub issues. See https://help.github.com/articles/creating-and-highlighting-code-blocks/#syntax-highlighting The pair of a language and a value is an equivalent to markdown: ```${language} ${value} ``` Note that markdown strings will be sanitized - that means html will be escaped. @deprecated use MarkupContent instead. """ class MarkupContent(TypedDict): r"""A `MarkupContent` literal represents a string value which content is interpreted base on its kind flag. Currently the protocol supports `plaintext` and `markdown` as markup kinds. If the kind is `markdown` then the value can contain fenced code blocks like in GitHub issues. See https://help.github.com/articles/creating-and-highlighting-code-blocks/#syntax-highlighting Here is an example how such a string can be constructed using JavaScript / TypeScript: ```ts let markdown: MarkdownContent = { kind: MarkupKind.Markdown, value: [ '# Header', 'Some text', '```typescript', 'someCode();', '```' ].join('\n') }; ``` *Please Note* that clients might sanitize the return markdown. A client could decide to remove HTML from the markdown to avoid script execution. """ kind: MarkupKind """ The type of the Markup """ value: str """ The content itself """ class Hover(TypedDict): """The result of a hover request.""" contents: MarkupContent | MarkedString | list[MarkedString] """ The hover's content """ range: NotRequired[Range] """ An optional range inside the text document that is used to visualize the hover, e.g. by changing the background color. """ class TextDocumentIdentifier(TypedDict): """A literal to identify a text document in the client.""" uri: DocumentUri """ The text document's uri. """ class TextEdit(TypedDict): """A textual edit applicable to a text document.""" range: Range """ The range of the text document to be manipulated. """ newText: str """ The string to be inserted. For delete operations use an empty string. """ class WorkspaceEdit(TypedDict): """A workspace edit represents changes to many resources managed in the workspace.""" changes: NotRequired[dict[DocumentUri, list[TextEdit]]] """ Holds changes to existing resources. """ documentChanges: NotRequired[list] """ Document changes array for versioned edits. """ class Diagnostic(TypedDict): """Diagnostic information for a text document.""" uri: DocumentUri """ The URI of the text document to which the diagnostics apply. """ range: Range """ The range of the text document to which the diagnostics apply. """ severity: NotRequired[DiagnosticSeverity] """ The severity of the diagnostic. """ message: str """ The diagnostic message. """ code: str """ The code of the diagnostic. """ source: NotRequired[str] """ The source of the diagnostic, e.g. the name of the tool that produced it. """ class SignatureHelp(TypedDict): """ Signature help represents the signature of something callable. There can be multiple signature but only one active and only one active parameter. See https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#signatureHelp """ signatures: list[SignatureInformation] """ One or more signatures. """ activeSignature: NotRequired[int] """ The active signature. If omitted or the value lies outside the range of `signatures` the value defaults to zero or is ignored if the `SignatureHelp` has no signatures. Whenever possible implementers should make an active decision about the active signature and shouldn't rely on a default value. In future version of the protocol this property might become mandatory to better express this. """ activeParameter: NotRequired[int] """ The active parameter of the active signature. If omitted or the value lies outside the range of `signatures[activeSignature].parameters` defaults to 0 if the active signature has parameters. If the active signature has no parameters it is ignored. In future version of the protocol this property might become mandatory to better express the active parameter if the active signature does have any. """ class SignatureInformation(TypedDict): """Represents the signature of something callable. A signature can have a label, like a function-name, a doc-comment, and a set of parameters. """ label: str """ The label of this signature. Will be shown in the UI. """ documentation: NotRequired[MarkupContent | str] """ The human-readable doc-comment of this signature. Will be shown in the UI but can be omitted. """ parameters: NotRequired[list[ParameterInformation]] """ The parameters of this signature. """ activeParameter: NotRequired[int] """ The index of the active parameter. If provided, this is used in place of `SignatureHelp.activeParameter`. @since 3.16.0 """ class ParameterInformation(TypedDict): """Represents a parameter of a callable-signature. A parameter can have a label and a doc-comment. """ label: str | list[int] """ The label of this parameter information. Either a string or an inclusive start and exclusive end offsets within its containing signature label. (see SignatureInformation.label). The offsets are based on a UTF-16 string representation as `Position` and `Range` does. *Note*: a label of type string should be a substring of its containing signature label. Its intended use case is to highlight the parameter label part in the `SignatureInformation.label`. """ documentation: NotRequired[MarkupContent | str] """ The human-readable doc-comment of this parameter. Will be shown in the UI but can be omitted. """ ================================================ FILE: src/solidlsp/ls_utils.py ================================================ """ This file contains various utility functions like I/O operations, handling paths, etc. """ import gzip import logging import os import platform import shutil import subprocess import uuid import zipfile from enum import Enum from pathlib import Path, PurePath import charset_normalizer import requests from solidlsp.ls_exceptions import SolidLSPException from solidlsp.ls_types import UnifiedSymbolInformation log = logging.getLogger(__name__) class InvalidTextLocationError(Exception): pass class TextUtils: """ Utilities for text operations. """ @staticmethod def get_line_col_from_index(text: str, index: int) -> tuple[int, int]: """ Returns the zero-indexed line and column number of the given index in the given text """ l = 0 c = 0 idx = 0 while idx < index: if text[idx] == "\n": l += 1 c = 0 else: c += 1 idx += 1 return l, c @staticmethod def get_index_from_line_col(text: str, line: int, col: int) -> int: """ Returns the index of the given zero-indexed line and column number in the given text """ idx = 0 while line > 0: if idx >= len(text): raise InvalidTextLocationError if text[idx] == "\n": line -= 1 idx += 1 idx += col return idx @staticmethod def _get_updated_position_from_line_and_column_and_edit(l: int, c: int, text_to_be_inserted: str) -> tuple[int, int]: """ Utility function to get the position of the cursor after inserting text at a given line and column. """ num_newlines_in_gen_text = text_to_be_inserted.count("\n") if num_newlines_in_gen_text > 0: l += num_newlines_in_gen_text c = len(text_to_be_inserted.split("\n")[-1]) else: c += len(text_to_be_inserted) return (l, c) @staticmethod def delete_text_between_positions(text: str, start_line: int, start_col: int, end_line: int, end_col: int) -> tuple[str, str]: """ Deletes the text between the given start and end positions. Returns the modified text and the deleted text. """ del_start_idx = TextUtils.get_index_from_line_col(text, start_line, start_col) del_end_idx = TextUtils.get_index_from_line_col(text, end_line, end_col) deleted_text = text[del_start_idx:del_end_idx] new_text = text[:del_start_idx] + text[del_end_idx:] return new_text, deleted_text @staticmethod def insert_text_at_position(text: str, line: int, col: int, text_to_be_inserted: str) -> tuple[str, int, int]: """ Inserts the given text at the given line and column. Returns the modified text and the new line and column. """ try: change_index = TextUtils.get_index_from_line_col(text, line, col) except InvalidTextLocationError: num_lines_in_text = text.count("\n") + 1 max_line = num_lines_in_text - 1 if line == max_line + 1 and col == 0: # trying to insert at new line after full text # insert at end, adding missing newline change_index = len(text) text_to_be_inserted = "\n" + text_to_be_inserted else: raise new_text = text[:change_index] + text_to_be_inserted + text[change_index:] new_l, new_c = TextUtils._get_updated_position_from_line_and_column_and_edit(line, col, text_to_be_inserted) return new_text, new_l, new_c class PathUtils: """ Utilities for platform-agnostic path operations. """ @staticmethod def uri_to_path(uri: str) -> str: """ Converts a URI to a file path. Works on both Linux and Windows. This method was obtained from https://stackoverflow.com/a/61922504 """ try: from urllib.parse import unquote, urlparse from urllib.request import url2pathname except ImportError: # backwards compatibility (Python 2) from urllib.parse import unquote as unquote_py2 from urllib.request import url2pathname as url2pathname_py2 from urlparse import urlparse as urlparse_py2 unquote = unquote_py2 url2pathname = url2pathname_py2 urlparse = urlparse_py2 parsed = urlparse(uri) host = f"{os.path.sep}{os.path.sep}{parsed.netloc}{os.path.sep}" path = os.path.normpath(os.path.join(host, url2pathname(unquote(parsed.path)))) return path @staticmethod def path_to_uri(path: str) -> str: """ Converts a file path to a file URI (file:///...). """ return str(Path(path).absolute().as_uri()) @staticmethod def is_glob_pattern(pattern: str) -> bool: """Check if a pattern contains glob-specific characters.""" return any(c in pattern for c in "*?[]!") @staticmethod def get_relative_path(path: str, base_path: str) -> str | None: """ Gets relative path if it's possible (paths should be on the same drive), returns `None` otherwise. """ if PurePath(path).drive == PurePath(base_path).drive: rel_path = str(PurePath(os.path.relpath(path, base_path))) return rel_path return None class FileUtils: """ Utility functions for file operations. """ @staticmethod def read_file(file_path: str, encoding: str) -> str: """ Reads the file at the given path using the given encoding and returns the contents as a string. If decoding fails, tries to detect the encoding using charset_normalizer. Raises FileNotFoundError if the file does not exist. """ if not os.path.exists(file_path): log.error(f"Failed to read '{file_path}': File does not exist.") raise FileNotFoundError(f"File read '{file_path}' failed: File does not exist.") try: try: with open(file_path, encoding=encoding) as inp_file: return inp_file.read() except UnicodeDecodeError as ude: results = charset_normalizer.from_path(file_path) match = results.best() if match: log.warning( f"Could not decode {file_path} with encoding='{encoding}'; using best match '{match.encoding}' instead", ) return match.raw.decode(match.encoding) raise ude except Exception as exc: log.error(f"Failed to read '{file_path}' with encoding '{encoding}': {exc}") raise exc @staticmethod def download_file(url: str, target_path: str) -> None: """ Downloads the file from the given URL to the given {target_path} """ os.makedirs(os.path.dirname(target_path), exist_ok=True) try: response = requests.get(url, stream=True, timeout=60) if response.status_code != 200: log.error(f"Error downloading file '{url}': {response.status_code} {response.text}") raise SolidLSPException("Error downloading file.") with open(target_path, "wb") as f: shutil.copyfileobj(response.raw, f) except Exception as exc: log.error(f"Error downloading file '{url}': {exc}") raise SolidLSPException("Error downloading file.") from None @staticmethod def download_and_extract_archive(url: str, target_path: str, archive_type: str) -> None: """ Downloads the archive from the given URL having format {archive_type} and extracts it to the given {target_path} """ try: tmp_files = [] tmp_file_name = str(PurePath(os.path.expanduser("~"), "solidlsp_tmp", uuid.uuid4().hex)) tmp_files.append(tmp_file_name) os.makedirs(os.path.dirname(tmp_file_name), exist_ok=True) FileUtils.download_file(url, tmp_file_name) if archive_type in ["tar", "gztar", "bztar", "xztar"]: os.makedirs(target_path, exist_ok=True) shutil.unpack_archive(tmp_file_name, target_path, archive_type) elif archive_type == "zip": os.makedirs(target_path, exist_ok=True) with zipfile.ZipFile(tmp_file_name, "r") as zip_ref: for zip_info in zip_ref.infolist(): extracted_path = zip_ref.extract(zip_info, target_path) ZIP_SYSTEM_UNIX = 3 # zip file created on Unix system if zip_info.create_system != ZIP_SYSTEM_UNIX: continue # extractall() does not preserve permissions # see. https://github.com/python/cpython/issues/59999 attrs = (zip_info.external_attr >> 16) & 0o777 if attrs: os.chmod(extracted_path, attrs) elif archive_type == "zip.gz": os.makedirs(target_path, exist_ok=True) tmp_file_name_ungzipped = tmp_file_name + ".zip" tmp_files.append(tmp_file_name_ungzipped) with gzip.open(tmp_file_name, "rb") as f_in, open(tmp_file_name_ungzipped, "wb") as f_out: shutil.copyfileobj(f_in, f_out) shutil.unpack_archive(tmp_file_name_ungzipped, target_path, "zip") elif archive_type == "gz": with gzip.open(tmp_file_name, "rb") as f_in, open(target_path, "wb") as f_out: shutil.copyfileobj(f_in, f_out) elif archive_type == "binary": # For single binary files, just move to target without extraction shutil.move(tmp_file_name, target_path) else: log.error(f"Unknown archive type '{archive_type}' for extraction") raise SolidLSPException(f"Unknown archive type '{archive_type}'") except Exception as exc: log.error(f"Error extracting archive '{tmp_file_name}' obtained from '{url}': {exc}") raise SolidLSPException("Error extracting archive.") from exc finally: for tmp_file_name in tmp_files: if os.path.exists(tmp_file_name): Path.unlink(Path(tmp_file_name)) class PlatformId(str, Enum): WIN_x86 = "win-x86" WIN_x64 = "win-x64" WIN_arm64 = "win-arm64" OSX = "osx" OSX_x64 = "osx-x64" OSX_arm64 = "osx-arm64" LINUX_x86 = "linux-x86" LINUX_x64 = "linux-x64" LINUX_arm64 = "linux-arm64" LINUX_MUSL_x64 = "linux-musl-x64" LINUX_MUSL_arm64 = "linux-musl-arm64" def is_windows(self) -> bool: return self.value.startswith("win") class DotnetVersion(str, Enum): V4 = "4" V6 = "6" V7 = "7" V8 = "8" V9 = "9" VMONO = "mono" class PlatformUtils: """ This class provides utilities for platform detection and identification. """ @classmethod def get_platform_id(cls) -> PlatformId: """ Returns the platform id for the current system """ system = platform.system() machine = platform.machine() bitness = platform.architecture()[0] if system == "Windows" and machine == "": machine = cls._determine_windows_machine_type() system_map = {"Windows": "win", "Darwin": "osx", "Linux": "linux"} machine_map = { "AMD64": "x64", "x86_64": "x64", "i386": "x86", "i686": "x86", "aarch64": "arm64", "arm64": "arm64", "ARM64": "arm64", } if system in system_map and machine in machine_map: platform_id = system_map[system] + "-" + machine_map[machine] if system == "Linux" and bitness == "64bit": libc = platform.libc_ver()[0] if libc != "glibc": # Format: linux-musl-arch (e.g., linux-musl-arm64) platform_id = f"{system_map[system]}-{libc}-{machine_map[machine]}" return PlatformId(platform_id) else: raise SolidLSPException(f"Unknown platform: {system=}, {machine=}, {bitness=}") @staticmethod def _determine_windows_machine_type() -> str: import ctypes from ctypes import wintypes class SYSTEM_INFO(ctypes.Structure): class _U(ctypes.Union): class _S(ctypes.Structure): _fields_ = [("wProcessorArchitecture", wintypes.WORD), ("wReserved", wintypes.WORD)] _fields_ = [("dwOemId", wintypes.DWORD), ("s", _S)] _anonymous_ = ("s",) _fields_ = [ ("u", _U), ("dwPageSize", wintypes.DWORD), ("lpMinimumApplicationAddress", wintypes.LPVOID), ("lpMaximumApplicationAddress", wintypes.LPVOID), ("dwActiveProcessorMask", wintypes.LPVOID), ("dwNumberOfProcessors", wintypes.DWORD), ("dwProcessorType", wintypes.DWORD), ("dwAllocationGranularity", wintypes.DWORD), ("wProcessorLevel", wintypes.WORD), ("wProcessorRevision", wintypes.WORD), ] _anonymous_ = ("u",) sys_info = SYSTEM_INFO() ctypes.windll.kernel32.GetNativeSystemInfo(ctypes.byref(sys_info)) # type: ignore arch_map = { 9: "AMD64", 5: "ARM", 12: "arm64", 6: "Intel Itanium-based", 0: "i386", } return arch_map.get(sys_info.wProcessorArchitecture, f"Unknown ({sys_info.wProcessorArchitecture})") @staticmethod def get_dotnet_version() -> DotnetVersion: """ Returns the dotnet version for the current system """ try: result = subprocess.run(["dotnet", "--list-runtimes"], capture_output=True, check=True) available_version_cmd_output = [] for line in result.stdout.decode("utf-8").split("\n"): if line.startswith("Microsoft.NETCore.App"): version_cmd_output = line.split(" ")[1] available_version_cmd_output.append(version_cmd_output) if not available_version_cmd_output: raise SolidLSPException("dotnet not found on the system") # Check for supported versions in order of preference (latest first) for version_cmd_output in available_version_cmd_output: if version_cmd_output.startswith("9"): return DotnetVersion.V9 if version_cmd_output.startswith("8"): return DotnetVersion.V8 if version_cmd_output.startswith("7"): return DotnetVersion.V7 if version_cmd_output.startswith("6"): return DotnetVersion.V6 if version_cmd_output.startswith("4"): return DotnetVersion.V4 # If no supported version found, raise exception with all available versions raise SolidLSPException( f"No supported dotnet version found. Available versions: {', '.join(available_version_cmd_output)}. Supported versions: 4, 6, 7, 8" ) except (FileNotFoundError, subprocess.CalledProcessError): try: result = subprocess.run(["mono", "--version"], capture_output=True, check=True) return DotnetVersion.VMONO except (FileNotFoundError, subprocess.CalledProcessError): raise SolidLSPException("dotnet or mono not found on the system") class SymbolUtils: @staticmethod def symbol_tree_contains_name(roots: list[UnifiedSymbolInformation], name: str) -> bool: """ Check if any symbol in the tree has a name matching the given name. """ for symbol in roots: if symbol["name"] == name: return True if SymbolUtils.symbol_tree_contains_name(symbol["children"], name): return True return False ================================================ FILE: src/solidlsp/lsp_protocol_handler/lsp_constants.py ================================================ """ This module contains constants used in the LSP protocol. """ class LSPConstants: """ This class contains constants used in the LSP protocol. """ # the key for uri used to represent paths URI = "uri" # the key for range, which is a from and to position within a text document RANGE = "range" # A key used in LocationLink type, used as the span of the origin link ORIGIN_SELECTION_RANGE = "originSelectionRange" # A key used in LocationLink type, used as the target uri of the link TARGET_URI = "targetUri" # A key used in LocationLink type, used as the target range of the link TARGET_RANGE = "targetRange" # A key used in LocationLink type, used as the target selection range of the link TARGET_SELECTION_RANGE = "targetSelectionRange" # key for the textDocument field in the request TEXT_DOCUMENT = "textDocument" # key used to represent the language a document is in - "java", "csharp", etc. LANGUAGE_ID = "languageId" # key used to represent the version of a document (a shared value between the client and server) VERSION = "version" # key used to represent the text of a document being sent from the client to the server on open TEXT = "text" # key used to represent a position (line and colnum) within a text document POSITION = "position" # key used to represent the line number of a position LINE = "line" # key used to represent the column number of a position CHARACTER = "character" # key used to represent the changes made to a document CONTENT_CHANGES = "contentChanges" # key used to represent name of symbols NAME = "name" # key used to represent the kind of symbols KIND = "kind" # key used to represent children in document symbols CHILDREN = "children" # key used to represent the location in symbols LOCATION = "location" # Severity level of the diagnostic SEVERITY = "severity" # The message of the diagnostic MESSAGE = "message" ================================================ FILE: src/solidlsp/lsp_protocol_handler/lsp_requests.py ================================================ # Code generated. DO NOT EDIT. # LSP v3.17.0 # TODO: Look into use of https://pypi.org/project/ts2python/ to generate the types for https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/ """ This file provides the python interface corresponding to the requests and notifications defined in Typescript in the language server protocol. This file is obtained from https://github.com/predragnikolic/OLSP under the MIT License with the following terms: MIT License Copyright (c) 2023 Предраг Николић Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ from typing import Any, Union from solidlsp.lsp_protocol_handler import lsp_types class LspRequest: def __init__(self, send_request: Any) -> None: self.send_request = send_request async def implementation( self, params: lsp_types.ImplementationParams ) -> Union["lsp_types.Definition", list["lsp_types.LocationLink"], None]: """A request to resolve the implementation locations of a symbol at a given text document position. The request's parameter is of type [TextDocumentPositionParams] (#TextDocumentPositionParams) the response is of type {@link Definition} or a Thenable that resolves to such. """ return await self.send_request("textDocument/implementation", params) async def type_definition( self, params: lsp_types.TypeDefinitionParams ) -> Union["lsp_types.Definition", list["lsp_types.LocationLink"], None]: """A request to resolve the type definition locations of a symbol at a given text document position. The request's parameter is of type [TextDocumentPositionParams] (#TextDocumentPositionParams) the response is of type {@link Definition} or a Thenable that resolves to such. """ return await self.send_request("textDocument/typeDefinition", params) async def document_color(self, params: lsp_types.DocumentColorParams) -> list["lsp_types.ColorInformation"]: """A request to list all color symbols found in a given text document. The request's parameter is of type {@link DocumentColorParams} the response is of type {@link ColorInformation ColorInformation[]} or a Thenable that resolves to such. """ return await self.send_request("textDocument/documentColor", params) async def color_presentation(self, params: lsp_types.ColorPresentationParams) -> list["lsp_types.ColorPresentation"]: """A request to list all presentation for a color. The request's parameter is of type {@link ColorPresentationParams} the response is of type {@link ColorInformation ColorInformation[]} or a Thenable that resolves to such. """ return await self.send_request("textDocument/colorPresentation", params) async def folding_range(self, params: lsp_types.FoldingRangeParams) -> list["lsp_types.FoldingRange"] | None: """A request to provide folding ranges in a document. The request's parameter is of type {@link FoldingRangeParams}, the response is of type {@link FoldingRangeList} or a Thenable that resolves to such. """ return await self.send_request("textDocument/foldingRange", params) async def declaration( self, params: lsp_types.DeclarationParams ) -> Union["lsp_types.Declaration", list["lsp_types.LocationLink"], None]: """A request to resolve the type definition locations of a symbol at a given text document position. The request's parameter is of type [TextDocumentPositionParams] (#TextDocumentPositionParams) the response is of type {@link Declaration} or a typed array of {@link DeclarationLink} or a Thenable that resolves to such. """ return await self.send_request("textDocument/declaration", params) async def selection_range(self, params: lsp_types.SelectionRangeParams) -> list["lsp_types.SelectionRange"] | None: """A request to provide selection ranges in a document. The request's parameter is of type {@link SelectionRangeParams}, the response is of type {@link SelectionRange SelectionRange[]} or a Thenable that resolves to such. """ return await self.send_request("textDocument/selectionRange", params) async def prepare_call_hierarchy(self, params: lsp_types.CallHierarchyPrepareParams) -> list["lsp_types.CallHierarchyItem"] | None: """A request to result a `CallHierarchyItem` in a document at a given position. Can be used as an input to an incoming or outgoing call hierarchy. @since 3.16.0 """ return await self.send_request("textDocument/prepareCallHierarchy", params) async def incoming_calls( self, params: lsp_types.CallHierarchyIncomingCallsParams ) -> list["lsp_types.CallHierarchyIncomingCall"] | None: """A request to resolve the incoming calls for a given `CallHierarchyItem`. @since 3.16.0 """ return await self.send_request("callHierarchy/incomingCalls", params) async def outgoing_calls( self, params: lsp_types.CallHierarchyOutgoingCallsParams ) -> list["lsp_types.CallHierarchyOutgoingCall"] | None: """A request to resolve the outgoing calls for a given `CallHierarchyItem`. @since 3.16.0 """ return await self.send_request("callHierarchy/outgoingCalls", params) async def semantic_tokens_full(self, params: lsp_types.SemanticTokensParams) -> Union["lsp_types.SemanticTokens", None]: """@since 3.16.0""" return await self.send_request("textDocument/semanticTokens/full", params) async def semantic_tokens_delta( self, params: lsp_types.SemanticTokensDeltaParams ) -> Union["lsp_types.SemanticTokens", "lsp_types.SemanticTokensDelta", None]: """@since 3.16.0""" return await self.send_request("textDocument/semanticTokens/full/delta", params) async def semantic_tokens_range(self, params: lsp_types.SemanticTokensRangeParams) -> Union["lsp_types.SemanticTokens", None]: """@since 3.16.0""" return await self.send_request("textDocument/semanticTokens/range", params) async def linked_editing_range(self, params: lsp_types.LinkedEditingRangeParams) -> Union["lsp_types.LinkedEditingRanges", None]: """A request to provide ranges that can be edited together. @since 3.16.0 """ return await self.send_request("textDocument/linkedEditingRange", params) async def will_create_files(self, params: lsp_types.CreateFilesParams) -> Union["lsp_types.WorkspaceEdit", None]: """The will create files request is sent from the client to the server before files are actually created as long as the creation is triggered from within the client. @since 3.16.0 """ return await self.send_request("workspace/willCreateFiles", params) async def will_rename_files(self, params: lsp_types.RenameFilesParams) -> Union["lsp_types.WorkspaceEdit", None]: """The will rename files request is sent from the client to the server before files are actually renamed as long as the rename is triggered from within the client. @since 3.16.0 """ return await self.send_request("workspace/willRenameFiles", params) async def will_delete_files(self, params: lsp_types.DeleteFilesParams) -> Union["lsp_types.WorkspaceEdit", None]: """The did delete files notification is sent from the client to the server when files were deleted from within the client. @since 3.16.0 """ return await self.send_request("workspace/willDeleteFiles", params) async def moniker(self, params: lsp_types.MonikerParams) -> list["lsp_types.Moniker"] | None: """A request to get the moniker of a symbol at a given text document position. The request parameter is of type {@link TextDocumentPositionParams}. The response is of type {@link Moniker Moniker[]} or `null`. """ return await self.send_request("textDocument/moniker", params) async def prepare_type_hierarchy(self, params: lsp_types.TypeHierarchyPrepareParams) -> list["lsp_types.TypeHierarchyItem"] | None: """A request to result a `TypeHierarchyItem` in a document at a given position. Can be used as an input to a subtypes or supertypes type hierarchy. @since 3.17.0 """ return await self.send_request("textDocument/prepareTypeHierarchy", params) async def type_hierarchy_supertypes( self, params: lsp_types.TypeHierarchySupertypesParams ) -> list["lsp_types.TypeHierarchyItem"] | None: """A request to resolve the supertypes for a given `TypeHierarchyItem`. @since 3.17.0 """ return await self.send_request("typeHierarchy/supertypes", params) async def type_hierarchy_subtypes(self, params: lsp_types.TypeHierarchySubtypesParams) -> list["lsp_types.TypeHierarchyItem"] | None: """A request to resolve the subtypes for a given `TypeHierarchyItem`. @since 3.17.0 """ return await self.send_request("typeHierarchy/subtypes", params) async def inline_value(self, params: lsp_types.InlineValueParams) -> list["lsp_types.InlineValue"] | None: """A request to provide inline values in a document. The request's parameter is of type {@link InlineValueParams}, the response is of type {@link InlineValue InlineValue[]} or a Thenable that resolves to such. @since 3.17.0 """ return await self.send_request("textDocument/inlineValue", params) async def inlay_hint(self, params: lsp_types.InlayHintParams) -> list["lsp_types.InlayHint"] | None: """A request to provide inlay hints in a document. The request's parameter is of type {@link InlayHintsParams}, the response is of type {@link InlayHint InlayHint[]} or a Thenable that resolves to such. @since 3.17.0 """ return await self.send_request("textDocument/inlayHint", params) async def resolve_inlay_hint(self, params: lsp_types.InlayHint) -> "lsp_types.InlayHint": """A request to resolve additional properties for an inlay hint. The request's parameter is of type {@link InlayHint}, the response is of type {@link InlayHint} or a Thenable that resolves to such. @since 3.17.0 """ return await self.send_request("inlayHint/resolve", params) async def text_document_diagnostic(self, params: lsp_types.DocumentDiagnosticParams) -> "lsp_types.DocumentDiagnosticReport": """The document diagnostic request definition. @since 3.17.0 """ return await self.send_request("textDocument/diagnostic", params) async def workspace_diagnostic(self, params: lsp_types.WorkspaceDiagnosticParams) -> "lsp_types.WorkspaceDiagnosticReport": """The workspace diagnostic request definition. @since 3.17.0 """ return await self.send_request("workspace/diagnostic", params) async def initialize(self, params: lsp_types.InitializeParams) -> "lsp_types.InitializeResult": """The initialize request is sent from the client to the server. It is sent once as the request after starting up the server. The requests parameter is of type {@link InitializeParams} the response if of type {@link InitializeResult} of a Thenable that resolves to such. """ return await self.send_request("initialize", params) async def shutdown(self) -> None: """A shutdown request is sent from the client to the server. It is sent once when the client decides to shutdown the server. The only notification that is sent after a shutdown request is the exit event. """ return await self.send_request("shutdown") async def will_save_wait_until(self, params: lsp_types.WillSaveTextDocumentParams) -> list["lsp_types.TextEdit"] | None: """A document will save request is sent from the client to the server before the document is actually saved. The request can return an array of TextEdits which will be applied to the text document before it is saved. Please note that clients might drop results if computing the text edits took too long or if a server constantly fails on this request. This is done to keep the save fast and reliable. """ return await self.send_request("textDocument/willSaveWaitUntil", params) async def completion( self, params: lsp_types.CompletionParams ) -> Union[list["lsp_types.CompletionItem"], "lsp_types.CompletionList", None]: """Request to request completion at a given text document position. The request's parameter is of type {@link TextDocumentPosition} the response is of type {@link CompletionItem CompletionItem[]} or {@link CompletionList} or a Thenable that resolves to such. The request can delay the computation of the {@link CompletionItem.detail `detail`} and {@link CompletionItem.documentation `documentation`} properties to the `completionItem/resolve` request. However, properties that are needed for the initial sorting and filtering, like `sortText`, `filterText`, `insertText`, and `textEdit`, must not be changed during resolve. """ return await self.send_request("textDocument/completion", params) async def resolve_completion_item(self, params: lsp_types.CompletionItem) -> "lsp_types.CompletionItem": """Request to resolve additional information for a given completion item.The request's parameter is of type {@link CompletionItem} the response is of type {@link CompletionItem} or a Thenable that resolves to such. """ return await self.send_request("completionItem/resolve", params) async def hover(self, params: lsp_types.HoverParams) -> Union["lsp_types.Hover", None]: """Request to request hover information at a given text document position. The request's parameter is of type {@link TextDocumentPosition} the response is of type {@link Hover} or a Thenable that resolves to such. """ return await self.send_request("textDocument/hover", params) async def signature_help(self, params: lsp_types.SignatureHelpParams) -> Union["lsp_types.SignatureHelp", None]: return await self.send_request("textDocument/signatureHelp", params) async def definition(self, params: lsp_types.DefinitionParams) -> Union["lsp_types.Definition", list["lsp_types.LocationLink"], None]: """A request to resolve the definition location of a symbol at a given text document position. The request's parameter is of type [TextDocumentPosition] (#TextDocumentPosition) the response is of either type {@link Definition} or a typed array of {@link DefinitionLink} or a Thenable that resolves to such. """ return await self.send_request("textDocument/definition", params) async def references(self, params: lsp_types.ReferenceParams) -> list["lsp_types.Location"] | None: """A request to resolve project-wide references for the symbol denoted by the given text document position. The request's parameter is of type {@link ReferenceParams} the response is of type {@link Location Location[]} or a Thenable that resolves to such. """ return await self.send_request("textDocument/references", params) async def document_highlight(self, params: lsp_types.DocumentHighlightParams) -> list["lsp_types.DocumentHighlight"] | None: """Request to resolve a {@link DocumentHighlight} for a given text document position. The request's parameter is of type [TextDocumentPosition] (#TextDocumentPosition) the request response is of type [DocumentHighlight[]] (#DocumentHighlight) or a Thenable that resolves to such. """ return await self.send_request("textDocument/documentHighlight", params) async def document_symbol( self, params: lsp_types.DocumentSymbolParams ) -> list["lsp_types.SymbolInformation"] | list["lsp_types.DocumentSymbol"] | None: """A request to list all symbols found in a given text document. The request's parameter is of type {@link TextDocumentIdentifier} the response is of type {@link SymbolInformation SymbolInformation[]} or a Thenable that resolves to such. """ return await self.send_request("textDocument/documentSymbol", params) async def code_action(self, params: lsp_types.CodeActionParams) -> list[Union["lsp_types.Command", "lsp_types.CodeAction"]] | None: """A request to provide commands for the given text document and range.""" return await self.send_request("textDocument/codeAction", params) async def resolve_code_action(self, params: lsp_types.CodeAction) -> "lsp_types.CodeAction": """Request to resolve additional information for a given code action.The request's parameter is of type {@link CodeAction} the response is of type {@link CodeAction} or a Thenable that resolves to such. """ return await self.send_request("codeAction/resolve", params) async def workspace_symbol( self, params: lsp_types.WorkspaceSymbolParams ) -> list["lsp_types.SymbolInformation"] | list["lsp_types.WorkspaceSymbol"] | None: """A request to list project-wide symbols matching the query string given by the {@link WorkspaceSymbolParams}. The response is of type {@link SymbolInformation SymbolInformation[]} or a Thenable that resolves to such. @since 3.17.0 - support for WorkspaceSymbol in the returned data. Clients need to advertise support for WorkspaceSymbols via the client capability `workspace.symbol.resolveSupport`. """ return await self.send_request("workspace/symbol", params) async def resolve_workspace_symbol(self, params: lsp_types.WorkspaceSymbol) -> "lsp_types.WorkspaceSymbol": """A request to resolve the range inside the workspace symbol's location. @since 3.17.0 """ return await self.send_request("workspaceSymbol/resolve", params) async def code_lens(self, params: lsp_types.CodeLensParams) -> list["lsp_types.CodeLens"] | None: """A request to provide code lens for the given text document.""" return await self.send_request("textDocument/codeLens", params) async def resolve_code_lens(self, params: lsp_types.CodeLens) -> "lsp_types.CodeLens": """A request to resolve a command for a given code lens.""" return await self.send_request("codeLens/resolve", params) async def document_link(self, params: lsp_types.DocumentLinkParams) -> list["lsp_types.DocumentLink"] | None: """A request to provide document links""" return await self.send_request("textDocument/documentLink", params) async def resolve_document_link(self, params: lsp_types.DocumentLink) -> "lsp_types.DocumentLink": """Request to resolve additional information for a given document link. The request's parameter is of type {@link DocumentLink} the response is of type {@link DocumentLink} or a Thenable that resolves to such. """ return await self.send_request("documentLink/resolve", params) async def formatting(self, params: lsp_types.DocumentFormattingParams) -> list["lsp_types.TextEdit"] | None: """A request to to format a whole document.""" return await self.send_request("textDocument/formatting", params) async def range_formatting(self, params: lsp_types.DocumentRangeFormattingParams) -> list["lsp_types.TextEdit"] | None: """A request to to format a range in a document.""" return await self.send_request("textDocument/rangeFormatting", params) async def on_type_formatting(self, params: lsp_types.DocumentOnTypeFormattingParams) -> list["lsp_types.TextEdit"] | None: """A request to format a document on type.""" return await self.send_request("textDocument/onTypeFormatting", params) async def rename(self, params: lsp_types.RenameParams) -> Union["lsp_types.WorkspaceEdit", None]: """A request to rename a symbol.""" return await self.send_request("textDocument/rename", params) async def prepare_rename(self, params: lsp_types.PrepareRenameParams) -> Union["lsp_types.PrepareRenameResult", None]: """A request to test and perform the setup necessary for a rename. @since 3.16 - support for default behavior """ return await self.send_request("textDocument/prepareRename", params) async def execute_command(self, params: lsp_types.ExecuteCommandParams) -> Union["lsp_types.LSPAny", None]: """A request send from the client to the server to execute a command. The request might return a workspace edit which the client will apply to the workspace. """ return await self.send_request("workspace/executeCommand", params) class LspNotification: def __init__(self, send_notification: Any) -> None: self.send_notification = send_notification def did_change_workspace_folders(self, params: lsp_types.DidChangeWorkspaceFoldersParams) -> None: """The `workspace/didChangeWorkspaceFolders` notification is sent from the client to the server when the workspace folder configuration changes. """ return self.send_notification("workspace/didChangeWorkspaceFolders", params) def cancel_work_done_progress(self, params: lsp_types.WorkDoneProgressCancelParams) -> None: """The `window/workDoneProgress/cancel` notification is sent from the client to the server to cancel a progress initiated on the server side. """ return self.send_notification("window/workDoneProgress/cancel", params) def did_create_files(self, params: lsp_types.CreateFilesParams) -> None: """The did create files notification is sent from the client to the server when files were created from within the client. @since 3.16.0 """ return self.send_notification("workspace/didCreateFiles", params) def did_rename_files(self, params: lsp_types.RenameFilesParams) -> None: """The did rename files notification is sent from the client to the server when files were renamed from within the client. @since 3.16.0 """ return self.send_notification("workspace/didRenameFiles", params) def did_delete_files(self, params: lsp_types.DeleteFilesParams) -> None: """The will delete files request is sent from the client to the server before files are actually deleted as long as the deletion is triggered from within the client. @since 3.16.0 """ return self.send_notification("workspace/didDeleteFiles", params) def did_open_notebook_document(self, params: lsp_types.DidOpenNotebookDocumentParams) -> None: """A notification sent when a notebook opens. @since 3.17.0 """ return self.send_notification("notebookDocument/didOpen", params) def did_change_notebook_document(self, params: lsp_types.DidChangeNotebookDocumentParams) -> None: return self.send_notification("notebookDocument/didChange", params) def did_save_notebook_document(self, params: lsp_types.DidSaveNotebookDocumentParams) -> None: """A notification sent when a notebook document is saved. @since 3.17.0 """ return self.send_notification("notebookDocument/didSave", params) def did_close_notebook_document(self, params: lsp_types.DidCloseNotebookDocumentParams) -> None: """A notification sent when a notebook closes. @since 3.17.0 """ return self.send_notification("notebookDocument/didClose", params) def initialized(self, params: lsp_types.InitializedParams) -> None: """The initialized notification is sent from the client to the server after the client is fully initialized and the server is allowed to send requests from the server to the client. """ return self.send_notification("initialized", params) def exit(self) -> None: """The exit event is sent from the client to the server to ask the server to exit its process. """ return self.send_notification("exit") def workspace_did_change_configuration(self, params: lsp_types.DidChangeConfigurationParams) -> None: """The configuration change notification is sent from the client to the server when the client's configuration has changed. The notification contains the changed configuration as defined by the language client. """ return self.send_notification("workspace/didChangeConfiguration", params) def did_open_text_document(self, params: lsp_types.DidOpenTextDocumentParams) -> None: """The document open notification is sent from the client to the server to signal newly opened text documents. The document's truth is now managed by the client and the server must not try to read the document's truth using the document's uri. Open in this sense means it is managed by the client. It doesn't necessarily mean that its content is presented in an editor. An open notification must not be sent more than once without a corresponding close notification send before. This means open and close notification must be balanced and the max open count is one. """ return self.send_notification("textDocument/didOpen", params) def did_change_text_document(self, params: lsp_types.DidChangeTextDocumentParams) -> None: """The document change notification is sent from the client to the server to signal changes to a text document. """ return self.send_notification("textDocument/didChange", params) def did_close_text_document(self, params: lsp_types.DidCloseTextDocumentParams) -> None: """The document close notification is sent from the client to the server when the document got closed in the client. The document's truth now exists where the document's uri points to (e.g. if the document's uri is a file uri the truth now exists on disk). As with the open notification the close notification is about managing the document's content. Receiving a close notification doesn't mean that the document was open in an editor before. A close notification requires a previous open notification to be sent. """ return self.send_notification("textDocument/didClose", params) def did_save_text_document(self, params: lsp_types.DidSaveTextDocumentParams) -> None: """The document save notification is sent from the client to the server when the document got saved in the client. """ return self.send_notification("textDocument/didSave", params) def will_save_text_document(self, params: lsp_types.WillSaveTextDocumentParams) -> None: """A document will save notification is sent from the client to the server before the document is actually saved. """ return self.send_notification("textDocument/willSave", params) def did_change_watched_files(self, params: lsp_types.DidChangeWatchedFilesParams) -> None: """The watched files notification is sent from the client to the server when the client detects changes to file watched by the language client. """ return self.send_notification("workspace/didChangeWatchedFiles", params) def set_trace(self, params: lsp_types.SetTraceParams) -> None: return self.send_notification("$/setTrace", params) def cancel_request(self, params: lsp_types.CancelParams) -> None: return self.send_notification("$/cancelRequest", params) def progress(self, params: lsp_types.ProgressParams) -> None: return self.send_notification("$/progress", params) ================================================ FILE: src/solidlsp/lsp_protocol_handler/lsp_types.py ================================================ # Code generated. DO NOT EDIT. # LSP v3.17.0 # TODO: Look into use of https://pypi.org/project/ts2python/ to generate the types for https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/ """ This file provides the Python types corresponding to the Typescript types defined in the language server protocol. This file is obtained from https://github.com/predragnikolic/OLSP under the MIT License with the following terms: MIT License Copyright (c) 2023 Предраг Николић Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ from enum import Enum, IntEnum, IntFlag from typing import Literal, NotRequired, Union from typing_extensions import TypedDict URI = str DocumentUri = str Uint = int RegExp = str class SemanticTokenTypes(Enum): """A set of predefined token types. This set is not fixed an clients can specify additional token types via the corresponding client capabilities. @since 3.16.0 """ Namespace = "namespace" Type = "type" """ Represents a generic type. Acts as a fallback for types which can't be mapped to a specific type like class or enum. """ Class = "class" Enum = "enum" Interface = "interface" Struct = "struct" TypeParameter = "typeParameter" Parameter = "parameter" Variable = "variable" Property = "property" EnumMember = "enumMember" Event = "event" Function = "function" Method = "method" Macro = "macro" Keyword = "keyword" Modifier = "modifier" Comment = "comment" String = "string" Number = "number" Regexp = "regexp" Operator = "operator" Decorator = "decorator" """ @since 3.17.0 """ class SemanticTokenModifiers(Enum): """A set of predefined token modifiers. This set is not fixed an clients can specify additional token types via the corresponding client capabilities. @since 3.16.0 """ Declaration = "declaration" Definition = "definition" Readonly = "readonly" Static = "static" Deprecated = "deprecated" Abstract = "abstract" Async = "async" Modification = "modification" Documentation = "documentation" DefaultLibrary = "defaultLibrary" class DocumentDiagnosticReportKind(Enum): """The document diagnostic report kinds. @since 3.17.0 """ Full = "full" """ A diagnostic report with a full set of problems. """ Unchanged = "unchanged" """ A report indicating that the last returned report is still accurate. """ class ErrorCodes(IntEnum): """Predefined error codes.""" ParseError = -32700 InvalidRequest = -32600 MethodNotFound = -32601 InvalidParams = -32602 InternalError = -32603 ServerNotInitialized = -32002 """ Error code indicating that a server received a notification or request before the server has received the `initialize` request. """ UnknownErrorCode = -32001 class LSPErrorCodes(IntEnum): RequestFailed = -32803 """ A request failed but it was syntactically correct, e.g the method name was known and the parameters were valid. The error message should contain human readable information about why the request failed. @since 3.17.0 """ ServerCancelled = -32802 """ The server cancelled the request. This error code should only be used for requests that explicitly support being server cancellable. @since 3.17.0 """ ContentModified = -32801 """ The server detected that the content of a document got modified outside normal conditions. A server should NOT send this error code if it detects a content change in it unprocessed messages. The result even computed on an older state might still be useful for the client. If a client decides that a result is not of any use anymore the client should cancel the request. """ RequestCancelled = -32800 """ The client has canceled a request and a server as detected the cancel. """ class FoldingRangeKind(Enum): """A set of predefined range kinds.""" Comment = "comment" """ Folding range for a comment """ Imports = "imports" """ Folding range for an import or include """ Region = "region" """ Folding range for a region (e.g. `#region`) """ class SymbolKind(IntEnum): """A symbol kind.""" File = 1 Module = 2 Namespace = 3 Package = 4 """ Represents a package or simply a directory in the filesystem """ Class = 5 Method = 6 Property = 7 Field = 8 Constructor = 9 Enum = 10 Interface = 11 Function = 12 Variable = 13 Constant = 14 String = 15 Number = 16 Boolean = 17 Array = 18 Object = 19 Key = 20 Null = 21 EnumMember = 22 Struct = 23 Event = 24 Operator = 25 TypeParameter = 26 @classmethod def from_int(cls, value: int) -> "SymbolKind": for symbol_kind in cls: if symbol_kind.value == value: return symbol_kind raise ValueError(f"Invalid symbol kind: {value}") class SymbolTag(IntEnum): """Symbol tags are extra annotations that tweak the rendering of a symbol. @since 3.16 """ Deprecated = 1 """ Render a symbol as obsolete, usually using a strike-out. """ class UniquenessLevel(Enum): """Moniker uniqueness level to define scope of the moniker. @since 3.16.0 """ Document = "document" """ The moniker is only unique inside a document """ Project = "project" """ The moniker is unique inside a project for which a dump got created """ Group = "group" """ The moniker is unique inside the group to which a project belongs """ Scheme = "scheme" """ The moniker is unique inside the moniker scheme. """ Global = "global" """ The moniker is globally unique """ class MonikerKind(Enum): """The moniker kind. @since 3.16.0 """ Import = "import" """ The moniker represent a symbol that is imported into a project """ Export = "export" """ The moniker represents a symbol that is exported from a project """ Local = "local" """ The moniker represents a symbol that is local to a project (e.g. a local variable of a function, a class not visible outside the project, ...) """ class InlayHintKind(IntEnum): """Inlay hint kinds. @since 3.17.0 """ Type = 1 """ An inlay hint that for a type annotation. """ Parameter = 2 """ An inlay hint that is for a parameter. """ class MessageType(IntEnum): """The message type""" Error = 1 """ An error message. """ Warning = 2 """ A warning message. """ Info = 3 """ An information message. """ Log = 4 """ A log message. """ class TextDocumentSyncKind(IntEnum): """Defines how the host (editor) should sync document changes to the language server. """ None_ = 0 """ Documents should not be synced at all. """ Full = 1 """ Documents are synced by always sending the full content of the document. """ Incremental = 2 """ Documents are synced by sending the full content on open. After that only incremental updates to the document are send. """ class TextDocumentSaveReason(IntEnum): """Represents reasons why a text document is saved.""" Manual = 1 """ Manually triggered, e.g. by the user pressing save, by starting debugging, or by an API call. """ AfterDelay = 2 """ Automatic after a delay. """ FocusOut = 3 """ When the editor lost focus. """ class CompletionItemKind(IntEnum): """The kind of a completion entry.""" Text = 1 Method = 2 Function = 3 Constructor = 4 Field = 5 Variable = 6 Class = 7 Interface = 8 Module = 9 Property = 10 Unit = 11 Value = 12 Enum = 13 Keyword = 14 Snippet = 15 Color = 16 File = 17 Reference = 18 Folder = 19 EnumMember = 20 Constant = 21 Struct = 22 Event = 23 Operator = 24 TypeParameter = 25 class CompletionItemTag(IntEnum): """Completion item tags are extra annotations that tweak the rendering of a completion item. @since 3.15.0 """ Deprecated = 1 """ Render a completion as obsolete, usually using a strike-out. """ class InsertTextFormat(IntEnum): """Defines whether the insert text in a completion item should be interpreted as plain text or a snippet. """ PlainText = 1 """ The primary text to be inserted is treated as a plain string. """ Snippet = 2 """ The primary text to be inserted is treated as a snippet. A snippet can define tab stops and placeholders with `$1`, `$2` and `${3:foo}`. `$0` defines the final tab stop, it defaults to the end of the snippet. Placeholders with equal identifiers are linked, that is typing in one will update others too. See also: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#snippet_syntax """ class InsertTextMode(IntEnum): """How whitespace and indentation is handled during completion item insertion. @since 3.16.0 """ AsIs = 1 """ The insertion or replace strings is taken as it is. If the value is multi line the lines below the cursor will be inserted using the indentation defined in the string value. The client will not apply any kind of adjustments to the string. """ AdjustIndentation = 2 """ The editor adjusts leading whitespace of new lines so that they match the indentation up to the cursor of the line for which the item is accepted. Consider a line like this: <2tabs><3tabs>foo. Accepting a multi line completion item is indented using 2 tabs and all following lines inserted will be indented using 2 tabs as well. """ class DocumentHighlightKind(IntEnum): """A document highlight kind.""" Text = 1 """ A textual occurrence. """ Read = 2 """ Read-access of a symbol, like reading a variable. """ Write = 3 """ Write-access of a symbol, like writing to a variable. """ class CodeActionKind(Enum): """A set of predefined code action kinds""" Empty = "" """ Empty kind. """ QuickFix = "quickfix" """ Base kind for quickfix actions: 'quickfix' """ Refactor = "refactor" """ Base kind for refactoring actions: 'refactor' """ RefactorExtract = "refactor.extract" """ Base kind for refactoring extraction actions: 'refactor.extract' Example extract actions: - Extract method - Extract function - Extract variable - Extract interface from class - ... """ RefactorInline = "refactor.inline" """ Base kind for refactoring inline actions: 'refactor.inline' Example inline actions: - Inline function - Inline variable - Inline constant - ... """ RefactorRewrite = "refactor.rewrite" """ Base kind for refactoring rewrite actions: 'refactor.rewrite' Example rewrite actions: - Convert JavaScript function to class - Add or remove parameter - Encapsulate field - Make method static - Move method to base class - ... """ Source = "source" """ Base kind for source actions: `source` Source code actions apply to the entire file. """ SourceOrganizeImports = "source.organizeImports" """ Base kind for an organize imports source action: `source.organizeImports` """ SourceFixAll = "source.fixAll" """ Base kind for auto-fix source actions: `source.fixAll`. Fix all actions automatically fix errors that have a clear fix that do not require user input. They should not suppress errors or perform unsafe fixes such as generating new types or classes. @since 3.15.0 """ class TraceValues(Enum): Off = "off" """ Turn tracing off. """ Messages = "messages" """ Trace messages only. """ Verbose = "verbose" """ Verbose message tracing. """ class MarkupKind(Enum): """Describes the content type that a client supports in various result literals like `Hover`, `ParameterInfo` or `CompletionItem`. Please note that `MarkupKinds` must not start with a `$`. This kinds are reserved for internal usage. """ PlainText = "plaintext" """ Plain text is supported as a content format """ Markdown = "markdown" """ Markdown is supported as a content format """ class PositionEncodingKind(Enum): """A set of predefined position encoding kinds. @since 3.17.0 """ UTF8 = "utf-8" """ Character offsets count UTF-8 code units. """ UTF16 = "utf-16" """ Character offsets count UTF-16 code units. This is the default and must always be supported by servers """ UTF32 = "utf-32" """ Character offsets count UTF-32 code units. Implementation note: these are the same as Unicode code points, so this `PositionEncodingKind` may also be used for an encoding-agnostic representation of character offsets. """ class FileChangeType(IntEnum): """The file event type""" Created = 1 """ The file got created. """ Changed = 2 """ The file got changed. """ Deleted = 3 """ The file got deleted. """ class WatchKind(IntFlag): Create = 1 """ Interested in create events. """ Change = 2 """ Interested in change events """ Delete = 4 """ Interested in delete events """ class DiagnosticSeverity(IntEnum): """The diagnostic's severity.""" Error = 1 """ Reports an error. """ Warning = 2 """ Reports a warning. """ Information = 3 """ Reports an information. """ Hint = 4 """ Reports a hint. """ class DiagnosticTag(IntEnum): """The diagnostic tags. @since 3.15.0 """ Unnecessary = 1 """ Unused or unnecessary code. Clients are allowed to render diagnostics with this tag faded out instead of having an error squiggle. """ Deprecated = 2 """ Deprecated or obsolete code. Clients are allowed to rendered diagnostics with this tag strike through. """ class CompletionTriggerKind(IntEnum): """How a completion was triggered""" Invoked = 1 """ Completion was triggered by typing an identifier (24x7 code complete), manual invocation (e.g Ctrl+Space) or via API. """ TriggerCharacter = 2 """ Completion was triggered by a trigger character specified by the `triggerCharacters` properties of the `CompletionRegistrationOptions`. """ TriggerForIncompleteCompletions = 3 """ Completion was re-triggered as current completion list is incomplete """ class SignatureHelpTriggerKind(IntEnum): """How a signature help was triggered. @since 3.15.0 """ Invoked = 1 """ Signature help was invoked manually by the user or by a command. """ TriggerCharacter = 2 """ Signature help was triggered by a trigger character. """ ContentChange = 3 """ Signature help was triggered by the cursor moving or by the document content changing. """ class CodeActionTriggerKind(IntEnum): """The reason why code actions were requested. @since 3.17.0 """ Invoked = 1 """ Code actions were explicitly requested by the user or by an extension. """ Automatic = 2 """ Code actions were requested automatically. This typically happens when current selection in a file changes, but can also be triggered when file content changes. """ class FileOperationPatternKind(Enum): """A pattern kind describing if a glob pattern matches a file a folder or both. @since 3.16.0 """ File = "file" """ The pattern matches a file only. """ Folder = "folder" """ The pattern matches a folder only. """ class NotebookCellKind(IntEnum): """A notebook cell kind. @since 3.17.0 """ Markup = 1 """ A markup-cell is formatted source that is used for display. """ Code = 2 """ A code-cell is source code. """ class ResourceOperationKind(Enum): Create = "create" """ Supports creating new files and folders. """ Rename = "rename" """ Supports renaming existing files and folders. """ Delete = "delete" """ Supports deleting existing files and folders. """ class FailureHandlingKind(Enum): Abort = "abort" """ Applying the workspace change is simply aborted if one of the changes provided fails. All operations executed before the failing operation stay executed. """ Transactional = "transactional" """ All operations are executed transactional. That means they either all succeed or no changes at all are applied to the workspace. """ TextOnlyTransactional = "textOnlyTransactional" """ If the workspace edit contains only textual file changes they are executed transactional. If resource changes (create, rename or delete file) are part of the change the failure handling strategy is abort. """ Undo = "undo" """ The client tries to undo the operations already executed. But there is no guarantee that this is succeeding. """ class PrepareSupportDefaultBehavior(IntEnum): Identifier = 1 """ The client's default behavior is to select the identifier according the to language's syntax rule. """ class TokenFormat(Enum): Relative = "relative" Definition = Union["Location", list["Location"]] """ The definition of a symbol represented as one or many {@link Location locations}. For most programming languages there is only one location at which a symbol is defined. Servers should prefer returning `DefinitionLink` over `Definition` if supported by the client. """ DefinitionLink = "LocationLink" """ Information about where a symbol is defined. Provides additional metadata over normal {@link Location location} definitions, including the range of the defining symbol """ LSPArray = list["LSPAny"] """ LSP arrays. @since 3.17.0 """ LSPAny = Union["LSPObject", "LSPArray", str, int, Uint, float, bool, None] """ The LSP any type. Please note that strictly speaking a property with the value `undefined` can't be converted into JSON preserving the property name. However for convenience it is allowed and assumed that all these properties are optional as well. @since 3.17.0 """ Declaration = Union["Location", list["Location"]] """ The declaration of a symbol representation as one or many {@link Location locations}. """ DeclarationLink = "LocationLink" """ Information about where a symbol is declared. Provides additional metadata over normal {@link Location location} declarations, including the range of the declaring symbol. Servers should prefer returning `DeclarationLink` over `Declaration` if supported by the client. """ InlineValue = Union["InlineValueText", "InlineValueVariableLookup", "InlineValueEvaluatableExpression"] """ Inline value information can be provided by different means: - directly as a text value (class InlineValueText). - as a name to use for a variable lookup (class InlineValueVariableLookup) - as an evaluatable expression (class InlineValueEvaluatableExpression) The InlineValue types combines all inline value types into one type. @since 3.17.0 """ DocumentDiagnosticReport = Union["RelatedFullDocumentDiagnosticReport", "RelatedUnchangedDocumentDiagnosticReport"] """ The result of a document diagnostic pull request. A report can either be a full report containing all diagnostics for the requested document or an unchanged report indicating that nothing has changed in terms of diagnostics in comparison to the last pull request. @since 3.17.0 """ PrepareRenameResult = Union["Range", "__PrepareRenameResult_Type_1", "__PrepareRenameResult_Type_2"] DocumentSelector = list["DocumentFilter"] """ A document selector is the combination of one or many document filters. @sample `let sel:DocumentSelector = [{ language: 'typescript' }, { language: 'json', pattern: '**/tsconfig.json' }]`; The use of a string as a document filter is deprecated @since 3.16.0. """ ProgressToken = Union[int, str] ChangeAnnotationIdentifier = str """ An identifier to refer to a change annotation stored with a workspace edit. """ WorkspaceDocumentDiagnosticReport = Union[ "WorkspaceFullDocumentDiagnosticReport", "WorkspaceUnchangedDocumentDiagnosticReport", ] """ A workspace diagnostic document report. @since 3.17.0 """ TextDocumentContentChangeEvent = Union["__TextDocumentContentChangeEvent_Type_1", "__TextDocumentContentChangeEvent_Type_2"] """ An event describing a change to a text document. If only a text is provided it is considered to be the full content of the document. """ MarkedString = Union[str, "__MarkedString_Type_1"] """ MarkedString can be used to render human readable text. It is either a markdown string or a code-block that provides a language and a code snippet. The language identifier is semantically equal to the optional language identifier in fenced code blocks in GitHub issues. See https://help.github.com/articles/creating-and-highlighting-code-blocks/#syntax-highlighting The pair of a language and a value is an equivalent to markdown: ```${language} ${value} ``` Note that markdown strings will be sanitized - that means html will be escaped. @deprecated use MarkupContent instead. """ DocumentFilter = Union["TextDocumentFilter", "NotebookCellTextDocumentFilter"] """ A document filter describes a top level text document or a notebook cell document. @since 3.17.0 - proposed support for NotebookCellTextDocumentFilter. """ LSPObject = dict[str, "LSPAny"] """ LSP object definition. @since 3.17.0 """ GlobPattern = Union["Pattern", "RelativePattern"] """ The glob pattern. Either a string pattern or a relative pattern. @since 3.17.0 """ TextDocumentFilter = Union[ "__TextDocumentFilter_Type_1", "__TextDocumentFilter_Type_2", "__TextDocumentFilter_Type_3", ] """ A document filter denotes a document by different properties like the {@link TextDocument.languageId language}, the {@link Uri.scheme scheme} of its resource, or a glob-pattern that is applied to the {@link TextDocument.fileName path}. Glob patterns can have the following syntax: - `*` to match one or more characters in a path segment - `?` to match on one character in a path segment - `**` to match any number of path segments, including none - `{}` to group sub patterns into an OR expression. (e.g. `**\u200b/*.{ts,js}` matches all TypeScript and JavaScript files) - `[]` to declare a range of characters to match in a path segment (e.g., `example.[0-9]` to match on `example.0`, `example.1`, …) - `[!...]` to negate a range of characters to match in a path segment (e.g., `example.[!0-9]` to match on `example.a`, `example.b`, but not `example.0`) @sample A language filter that applies to typescript files on disk: `{ language: 'typescript', scheme: 'file' }` @sample A language filter that applies to all package.json paths: `{ language: 'json', pattern: '**package.json' }` @since 3.17.0 """ NotebookDocumentFilter = Union[ "__NotebookDocumentFilter_Type_1", "__NotebookDocumentFilter_Type_2", "__NotebookDocumentFilter_Type_3", ] """ A notebook document filter denotes a notebook document by different properties. The properties will be match against the notebook's URI (same as with documents) @since 3.17.0 """ Pattern = str """ The glob pattern to watch relative to the base path. Glob patterns can have the following syntax: - `*` to match one or more characters in a path segment - `?` to match on one character in a path segment - `**` to match any number of path segments, including none - `{}` to group conditions (e.g. `**\u200b/*.{ts,js}` matches all TypeScript and JavaScript files) - `[]` to declare a range of characters to match in a path segment (e.g., `example.[0-9]` to match on `example.0`, `example.1`, …) - `[!...]` to negate a range of characters to match in a path segment (e.g., `example.[!0-9]` to match on `example.a`, `example.b`, but not `example.0`) @since 3.17.0 """ class ImplementationParams(TypedDict): textDocument: "TextDocumentIdentifier" """ The text document. """ position: "Position" """ The position inside the text document. """ workDoneToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report work done progress. """ partialResultToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report partial results (e.g. streaming) to the client. """ class Location(TypedDict): """Represents a location inside a resource, such as a line inside a text file. """ uri: "DocumentUri" range: "Range" class ImplementationRegistrationOptions(TypedDict): documentSelector: Union["DocumentSelector", None] """ A document selector to identify the scope of the registration. If set to null the document selector provided on the client side will be used. """ id: NotRequired[str] """ The id used to register the request. The id can be used to deregister the request again. See also Registration#id. """ class TypeDefinitionParams(TypedDict): textDocument: "TextDocumentIdentifier" """ The text document. """ position: "Position" """ The position inside the text document. """ workDoneToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report work done progress. """ partialResultToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report partial results (e.g. streaming) to the client. """ class TypeDefinitionRegistrationOptions(TypedDict): documentSelector: Union["DocumentSelector", None] """ A document selector to identify the scope of the registration. If set to null the document selector provided on the client side will be used. """ id: NotRequired[str] """ The id used to register the request. The id can be used to deregister the request again. See also Registration#id. """ class WorkspaceFolder(TypedDict): """A workspace folder inside a client.""" uri: "URI" """ The associated URI for this workspace folder. """ name: str """ The name of the workspace folder. Used to refer to this workspace folder in the user interface. """ class DidChangeWorkspaceFoldersParams(TypedDict): """The parameters of a `workspace/didChangeWorkspaceFolders` notification.""" event: "WorkspaceFoldersChangeEvent" """ The actual workspace folder change event. """ class ConfigurationParams(TypedDict): """The parameters of a configuration request.""" items: list["ConfigurationItem"] class DocumentColorParams(TypedDict): """Parameters for a {@link DocumentColorRequest}.""" textDocument: "TextDocumentIdentifier" """ The text document. """ workDoneToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report work done progress. """ partialResultToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report partial results (e.g. streaming) to the client. """ class ColorInformation(TypedDict): """Represents a color range from a document.""" range: "Range" """ The range in the document where this color appears. """ color: "Color" """ The actual color value for this color range. """ class DocumentColorRegistrationOptions(TypedDict): documentSelector: Union["DocumentSelector", None] """ A document selector to identify the scope of the registration. If set to null the document selector provided on the client side will be used. """ id: NotRequired[str] """ The id used to register the request. The id can be used to deregister the request again. See also Registration#id. """ class ColorPresentationParams(TypedDict): """Parameters for a {@link ColorPresentationRequest}.""" textDocument: "TextDocumentIdentifier" """ The text document. """ color: "Color" """ The color to request presentations for. """ range: "Range" """ The range where the color would be inserted. Serves as a context. """ workDoneToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report work done progress. """ partialResultToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report partial results (e.g. streaming) to the client. """ class ColorPresentation(TypedDict): label: str """ The label of this color presentation. It will be shown on the color picker header. By default this is also the text that is inserted when selecting this color presentation. """ textEdit: NotRequired["TextEdit"] """ An {@link TextEdit edit} which is applied to a document when selecting this presentation for the color. When `falsy` the {@link ColorPresentation.label label} is used. """ additionalTextEdits: NotRequired[list["TextEdit"]] """ An optional array of additional {@link TextEdit text edits} that are applied when selecting this color presentation. Edits must not overlap with the main {@link ColorPresentation.textEdit edit} nor with themselves. """ class WorkDoneProgressOptions(TypedDict): workDoneProgress: NotRequired[bool] class TextDocumentRegistrationOptions(TypedDict): """General text document registration options.""" documentSelector: Union["DocumentSelector", None] """ A document selector to identify the scope of the registration. If set to null the document selector provided on the client side will be used. """ class FoldingRangeParams(TypedDict): """Parameters for a {@link FoldingRangeRequest}.""" textDocument: "TextDocumentIdentifier" """ The text document. """ workDoneToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report work done progress. """ partialResultToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report partial results (e.g. streaming) to the client. """ class FoldingRange(TypedDict): """Represents a folding range. To be valid, start and end line must be bigger than zero and smaller than the number of lines in the document. Clients are free to ignore invalid ranges. """ startLine: Uint """ The zero-based start line of the range to fold. The folded area starts after the line's last character. To be valid, the end must be zero or larger and smaller than the number of lines in the document. """ startCharacter: NotRequired[Uint] """ The zero-based character offset from where the folded range starts. If not defined, defaults to the length of the start line. """ endLine: Uint """ The zero-based end line of the range to fold. The folded area ends with the line's last character. To be valid, the end must be zero or larger and smaller than the number of lines in the document. """ endCharacter: NotRequired[Uint] """ The zero-based character offset before the folded range ends. If not defined, defaults to the length of the end line. """ kind: NotRequired["FoldingRangeKind"] """ Describes the kind of the folding range such as `comment' or 'region'. The kind is used to categorize folding ranges and used by commands like 'Fold all comments'. See {@link FoldingRangeKind} for an enumeration of standardized kinds. """ collapsedText: NotRequired[str] """ The text that the client should show when the specified range is collapsed. If not defined or not supported by the client, a default will be chosen by the client. @since 3.17.0 """ class FoldingRangeRegistrationOptions(TypedDict): documentSelector: Union["DocumentSelector", None] """ A document selector to identify the scope of the registration. If set to null the document selector provided on the client side will be used. """ id: NotRequired[str] """ The id used to register the request. The id can be used to deregister the request again. See also Registration#id. """ class DeclarationParams(TypedDict): textDocument: "TextDocumentIdentifier" """ The text document. """ position: "Position" """ The position inside the text document. """ workDoneToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report work done progress. """ partialResultToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report partial results (e.g. streaming) to the client. """ class DeclarationRegistrationOptions(TypedDict): documentSelector: Union["DocumentSelector", None] """ A document selector to identify the scope of the registration. If set to null the document selector provided on the client side will be used. """ id: NotRequired[str] """ The id used to register the request. The id can be used to deregister the request again. See also Registration#id. """ class SelectionRangeParams(TypedDict): """A parameter literal used in selection range requests.""" textDocument: "TextDocumentIdentifier" """ The text document. """ positions: list["Position"] """ The positions inside the text document. """ workDoneToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report work done progress. """ partialResultToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report partial results (e.g. streaming) to the client. """ class SelectionRange(TypedDict): """A selection range represents a part of a selection hierarchy. A selection range may have a parent selection range that contains it. """ range: "Range" """ The {@link Range range} of this selection range. """ parent: NotRequired["SelectionRange"] """ The parent selection range containing this range. Therefore `parent.range` must contain `this.range`. """ class SelectionRangeRegistrationOptions(TypedDict): documentSelector: Union["DocumentSelector", None] """ A document selector to identify the scope of the registration. If set to null the document selector provided on the client side will be used. """ id: NotRequired[str] """ The id used to register the request. The id can be used to deregister the request again. See also Registration#id. """ class WorkDoneProgressCreateParams(TypedDict): token: "ProgressToken" """ The token to be used to report progress. """ class WorkDoneProgressCancelParams(TypedDict): token: "ProgressToken" """ The token to be used to report progress. """ class CallHierarchyPrepareParams(TypedDict): """The parameter of a `textDocument/prepareCallHierarchy` request. @since 3.16.0 """ textDocument: "TextDocumentIdentifier" """ The text document. """ position: "Position" """ The position inside the text document. """ workDoneToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report work done progress. """ class CallHierarchyItem(TypedDict): """Represents programming constructs like functions or constructors in the context of call hierarchy. @since 3.16.0 """ name: str """ The name of this item. """ kind: "SymbolKind" """ The kind of this item. """ tags: NotRequired[list["SymbolTag"]] """ Tags for this item. """ detail: NotRequired[str] """ More detail for this item, e.g. the signature of a function. """ uri: "DocumentUri" """ The resource identifier of this item. """ range: "Range" """ The range enclosing this symbol not including leading/trailing whitespace but everything else, e.g. comments and code. """ selectionRange: "Range" """ The range that should be selected and revealed when this symbol is being picked, e.g. the name of a function. Must be contained by the {@link CallHierarchyItem.range `range`}. """ data: NotRequired["LSPAny"] """ A data entry field that is preserved between a call hierarchy prepare and incoming calls or outgoing calls requests. """ class CallHierarchyRegistrationOptions(TypedDict): """Call hierarchy options used during static or dynamic registration. @since 3.16.0 """ documentSelector: Union["DocumentSelector", None] """ A document selector to identify the scope of the registration. If set to null the document selector provided on the client side will be used. """ id: NotRequired[str] """ The id used to register the request. The id can be used to deregister the request again. See also Registration#id. """ class CallHierarchyIncomingCallsParams(TypedDict): """The parameter of a `callHierarchy/incomingCalls` request. @since 3.16.0 """ item: "CallHierarchyItem" workDoneToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report work done progress. """ partialResultToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report partial results (e.g. streaming) to the client. """ CallHierarchyIncomingCall = TypedDict( "CallHierarchyIncomingCall", { # The item that makes the call. "from": "CallHierarchyItem", # The ranges at which the calls appear. This is relative to the caller # denoted by {@link CallHierarchyIncomingCall.from `this.from`}. "fromRanges": list["Range"], }, ) """ Represents an incoming call, e.g. a caller of a method or constructor. @since 3.16.0 """ class CallHierarchyOutgoingCallsParams(TypedDict): """The parameter of a `callHierarchy/outgoingCalls` request. @since 3.16.0 """ item: "CallHierarchyItem" workDoneToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report work done progress. """ partialResultToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report partial results (e.g. streaming) to the client. """ class CallHierarchyOutgoingCall(TypedDict): """Represents an outgoing call, e.g. calling a getter from a method or a method from a constructor etc. @since 3.16.0 """ to: "CallHierarchyItem" """ The item that is called. """ fromRanges: list["Range"] """ The range at which this item is called. This is the range relative to the caller, e.g the item passed to {@link CallHierarchyItemProvider.provideCallHierarchyOutgoingCalls `provideCallHierarchyOutgoingCalls`} and not {@link CallHierarchyOutgoingCall.to `this.to`}. """ class SemanticTokensParams(TypedDict): """@since 3.16.0""" textDocument: "TextDocumentIdentifier" """ The text document. """ workDoneToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report work done progress. """ partialResultToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report partial results (e.g. streaming) to the client. """ class SemanticTokens(TypedDict): """@since 3.16.0""" resultId: NotRequired[str] """ An optional result id. If provided and clients support delta updating the client will include the result id in the next semantic token request. A server can then instead of computing all semantic tokens again simply send a delta. """ data: list[Uint] """ The actual tokens. """ class SemanticTokensPartialResult(TypedDict): """@since 3.16.0""" data: list[Uint] class SemanticTokensRegistrationOptions(TypedDict): """@since 3.16.0""" documentSelector: Union["DocumentSelector", None] """ A document selector to identify the scope of the registration. If set to null the document selector provided on the client side will be used. """ legend: "SemanticTokensLegend" """ The legend used by the server """ range: NotRequired[bool | dict] """ Server supports providing semantic tokens for a specific range of a document. """ full: NotRequired[Union[bool, "__SemanticTokensOptions_full_Type_1"]] """ Server supports providing semantic tokens for a full document. """ id: NotRequired[str] """ The id used to register the request. The id can be used to deregister the request again. See also Registration#id. """ class SemanticTokensDeltaParams(TypedDict): """@since 3.16.0""" textDocument: "TextDocumentIdentifier" """ The text document. """ previousResultId: str """ The result id of a previous response. The result Id can either point to a full response or a delta response depending on what was received last. """ workDoneToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report work done progress. """ partialResultToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report partial results (e.g. streaming) to the client. """ class SemanticTokensDelta(TypedDict): """@since 3.16.0""" resultId: NotRequired[str] edits: list["SemanticTokensEdit"] """ The semantic token edits to transform a previous result into a new result. """ class SemanticTokensDeltaPartialResult(TypedDict): """@since 3.16.0""" edits: list["SemanticTokensEdit"] class SemanticTokensRangeParams(TypedDict): """@since 3.16.0""" textDocument: "TextDocumentIdentifier" """ The text document. """ range: "Range" """ The range the semantic tokens are requested for. """ workDoneToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report work done progress. """ partialResultToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report partial results (e.g. streaming) to the client. """ class ShowDocumentParams(TypedDict): """Params to show a document. @since 3.16.0 """ uri: "URI" """ The document uri to show. """ external: NotRequired[bool] """ Indicates to show the resource in an external program. To show for example `https://code.visualstudio.com/` in the default WEB browser set `external` to `true`. """ takeFocus: NotRequired[bool] """ An optional property to indicate whether the editor showing the document should take focus or not. Clients might ignore this property if an external program is started. """ selection: NotRequired["Range"] """ An optional selection range if the document is a text document. Clients might ignore the property if an external program is started or the file is not a text file. """ class ShowDocumentResult(TypedDict): """The result of a showDocument request. @since 3.16.0 """ success: bool """ A boolean indicating if the show was successful. """ class LinkedEditingRangeParams(TypedDict): textDocument: "TextDocumentIdentifier" """ The text document. """ position: "Position" """ The position inside the text document. """ workDoneToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report work done progress. """ class LinkedEditingRanges(TypedDict): """The result of a linked editing range request. @since 3.16.0 """ ranges: list["Range"] """ A list of ranges that can be edited together. The ranges must have identical length and contain identical text content. The ranges cannot overlap. """ wordPattern: NotRequired[str] """ An optional word pattern (regular expression) that describes valid contents for the given ranges. If no pattern is provided, the client configuration's word pattern will be used. """ class LinkedEditingRangeRegistrationOptions(TypedDict): documentSelector: Union["DocumentSelector", None] """ A document selector to identify the scope of the registration. If set to null the document selector provided on the client side will be used. """ id: NotRequired[str] """ The id used to register the request. The id can be used to deregister the request again. See also Registration#id. """ class CreateFilesParams(TypedDict): """The parameters sent in notifications/requests for user-initiated creation of files. @since 3.16.0 """ files: list["FileCreate"] """ An array of all files/folders created in this operation. """ class WorkspaceEdit(TypedDict): """A workspace edit represents changes to many resources managed in the workspace. The edit should either provide `changes` or `documentChanges`. If documentChanges are present they are preferred over `changes` if the client can handle versioned document edits. Since version 3.13.0 a workspace edit can contain resource operations as well. If resource operations are present clients need to execute the operations in the order in which they are provided. So a workspace edit for example can consist of the following two changes: (1) a create file a.txt and (2) a text document edit which insert text into file a.txt. An invalid sequence (e.g. (1) delete file a.txt and (2) insert text into file a.txt) will cause failure of the operation. How the client recovers from the failure is described by the client capability: `workspace.workspaceEdit.failureHandling` """ changes: NotRequired[dict["DocumentUri", list["TextEdit"]]] """ Holds changes to existing resources. """ documentChanges: NotRequired[list[Union["TextDocumentEdit", "CreateFile", "RenameFile", "DeleteFile"]]] """ Depending on the client capability `workspace.workspaceEdit.resourceOperations` document changes are either an array of `TextDocumentEdit`s to express changes to n different text documents where each text document edit addresses a specific version of a text document. Or it can contain above `TextDocumentEdit`s mixed with create, rename and delete file / folder operations. Whether a client supports versioned document edits is expressed via `workspace.workspaceEdit.documentChanges` client capability. If a client neither supports `documentChanges` nor `workspace.workspaceEdit.resourceOperations` then only plain `TextEdit`s using the `changes` property are supported. """ changeAnnotations: NotRequired[dict["ChangeAnnotationIdentifier", "ChangeAnnotation"]] """ A map of change annotations that can be referenced in `AnnotatedTextEdit`s or create, rename and delete file / folder operations. Whether clients honor this property depends on the client capability `workspace.changeAnnotationSupport`. @since 3.16.0 """ class FileOperationRegistrationOptions(TypedDict): """The options to register for file operations. @since 3.16.0 """ filters: list["FileOperationFilter"] """ The actual filters. """ class RenameFilesParams(TypedDict): """The parameters sent in notifications/requests for user-initiated renames of files. @since 3.16.0 """ files: list["FileRename"] """ An array of all files/folders renamed in this operation. When a folder is renamed, only the folder will be included, and not its children. """ class DeleteFilesParams(TypedDict): """The parameters sent in notifications/requests for user-initiated deletes of files. @since 3.16.0 """ files: list["FileDelete"] """ An array of all files/folders deleted in this operation. """ class MonikerParams(TypedDict): textDocument: "TextDocumentIdentifier" """ The text document. """ position: "Position" """ The position inside the text document. """ workDoneToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report work done progress. """ partialResultToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report partial results (e.g. streaming) to the client. """ class Moniker(TypedDict): """Moniker definition to match LSIF 0.5 moniker definition. @since 3.16.0 """ scheme: str """ The scheme of the moniker. For example tsc or .Net """ identifier: str """ The identifier of the moniker. The value is opaque in LSIF however schema owners are allowed to define the structure if they want. """ unique: "UniquenessLevel" """ The scope in which the moniker is unique """ kind: NotRequired["MonikerKind"] """ The moniker kind if known. """ class MonikerRegistrationOptions(TypedDict): documentSelector: Union["DocumentSelector", None] """ A document selector to identify the scope of the registration. If set to null the document selector provided on the client side will be used. """ class TypeHierarchyPrepareParams(TypedDict): """The parameter of a `textDocument/prepareTypeHierarchy` request. @since 3.17.0 """ textDocument: "TextDocumentIdentifier" """ The text document. """ position: "Position" """ The position inside the text document. """ workDoneToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report work done progress. """ class TypeHierarchyItem(TypedDict): """@since 3.17.0""" name: str """ The name of this item. """ kind: "SymbolKind" """ The kind of this item. """ tags: NotRequired[list["SymbolTag"]] """ Tags for this item. """ detail: NotRequired[str] """ More detail for this item, e.g. the signature of a function. """ uri: "DocumentUri" """ The resource identifier of this item. """ range: "Range" """ The range enclosing this symbol not including leading/trailing whitespace but everything else, e.g. comments and code. """ selectionRange: "Range" """ The range that should be selected and revealed when this symbol is being picked, e.g. the name of a function. Must be contained by the {@link TypeHierarchyItem.range `range`}. """ data: NotRequired["LSPAny"] """ A data entry field that is preserved between a type hierarchy prepare and supertypes or subtypes requests. It could also be used to identify the type hierarchy in the server, helping improve the performance on resolving supertypes and subtypes. """ class TypeHierarchyRegistrationOptions(TypedDict): """Type hierarchy options used during static or dynamic registration. @since 3.17.0 """ documentSelector: Union["DocumentSelector", None] """ A document selector to identify the scope of the registration. If set to null the document selector provided on the client side will be used. """ id: NotRequired[str] """ The id used to register the request. The id can be used to deregister the request again. See also Registration#id. """ class TypeHierarchySupertypesParams(TypedDict): """The parameter of a `typeHierarchy/supertypes` request. @since 3.17.0 """ item: "TypeHierarchyItem" workDoneToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report work done progress. """ partialResultToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report partial results (e.g. streaming) to the client. """ class TypeHierarchySubtypesParams(TypedDict): """The parameter of a `typeHierarchy/subtypes` request. @since 3.17.0 """ item: "TypeHierarchyItem" workDoneToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report work done progress. """ partialResultToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report partial results (e.g. streaming) to the client. """ class InlineValueParams(TypedDict): """A parameter literal used in inline value requests. @since 3.17.0 """ textDocument: "TextDocumentIdentifier" """ The text document. """ range: "Range" """ The document range for which inline values should be computed. """ context: "InlineValueContext" """ Additional information about the context in which inline values were requested. """ workDoneToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report work done progress. """ class InlineValueRegistrationOptions(TypedDict): """Inline value options used during static or dynamic registration. @since 3.17.0 """ documentSelector: Union["DocumentSelector", None] """ A document selector to identify the scope of the registration. If set to null the document selector provided on the client side will be used. """ id: NotRequired[str] """ The id used to register the request. The id can be used to deregister the request again. See also Registration#id. """ class InlayHintParams(TypedDict): """A parameter literal used in inlay hint requests. @since 3.17.0 """ textDocument: "TextDocumentIdentifier" """ The text document. """ range: "Range" """ The document range for which inlay hints should be computed. """ workDoneToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report work done progress. """ class InlayHint(TypedDict): """Inlay hint information. @since 3.17.0 """ position: "Position" """ The position of this hint. """ label: str | list["InlayHintLabelPart"] """ The label of this hint. A human readable string or an array of InlayHintLabelPart label parts. *Note* that neither the string nor the label part can be empty. """ kind: NotRequired["InlayHintKind"] """ The kind of this hint. Can be omitted in which case the client should fall back to a reasonable default. """ textEdits: NotRequired[list["TextEdit"]] """ Optional text edits that are performed when accepting this inlay hint. *Note* that edits are expected to change the document so that the inlay hint (or its nearest variant) is now part of the document and the inlay hint itself is now obsolete. """ tooltip: NotRequired[Union[str, "MarkupContent"]] """ The tooltip text when you hover over this item. """ paddingLeft: NotRequired[bool] """ Render padding before the hint. Note: Padding should use the editor's background color, not the background color of the hint itself. That means padding can be used to visually align/separate an inlay hint. """ paddingRight: NotRequired[bool] """ Render padding after the hint. Note: Padding should use the editor's background color, not the background color of the hint itself. That means padding can be used to visually align/separate an inlay hint. """ data: NotRequired["LSPAny"] """ A data entry field that is preserved on an inlay hint between a `textDocument/inlayHint` and a `inlayHint/resolve` request. """ class InlayHintRegistrationOptions(TypedDict): """Inlay hint options used during static or dynamic registration. @since 3.17.0 """ resolveProvider: NotRequired[bool] """ The server provides support to resolve additional information for an inlay hint item. """ documentSelector: Union["DocumentSelector", None] """ A document selector to identify the scope of the registration. If set to null the document selector provided on the client side will be used. """ id: NotRequired[str] """ The id used to register the request. The id can be used to deregister the request again. See also Registration#id. """ class DocumentDiagnosticParams(TypedDict): """Parameters of the document diagnostic request. @since 3.17.0 """ textDocument: "TextDocumentIdentifier" """ The text document. """ identifier: NotRequired[str] """ The additional identifier provided during registration. """ previousResultId: NotRequired[str] """ The result id of a previous response if provided. """ workDoneToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report work done progress. """ partialResultToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report partial results (e.g. streaming) to the client. """ class DocumentDiagnosticReportPartialResult(TypedDict): """A partial result for a document diagnostic report. @since 3.17.0 """ relatedDocuments: dict[ "DocumentUri", Union["FullDocumentDiagnosticReport", "UnchangedDocumentDiagnosticReport"], ] class DiagnosticServerCancellationData(TypedDict): """Cancellation data returned from a diagnostic request. @since 3.17.0 """ retriggerRequest: bool class DiagnosticRegistrationOptions(TypedDict): """Diagnostic registration options. @since 3.17.0 """ documentSelector: Union["DocumentSelector", None] """ A document selector to identify the scope of the registration. If set to null the document selector provided on the client side will be used. """ identifier: NotRequired[str] """ An optional identifier under which the diagnostics are managed by the client. """ interFileDependencies: bool """ Whether the language has inter file dependencies meaning that editing code in one file can result in a different diagnostic set in another file. Inter file dependencies are common for most programming languages and typically uncommon for linters. """ workspaceDiagnostics: bool """ The server provides support for workspace diagnostics as well. """ id: NotRequired[str] """ The id used to register the request. The id can be used to deregister the request again. See also Registration#id. """ class WorkspaceDiagnosticParams(TypedDict): """Parameters of the workspace diagnostic request. @since 3.17.0 """ identifier: NotRequired[str] """ The additional identifier provided during registration. """ previousResultIds: list["PreviousResultId"] """ The currently known diagnostic reports with their previous result ids. """ workDoneToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report work done progress. """ partialResultToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report partial results (e.g. streaming) to the client. """ class WorkspaceDiagnosticReport(TypedDict): """A workspace diagnostic report. @since 3.17.0 """ items: list["WorkspaceDocumentDiagnosticReport"] class WorkspaceDiagnosticReportPartialResult(TypedDict): """A partial result for a workspace diagnostic report. @since 3.17.0 """ items: list["WorkspaceDocumentDiagnosticReport"] class DidOpenNotebookDocumentParams(TypedDict): """The params sent in an open notebook document notification. @since 3.17.0 """ notebookDocument: "NotebookDocument" """ The notebook document that got opened. """ cellTextDocuments: list["TextDocumentItem"] """ The text documents that represent the content of a notebook cell. """ class DidChangeNotebookDocumentParams(TypedDict): """The params sent in a change notebook document notification. @since 3.17.0 """ notebookDocument: "VersionedNotebookDocumentIdentifier" """ The notebook document that did change. The version number points to the version after all provided changes have been applied. If only the text document content of a cell changes the notebook version doesn't necessarily have to change. """ change: "NotebookDocumentChangeEvent" """ The actual changes to the notebook document. The changes describe single state changes to the notebook document. So if there are two changes c1 (at array index 0) and c2 (at array index 1) for a notebook in state S then c1 moves the notebook from S to S' and c2 from S' to S''. So c1 is computed on the state S and c2 is computed on the state S'. To mirror the content of a notebook using change events use the following approach: - start with the same initial content - apply the 'notebookDocument/didChange' notifications in the order you receive them. - apply the `NotebookChangeEvent`s in a single notification in the order you receive them. """ class DidSaveNotebookDocumentParams(TypedDict): """The params sent in a save notebook document notification. @since 3.17.0 """ notebookDocument: "NotebookDocumentIdentifier" """ The notebook document that got saved. """ class DidCloseNotebookDocumentParams(TypedDict): """The params sent in a close notebook document notification. @since 3.17.0 """ notebookDocument: "NotebookDocumentIdentifier" """ The notebook document that got closed. """ cellTextDocuments: list["TextDocumentIdentifier"] """ The text documents that represent the content of a notebook cell that got closed. """ class RegistrationParams(TypedDict): registrations: list["Registration"] class UnregistrationParams(TypedDict): unregisterations: list["Unregistration"] class InitializeParams(TypedDict): processId: int | None """ The process Id of the parent process that started the server. Is `null` if the process has not been started by another process. If the parent process is not alive then the server should exit. """ clientInfo: NotRequired["___InitializeParams_clientInfo_Type_1"] """ Information about the client @since 3.15.0 """ locale: NotRequired[str] """ The locale the client is currently showing the user interface in. This must not necessarily be the locale of the operating system. Uses IETF language tags as the value's syntax (See https://en.wikipedia.org/wiki/IETF_language_tag) @since 3.16.0 """ rootPath: NotRequired[str | None] """ The rootPath of the workspace. Is null if no folder is open. @deprecated in favour of rootUri. """ rootUri: Union["DocumentUri", None] """ The rootUri of the workspace. Is null if no folder is open. If both `rootPath` and `rootUri` are set `rootUri` wins. @deprecated in favour of workspaceFolders. """ capabilities: "ClientCapabilities" """ The capabilities provided by the client (editor or tool) """ initializationOptions: NotRequired["LSPAny"] """ User provided initialization options. """ trace: NotRequired["TraceValues"] """ The initial trace setting. If omitted trace is disabled ('off'). """ workspaceFolders: NotRequired[list["WorkspaceFolder"] | None] """ The workspace folders configured in the client when the server starts. This property is only available if the client supports workspace folders. It can be `null` if the client supports workspace folders but none are configured. @since 3.6.0 """ class InitializeResult(TypedDict): """The result returned from an initialize request.""" capabilities: "ServerCapabilities" """ The capabilities the language server provides. """ serverInfo: NotRequired["__InitializeResult_serverInfo_Type_1"] """ Information about the server. @since 3.15.0 """ class InitializeError(TypedDict): """The data type of the ResponseError if the initialize request fails. """ retry: bool """ Indicates whether the client execute the following retry logic: (1) show the message provided by the ResponseError to the user (2) user selects retry or cancel (3) if user selected retry the initialize method is sent again. """ class InitializedParams(TypedDict): pass class DidChangeConfigurationParams(TypedDict): """The parameters of a change configuration notification.""" settings: "LSPAny" """ The actual changed settings """ class DidChangeConfigurationRegistrationOptions(TypedDict): section: NotRequired[str | list[str]] class ShowMessageParams(TypedDict): """The parameters of a notification message.""" type: "MessageType" """ The message type. See {@link MessageType} """ message: str """ The actual message. """ class ShowMessageRequestParams(TypedDict): type: "MessageType" """ The message type. See {@link MessageType} """ message: str """ The actual message. """ actions: NotRequired[list["MessageActionItem"]] """ The message action items to present. """ class MessageActionItem(TypedDict): title: str """ A short title like 'Retry', 'Open Log' etc. """ class LogMessageParams(TypedDict): """The log message parameters.""" type: "MessageType" """ The message type. See {@link MessageType} """ message: str """ The actual message. """ class DidOpenTextDocumentParams(TypedDict): """The parameters sent in an open text document notification""" textDocument: "TextDocumentItem" """ The document that was opened. """ class DidChangeTextDocumentParams(TypedDict): """The change text document notification's parameters.""" textDocument: "VersionedTextDocumentIdentifier" """ The document that did change. The version number points to the version after all provided content changes have been applied. """ contentChanges: list["TextDocumentContentChangeEvent"] """ The actual content changes. The content changes describe single state changes to the document. So if there are two content changes c1 (at array index 0) and c2 (at array index 1) for a document in state S then c1 moves the document from S to S' and c2 from S' to S''. So c1 is computed on the state S and c2 is computed on the state S'. To mirror the content of a document using change events use the following approach: - start with the same initial content - apply the 'textDocument/didChange' notifications in the order you receive them. - apply the `TextDocumentContentChangeEvent`s in a single notification in the order you receive them. """ class TextDocumentChangeRegistrationOptions(TypedDict): """Describe options to be used when registered for text document change events.""" syncKind: "TextDocumentSyncKind" """ How documents are synced to the server. """ documentSelector: Union["DocumentSelector", None] """ A document selector to identify the scope of the registration. If set to null the document selector provided on the client side will be used. """ class DidCloseTextDocumentParams(TypedDict): """The parameters sent in a close text document notification""" textDocument: "TextDocumentIdentifier" """ The document that was closed. """ class DidSaveTextDocumentParams(TypedDict): """The parameters sent in a save text document notification""" textDocument: "TextDocumentIdentifier" """ The document that was saved. """ text: NotRequired[str] """ Optional the content when saved. Depends on the includeText value when the save notification was requested. """ class TextDocumentSaveRegistrationOptions(TypedDict): """Save registration options.""" documentSelector: Union["DocumentSelector", None] """ A document selector to identify the scope of the registration. If set to null the document selector provided on the client side will be used. """ includeText: NotRequired[bool] """ The client is supposed to include the content on save. """ class WillSaveTextDocumentParams(TypedDict): """The parameters sent in a will save text document notification.""" textDocument: "TextDocumentIdentifier" """ The document that will be saved. """ reason: "TextDocumentSaveReason" """ The 'TextDocumentSaveReason'. """ class TextEdit(TypedDict): """A text edit applicable to a text document.""" range: "Range" """ The range of the text document to be manipulated. To insert text into a document create a range where start === end. """ newText: str """ The string to be inserted. For delete operations use an empty string. """ class DidChangeWatchedFilesParams(TypedDict): """The watched files change notification's parameters.""" changes: list["FileEvent"] """ The actual file events. """ class DidChangeWatchedFilesRegistrationOptions(TypedDict): """Describe options to be used when registered for text document change events.""" watchers: list["FileSystemWatcher"] """ The watchers to register. """ class PublishDiagnosticsParams(TypedDict): """The publish diagnostic notification's parameters.""" uri: "DocumentUri" """ The URI for which diagnostic information is reported. """ version: NotRequired[int] """ Optional the version number of the document the diagnostics are published for. @since 3.15.0 """ diagnostics: list["Diagnostic"] """ An array of diagnostic information items. """ class CompletionParams(TypedDict): """Completion parameters""" context: NotRequired["CompletionContext"] """ The completion context. This is only available it the client specifies to send this using the client capability `textDocument.completion.contextSupport === true` """ textDocument: "TextDocumentIdentifier" """ The text document. """ position: "Position" """ The position inside the text document. """ workDoneToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report work done progress. """ partialResultToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report partial results (e.g. streaming) to the client. """ class CompletionItem(TypedDict): """A completion item represents a text snippet that is proposed to complete text that is being typed. """ label: str """ The label of this completion item. The label property is also by default the text that is inserted when selecting this completion. If label details are provided the label itself should be an unqualified name of the completion item. """ labelDetails: NotRequired["CompletionItemLabelDetails"] """ Additional details for the label @since 3.17.0 """ kind: NotRequired["CompletionItemKind"] """ The kind of this completion item. Based of the kind an icon is chosen by the editor. """ tags: NotRequired[list["CompletionItemTag"]] """ Tags for this completion item. @since 3.15.0 """ detail: NotRequired[str] """ A human-readable string with additional information about this item, like type or symbol information. """ documentation: NotRequired[Union[str, "MarkupContent"]] """ A human-readable string that represents a doc-comment. """ deprecated: NotRequired[bool] """ Indicates if this item is deprecated. @deprecated Use `tags` instead. """ preselect: NotRequired[bool] """ Select this item when showing. *Note* that only one completion item can be selected and that the tool / client decides which item that is. The rule is that the *first* item of those that match best is selected. """ sortText: NotRequired[str] """ A string that should be used when comparing this item with other items. When `falsy` the {@link CompletionItem.label label} is used. """ filterText: NotRequired[str] """ A string that should be used when filtering a set of completion items. When `falsy` the {@link CompletionItem.label label} is used. """ insertText: NotRequired[str] """ A string that should be inserted into a document when selecting this completion. When `falsy` the {@link CompletionItem.label label} is used. The `insertText` is subject to interpretation by the client side. Some tools might not take the string literally. For example VS Code when code complete is requested in this example `con` and a completion item with an `insertText` of `console` is provided it will only insert `sole`. Therefore it is recommended to use `textEdit` instead since it avoids additional client side interpretation. """ insertTextFormat: NotRequired["InsertTextFormat"] """ The format of the insert text. The format applies to both the `insertText` property and the `newText` property of a provided `textEdit`. If omitted defaults to `InsertTextFormat.PlainText`. Please note that the insertTextFormat doesn't apply to `additionalTextEdits`. """ insertTextMode: NotRequired["InsertTextMode"] """ How whitespace and indentation is handled during completion item insertion. If not provided the clients default value depends on the `textDocument.completion.insertTextMode` client capability. @since 3.16.0 """ textEdit: NotRequired[Union["TextEdit", "InsertReplaceEdit"]] """ An {@link TextEdit edit} which is applied to a document when selecting this completion. When an edit is provided the value of {@link CompletionItem.insertText insertText} is ignored. Most editors support two different operations when accepting a completion item. One is to insert a completion text and the other is to replace an existing text with a completion text. Since this can usually not be predetermined by a server it can report both ranges. Clients need to signal support for `InsertReplaceEdits` via the `textDocument.completion.insertReplaceSupport` client capability property. *Note 1:* The text edit's range as well as both ranges from an insert replace edit must be a [single line] and they must contain the position at which completion has been requested. *Note 2:* If an `InsertReplaceEdit` is returned the edit's insert range must be a prefix of the edit's replace range, that means it must be contained and starting at the same position. @since 3.16.0 additional type `InsertReplaceEdit` """ textEditText: NotRequired[str] """ The edit text used if the completion item is part of a CompletionList and CompletionList defines an item default for the text edit range. Clients will only honor this property if they opt into completion list item defaults using the capability `completionList.itemDefaults`. If not provided and a list's default range is provided the label property is used as a text. @since 3.17.0 """ additionalTextEdits: NotRequired[list["TextEdit"]] """ An optional array of additional {@link TextEdit text edits} that are applied when selecting this completion. Edits must not overlap (including the same insert position) with the main {@link CompletionItem.textEdit edit} nor with themselves. Additional text edits should be used to change text unrelated to the current cursor position (for example adding an import statement at the top of the file if the completion item will insert an unqualified type). """ commitCharacters: NotRequired[list[str]] """ An optional set of characters that when pressed while this completion is active will accept it first and then type that character. *Note* that all commit characters should have `length=1` and that superfluous characters will be ignored. """ command: NotRequired["Command"] """ An optional {@link Command command} that is executed *after* inserting this completion. *Note* that additional modifications to the current document should be described with the {@link CompletionItem.additionalTextEdits additionalTextEdits}-property. """ data: NotRequired["LSPAny"] """ A data entry field that is preserved on a completion item between a {@link CompletionRequest} and a {@link CompletionResolveRequest}. """ class CompletionList(TypedDict): """Represents a collection of {@link CompletionItem completion items} to be presented in the editor. """ isIncomplete: bool """ This list it not complete. Further typing results in recomputing this list. Recomputed lists have all their items replaced (not appended) in the incomplete completion sessions. """ itemDefaults: NotRequired["__CompletionList_itemDefaults_Type_1"] """ In many cases the items of an actual completion result share the same value for properties like `commitCharacters` or the range of a text edit. A completion list can therefore define item defaults which will be used if a completion item itself doesn't specify the value. If a completion list specifies a default value and a completion item also specifies a corresponding value the one from the item is used. Servers are only allowed to return default values if the client signals support for this via the `completionList.itemDefaults` capability. @since 3.17.0 """ items: list["CompletionItem"] """ The completion items. """ class CompletionRegistrationOptions(TypedDict): """Registration options for a {@link CompletionRequest}.""" documentSelector: Union["DocumentSelector", None] """ A document selector to identify the scope of the registration. If set to null the document selector provided on the client side will be used. """ triggerCharacters: NotRequired[list[str]] """ Most tools trigger completion request automatically without explicitly requesting it using a keyboard shortcut (e.g. Ctrl+Space). Typically they do so when the user starts to type an identifier. For example if the user types `c` in a JavaScript file code complete will automatically pop up present `console` besides others as a completion item. Characters that make up identifiers don't need to be listed here. If code complete should automatically be trigger on characters not being valid inside an identifier (for example `.` in JavaScript) list them in `triggerCharacters`. """ allCommitCharacters: NotRequired[list[str]] """ The list of all possible characters that commit a completion. This field can be used if clients don't support individual commit characters per completion item. See `ClientCapabilities.textDocument.completion.completionItem.commitCharactersSupport` If a server provides both `allCommitCharacters` and commit characters on an individual completion item the ones on the completion item win. @since 3.2.0 """ resolveProvider: NotRequired[bool] """ The server provides support to resolve additional information for a completion item. """ completionItem: NotRequired["__CompletionOptions_completionItem_Type_1"] """ The server supports the following `CompletionItem` specific capabilities. @since 3.17.0 """ class HoverParams(TypedDict): """Parameters for a {@link HoverRequest}.""" textDocument: "TextDocumentIdentifier" """ The text document. """ position: "Position" """ The position inside the text document. """ workDoneToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report work done progress. """ class Hover(TypedDict): """The result of a hover request.""" contents: Union["MarkupContent", "MarkedString", list["MarkedString"]] """ The hover's content """ range: NotRequired["Range"] """ An optional range inside the text document that is used to visualize the hover, e.g. by changing the background color. """ class HoverRegistrationOptions(TypedDict): """Registration options for a {@link HoverRequest}.""" documentSelector: Union["DocumentSelector", None] """ A document selector to identify the scope of the registration. If set to null the document selector provided on the client side will be used. """ class SignatureHelpParams(TypedDict): """Parameters for a {@link SignatureHelpRequest}.""" context: NotRequired["SignatureHelpContext"] """ The signature help context. This is only available if the client specifies to send this using the client capability `textDocument.signatureHelp.contextSupport === true` @since 3.15.0 """ textDocument: "TextDocumentIdentifier" """ The text document. """ position: "Position" """ The position inside the text document. """ workDoneToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report work done progress. """ class SignatureHelp(TypedDict): """Signature help represents the signature of something callable. There can be multiple signature but only one active and only one active parameter. """ signatures: list["SignatureInformation"] """ One or more signatures. """ activeSignature: NotRequired[Uint] """ The active signature. If omitted or the value lies outside the range of `signatures` the value defaults to zero or is ignored if the `SignatureHelp` has no signatures. Whenever possible implementers should make an active decision about the active signature and shouldn't rely on a default value. In future version of the protocol this property might become mandatory to better express this. """ activeParameter: NotRequired[Uint] """ The active parameter of the active signature. If omitted or the value lies outside the range of `signatures[activeSignature].parameters` defaults to 0 if the active signature has parameters. If the active signature has no parameters it is ignored. In future version of the protocol this property might become mandatory to better express the active parameter if the active signature does have any. """ class SignatureHelpRegistrationOptions(TypedDict): """Registration options for a {@link SignatureHelpRequest}.""" documentSelector: Union["DocumentSelector", None] """ A document selector to identify the scope of the registration. If set to null the document selector provided on the client side will be used. """ triggerCharacters: NotRequired[list[str]] """ List of characters that trigger signature help automatically. """ retriggerCharacters: NotRequired[list[str]] """ List of characters that re-trigger signature help. These trigger characters are only active when signature help is already showing. All trigger characters are also counted as re-trigger characters. @since 3.15.0 """ class DefinitionParams(TypedDict): """Parameters for a {@link DefinitionRequest}.""" textDocument: "TextDocumentIdentifier" """ The text document. """ position: "Position" """ The position inside the text document. """ workDoneToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report work done progress. """ partialResultToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report partial results (e.g. streaming) to the client. """ class DefinitionRegistrationOptions(TypedDict): """Registration options for a {@link DefinitionRequest}.""" documentSelector: Union["DocumentSelector", None] """ A document selector to identify the scope of the registration. If set to null the document selector provided on the client side will be used. """ class ReferenceParams(TypedDict): """Parameters for a {@link ReferencesRequest}.""" context: "ReferenceContext" textDocument: "TextDocumentIdentifier" """ The text document. """ position: "Position" """ The position inside the text document. """ workDoneToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report work done progress. """ partialResultToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report partial results (e.g. streaming) to the client. """ class ReferenceRegistrationOptions(TypedDict): """Registration options for a {@link ReferencesRequest}.""" documentSelector: Union["DocumentSelector", None] """ A document selector to identify the scope of the registration. If set to null the document selector provided on the client side will be used. """ class DocumentHighlightParams(TypedDict): """Parameters for a {@link DocumentHighlightRequest}.""" textDocument: "TextDocumentIdentifier" """ The text document. """ position: "Position" """ The position inside the text document. """ workDoneToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report work done progress. """ partialResultToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report partial results (e.g. streaming) to the client. """ class DocumentHighlight(TypedDict): """A document highlight is a range inside a text document which deserves special attention. Usually a document highlight is visualized by changing the background color of its range. """ range: "Range" """ The range this highlight applies to. """ kind: NotRequired["DocumentHighlightKind"] """ The highlight kind, default is {@link DocumentHighlightKind.Text text}. """ class DocumentHighlightRegistrationOptions(TypedDict): """Registration options for a {@link DocumentHighlightRequest}.""" documentSelector: Union["DocumentSelector", None] """ A document selector to identify the scope of the registration. If set to null the document selector provided on the client side will be used. """ class DocumentSymbolParams(TypedDict): """Parameters for a {@link DocumentSymbolRequest}.""" textDocument: "TextDocumentIdentifier" """ The text document. """ workDoneToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report work done progress. """ partialResultToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report partial results (e.g. streaming) to the client. """ class SymbolInformation(TypedDict): """Represents information about programming constructs like variables, classes, interfaces etc. """ deprecated: NotRequired[bool] """ Indicates if this symbol is deprecated. @deprecated Use tags instead """ location: "Location" """ The location of this symbol. The location's range is used by a tool to reveal the location in the editor. If the symbol is selected in the tool the range's start information is used to position the cursor. So the range usually spans more than the actual symbol's name and does normally include things like visibility modifiers. The range doesn't have to denote a node range in the sense of an abstract syntax tree. It can therefore not be used to re-construct a hierarchy of the symbols. """ name: str """ The name of this symbol. """ kind: "SymbolKind" """ The kind of this symbol. """ tags: NotRequired[list["SymbolTag"]] """ Tags for this symbol. @since 3.16.0 """ containerName: NotRequired[str] """ The name of the symbol containing this symbol. This information is for user interface purposes (e.g. to render a qualifier in the user interface if necessary). It can't be used to re-infer a hierarchy for the document symbols. """ class DocumentSymbol(TypedDict): """Represents programming constructs like variables, classes, interfaces etc. that appear in a document. Document symbols can be hierarchical and they have two ranges: one that encloses its definition and one that points to its most interesting range, e.g. the range of an identifier. """ name: str """ The name of this symbol. Will be displayed in the user interface and therefore must not be an empty string or a string only consisting of white spaces. """ detail: NotRequired[str] """ More detail for this symbol, e.g the signature of a function. """ kind: "SymbolKind" """ The kind of this symbol. """ tags: NotRequired[list["SymbolTag"]] """ Tags for this document symbol. @since 3.16.0 """ deprecated: NotRequired[bool] """ Indicates if this symbol is deprecated. @deprecated Use tags instead """ range: "Range" """ The range enclosing this symbol not including leading/trailing whitespace but everything else like comments. This information is typically used to determine if the clients cursor is inside the symbol to reveal in the symbol in the UI. """ selectionRange: "Range" """ The range that should be selected and revealed when this symbol is being picked, e.g the name of a function. Must be contained by the `range`. """ # TODO: I think this type is missing the 'children' field - DJ class DocumentSymbolRegistrationOptions(TypedDict): """Registration options for a {@link DocumentSymbolRequest}.""" documentSelector: Union["DocumentSelector", None] """ A document selector to identify the scope of the registration. If set to null the document selector provided on the client side will be used. """ label: NotRequired[str] """ A human-readable string that is shown when multiple outlines trees are shown for the same document. @since 3.16.0 """ class CodeActionParams(TypedDict): """The parameters of a {@link CodeActionRequest}.""" textDocument: "TextDocumentIdentifier" """ The document in which the command was invoked. """ range: "Range" """ The range for which the command was invoked. """ context: "CodeActionContext" """ Context carrying additional information. """ workDoneToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report work done progress. """ partialResultToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report partial results (e.g. streaming) to the client. """ class Command(TypedDict): """Represents a reference to a command. Provides a title which will be used to represent a command in the UI and, optionally, an array of arguments which will be passed to the command handler function when invoked. """ title: str """ Title of the command, like `save`. """ command: str """ The identifier of the actual command handler. """ arguments: NotRequired[list["LSPAny"]] """ Arguments that the command handler should be invoked with. """ class CodeAction(TypedDict): """A code action represents a change that can be performed in code, e.g. to fix a problem or to refactor code. A CodeAction must set either `edit` and/or a `command`. If both are supplied, the `edit` is applied first, then the `command` is executed. """ title: str """ A short, human-readable, title for this code action. """ kind: NotRequired["CodeActionKind"] """ The kind of the code action. Used to filter code actions. """ diagnostics: NotRequired[list["Diagnostic"]] """ The diagnostics that this code action resolves. """ isPreferred: NotRequired[bool] """ Marks this as a preferred action. Preferred actions are used by the `auto fix` command and can be targeted by keybindings. A quick fix should be marked preferred if it properly addresses the underlying error. A refactoring should be marked preferred if it is the most reasonable choice of actions to take. @since 3.15.0 """ disabled: NotRequired["__CodeAction_disabled_Type_1"] """ Marks that the code action cannot currently be applied. Clients should follow the following guidelines regarding disabled code actions: - Disabled code actions are not shown in automatic [lightbulbs](https://code.visualstudio.com/docs/editor/editingevolved#_code-action) code action menus. - Disabled actions are shown as faded out in the code action menu when the user requests a more specific type of code action, such as refactorings. - If the user has a [keybinding](https://code.visualstudio.com/docs/editor/refactoring#_keybindings-for-code-actions) that auto applies a code action and only disabled code actions are returned, the client should show the user an error message with `reason` in the editor. @since 3.16.0 """ edit: NotRequired["WorkspaceEdit"] """ The workspace edit this code action performs. """ command: NotRequired["Command"] """ A command this code action executes. If a code action provides an edit and a command, first the edit is executed and then the command. """ data: NotRequired["LSPAny"] """ A data entry field that is preserved on a code action between a `textDocument/codeAction` and a `codeAction/resolve` request. @since 3.16.0 """ class CodeActionRegistrationOptions(TypedDict): """Registration options for a {@link CodeActionRequest}.""" documentSelector: Union["DocumentSelector", None] """ A document selector to identify the scope of the registration. If set to null the document selector provided on the client side will be used. """ codeActionKinds: NotRequired[list["CodeActionKind"]] """ CodeActionKinds that this server may return. The list of kinds may be generic, such as `CodeActionKind.Refactor`, or the server may list out every specific kind they provide. """ resolveProvider: NotRequired[bool] """ The server provides support to resolve additional information for a code action. @since 3.16.0 """ class WorkspaceSymbolParams(TypedDict): """The parameters of a {@link WorkspaceSymbolRequest}.""" query: str """ A query string to filter symbols by. Clients may send an empty string here to request all symbols. """ workDoneToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report work done progress. """ partialResultToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report partial results (e.g. streaming) to the client. """ class WorkspaceSymbol(TypedDict): """A special workspace symbol that supports locations without a range. See also SymbolInformation. @since 3.17.0 """ location: Union["Location", "__WorkspaceSymbol_location_Type_1"] """ The location of the symbol. Whether a server is allowed to return a location without a range depends on the client capability `workspace.symbol.resolveSupport`. See SymbolInformation#location for more details. """ data: NotRequired["LSPAny"] """ A data entry field that is preserved on a workspace symbol between a workspace symbol request and a workspace symbol resolve request. """ name: str """ The name of this symbol. """ kind: "SymbolKind" """ The kind of this symbol. """ tags: NotRequired[list["SymbolTag"]] """ Tags for this symbol. @since 3.16.0 """ containerName: NotRequired[str] """ The name of the symbol containing this symbol. This information is for user interface purposes (e.g. to render a qualifier in the user interface if necessary). It can't be used to re-infer a hierarchy for the document symbols. """ class WorkspaceSymbolRegistrationOptions(TypedDict): """Registration options for a {@link WorkspaceSymbolRequest}.""" resolveProvider: NotRequired[bool] """ The server provides support to resolve additional information for a workspace symbol. @since 3.17.0 """ class CodeLensParams(TypedDict): """The parameters of a {@link CodeLensRequest}.""" textDocument: "TextDocumentIdentifier" """ The document to request code lens for. """ workDoneToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report work done progress. """ partialResultToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report partial results (e.g. streaming) to the client. """ class CodeLens(TypedDict): """A code lens represents a {@link Command command} that should be shown along with source text, like the number of references, a way to run tests, etc. A code lens is _unresolved_ when no command is associated to it. For performance reasons the creation of a code lens and resolving should be done in two stages. """ range: "Range" """ The range in which this code lens is valid. Should only span a single line. """ command: NotRequired["Command"] """ The command this code lens represents. """ data: NotRequired["LSPAny"] """ A data entry field that is preserved on a code lens item between a {@link CodeLensRequest} and a [CodeLensResolveRequest] (#CodeLensResolveRequest) """ class CodeLensRegistrationOptions(TypedDict): """Registration options for a {@link CodeLensRequest}.""" documentSelector: Union["DocumentSelector", None] """ A document selector to identify the scope of the registration. If set to null the document selector provided on the client side will be used. """ resolveProvider: NotRequired[bool] """ Code lens has a resolve provider as well. """ class DocumentLinkParams(TypedDict): """The parameters of a {@link DocumentLinkRequest}.""" textDocument: "TextDocumentIdentifier" """ The document to provide document links for. """ workDoneToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report work done progress. """ partialResultToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report partial results (e.g. streaming) to the client. """ class DocumentLink(TypedDict): """A document link is a range in a text document that links to an internal or external resource, like another text document or a web site. """ range: "Range" """ The range this link applies to. """ target: NotRequired[str] """ The uri this link points to. If missing a resolve request is sent later. """ tooltip: NotRequired[str] """ The tooltip text when you hover over this link. If a tooltip is provided, is will be displayed in a string that includes instructions on how to trigger the link, such as `{0} (ctrl + click)`. The specific instructions vary depending on OS, user settings, and localization. @since 3.15.0 """ data: NotRequired["LSPAny"] """ A data entry field that is preserved on a document link between a DocumentLinkRequest and a DocumentLinkResolveRequest. """ class DocumentLinkRegistrationOptions(TypedDict): """Registration options for a {@link DocumentLinkRequest}.""" documentSelector: Union["DocumentSelector", None] """ A document selector to identify the scope of the registration. If set to null the document selector provided on the client side will be used. """ resolveProvider: NotRequired[bool] """ Document links have a resolve provider as well. """ class DocumentFormattingParams(TypedDict): """The parameters of a {@link DocumentFormattingRequest}.""" textDocument: "TextDocumentIdentifier" """ The document to format. """ options: "FormattingOptions" """ The format options. """ workDoneToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report work done progress. """ class DocumentFormattingRegistrationOptions(TypedDict): """Registration options for a {@link DocumentFormattingRequest}.""" documentSelector: Union["DocumentSelector", None] """ A document selector to identify the scope of the registration. If set to null the document selector provided on the client side will be used. """ class DocumentRangeFormattingParams(TypedDict): """The parameters of a {@link DocumentRangeFormattingRequest}.""" textDocument: "TextDocumentIdentifier" """ The document to format. """ range: "Range" """ The range to format """ options: "FormattingOptions" """ The format options """ workDoneToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report work done progress. """ class DocumentRangeFormattingRegistrationOptions(TypedDict): """Registration options for a {@link DocumentRangeFormattingRequest}.""" documentSelector: Union["DocumentSelector", None] """ A document selector to identify the scope of the registration. If set to null the document selector provided on the client side will be used. """ class DocumentOnTypeFormattingParams(TypedDict): """The parameters of a {@link DocumentOnTypeFormattingRequest}.""" textDocument: "TextDocumentIdentifier" """ The document to format. """ position: "Position" """ The position around which the on type formatting should happen. This is not necessarily the exact position where the character denoted by the property `ch` got typed. """ ch: str """ The character that has been typed that triggered the formatting on type request. That is not necessarily the last character that got inserted into the document since the client could auto insert characters as well (e.g. like automatic brace completion). """ options: "FormattingOptions" """ The formatting options. """ class DocumentOnTypeFormattingRegistrationOptions(TypedDict): """Registration options for a {@link DocumentOnTypeFormattingRequest}.""" documentSelector: Union["DocumentSelector", None] """ A document selector to identify the scope of the registration. If set to null the document selector provided on the client side will be used. """ firstTriggerCharacter: str """ A character on which formatting should be triggered, like `{`. """ moreTriggerCharacter: NotRequired[list[str]] """ More trigger characters. """ class RenameParams(TypedDict): """The parameters of a {@link RenameRequest}.""" textDocument: "TextDocumentIdentifier" """ The document to rename. """ position: "Position" """ The position at which this request was sent. """ newName: str """ The new name of the symbol. If the given name is not valid the request must return a {@link ResponseError} with an appropriate message set. """ workDoneToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report work done progress. """ class RenameRegistrationOptions(TypedDict): """Registration options for a {@link RenameRequest}.""" documentSelector: Union["DocumentSelector", None] """ A document selector to identify the scope of the registration. If set to null the document selector provided on the client side will be used. """ prepareProvider: NotRequired[bool] """ Renames should be checked and tested before being executed. @since version 3.12.0 """ class PrepareRenameParams(TypedDict): textDocument: "TextDocumentIdentifier" """ The text document. """ position: "Position" """ The position inside the text document. """ workDoneToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report work done progress. """ class ExecuteCommandParams(TypedDict): """The parameters of a {@link ExecuteCommandRequest}.""" command: str """ The identifier of the actual command handler. """ arguments: NotRequired[list["LSPAny"]] """ Arguments that the command should be invoked with. """ workDoneToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report work done progress. """ class ExecuteCommandRegistrationOptions(TypedDict): """Registration options for a {@link ExecuteCommandRequest}.""" commands: list[str] """ The commands to be executed on the server """ class ApplyWorkspaceEditParams(TypedDict): """The parameters passed via a apply workspace edit request.""" label: NotRequired[str] """ An optional label of the workspace edit. This label is presented in the user interface for example on an undo stack to undo the workspace edit. """ edit: "WorkspaceEdit" """ The edits to apply. """ class ApplyWorkspaceEditResult(TypedDict): """The result returned from the apply workspace edit request. @since 3.17 renamed from ApplyWorkspaceEditResponse """ applied: bool """ Indicates whether the edit was applied or not. """ failureReason: NotRequired[str] """ An optional textual description for why the edit was not applied. This may be used by the server for diagnostic logging or to provide a suitable error for a request that triggered the edit. """ failedChange: NotRequired[Uint] """ Depending on the client's failure handling strategy `failedChange` might contain the index of the change that failed. This property is only available if the client signals a `failureHandlingStrategy` in its client capabilities. """ class WorkDoneProgressBegin(TypedDict): kind: Literal["begin"] title: str """ Mandatory title of the progress operation. Used to briefly inform about the kind of operation being performed. Examples: "Indexing" or "Linking dependencies". """ cancellable: NotRequired[bool] """ Controls if a cancel button should show to allow the user to cancel the long running operation. Clients that don't support cancellation are allowed to ignore the setting. """ message: NotRequired[str] """ Optional, more detailed associated progress message. Contains complementary information to the `title`. Examples: "3/25 files", "project/src/module2", "node_modules/some_dep". If unset, the previous progress message (if any) is still valid. """ percentage: NotRequired[Uint] """ Optional progress percentage to display (value 100 is considered 100%). If not provided infinite progress is assumed and clients are allowed to ignore the `percentage` value in subsequent in report notifications. The value should be steadily rising. Clients are free to ignore values that are not following this rule. The value range is [0, 100]. """ class WorkDoneProgressReport(TypedDict): kind: Literal["report"] cancellable: NotRequired[bool] """ Controls enablement state of a cancel button. Clients that don't support cancellation or don't support controlling the button's enablement state are allowed to ignore the property. """ message: NotRequired[str] """ Optional, more detailed associated progress message. Contains complementary information to the `title`. Examples: "3/25 files", "project/src/module2", "node_modules/some_dep". If unset, the previous progress message (if any) is still valid. """ percentage: NotRequired[Uint] """ Optional progress percentage to display (value 100 is considered 100%). If not provided infinite progress is assumed and clients are allowed to ignore the `percentage` value in subsequent in report notifications. The value should be steadily rising. Clients are free to ignore values that are not following this rule. The value range is [0, 100] """ class WorkDoneProgressEnd(TypedDict): kind: Literal["end"] message: NotRequired[str] """ Optional, a final message indicating to for example indicate the outcome of the operation. """ class SetTraceParams(TypedDict): value: "TraceValues" class LogTraceParams(TypedDict): message: str verbose: NotRequired[str] class CancelParams(TypedDict): id: int | str """ The request id to cancel. """ class ProgressParams(TypedDict): token: "ProgressToken" """ The progress token provided by the client or server. """ value: "LSPAny" """ The progress data. """ class TextDocumentPositionParams(TypedDict): """A parameter literal used in requests to pass a text document and a position inside that document. """ textDocument: "TextDocumentIdentifier" """ The text document. """ position: "Position" """ The position inside the text document. """ class WorkDoneProgressParams(TypedDict): workDoneToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report work done progress. """ class PartialResultParams(TypedDict): partialResultToken: NotRequired["ProgressToken"] """ An optional token that a server can use to report partial results (e.g. streaming) to the client. """ class LocationLink(TypedDict): """Represents the connection of two locations. Provides additional metadata over normal {@link Location locations}, including an origin range. """ originSelectionRange: NotRequired["Range"] """ Span of the origin of this link. Used as the underlined span for mouse interaction. Defaults to the word range at the definition position. """ targetUri: "DocumentUri" """ The target resource identifier of this link. """ targetRange: "Range" """ The full target range of this link. If the target for example is a symbol then target range is the range enclosing this symbol not including leading/trailing whitespace but everything else like comments. This information is typically used to highlight the range in the editor. """ targetSelectionRange: "Range" """ The range that should be selected and revealed when this link is being followed, e.g the name of a function. Must be contained by the `targetRange`. See also `DocumentSymbol#range` """ class Range(TypedDict): """A range in a text document expressed as (zero-based) start and end positions. If you want to specify a range that contains a line including the line ending character(s) then use an end position denoting the start of the next line. For example: ```ts { start: { line: 5, character: 23 } end : { line 6, character : 0 } } ``` """ start: "Position" """ The range's start position. """ end: "Position" """ The range's end position. """ class ImplementationOptions(TypedDict): workDoneProgress: NotRequired[bool] class StaticRegistrationOptions(TypedDict): """Static registration options to be returned in the initialize request. """ id: NotRequired[str] """ The id used to register the request. The id can be used to deregister the request again. See also Registration#id. """ class TypeDefinitionOptions(TypedDict): workDoneProgress: NotRequired[bool] class WorkspaceFoldersChangeEvent(TypedDict): """The workspace folder change event.""" added: list["WorkspaceFolder"] """ The array of added workspace folders """ removed: list["WorkspaceFolder"] """ The array of the removed workspace folders """ class ConfigurationItem(TypedDict): scopeUri: NotRequired[str] """ The scope to get the configuration section for. """ section: NotRequired[str] """ The configuration section asked for. """ class TextDocumentIdentifier(TypedDict): """A literal to identify a text document in the client.""" uri: "DocumentUri" """ The text document's uri. """ class Color(TypedDict): """Represents a color in RGBA space.""" red: float """ The red component of this color in the range [0-1]. """ green: float """ The green component of this color in the range [0-1]. """ blue: float """ The blue component of this color in the range [0-1]. """ alpha: float """ The alpha component of this color in the range [0-1]. """ class DocumentColorOptions(TypedDict): workDoneProgress: NotRequired[bool] class FoldingRangeOptions(TypedDict): workDoneProgress: NotRequired[bool] class DeclarationOptions(TypedDict): workDoneProgress: NotRequired[bool] class Position(TypedDict): r"""Position in a text document expressed as zero-based line and character offset. Prior to 3.17 the offsets were always based on a UTF-16 string representation. So a string of the form `a𐐀b` the character offset of the character `a` is 0, the character offset of `𐐀` is 1 and the character offset of b is 3 since `𐐀` is represented using two code units in UTF-16. Since 3.17 clients and servers can agree on a different string encoding representation (e.g. UTF-8). The client announces it's supported encoding via the client capability [`general.positionEncodings`](#clientCapabilities). The value is an array of position encodings the client supports, with decreasing preference (e.g. the encoding at index `0` is the most preferred one). To stay backwards compatible the only mandatory encoding is UTF-16 represented via the string `utf-16`. The server can pick one of the encodings offered by the client and signals that encoding back to the client via the initialize result's property [`capabilities.positionEncoding`](#serverCapabilities). If the string value `utf-16` is missing from the client's capability `general.positionEncodings` servers can safely assume that the client supports UTF-16. If the server omits the position encoding in its initialize result the encoding defaults to the string value `utf-16`. Implementation considerations: since the conversion from one encoding into another requires the content of the file / line the conversion is best done where the file is read which is usually on the server side. Positions are line end character agnostic. So you can not specify a position that denotes `\r|\n` or `\n|` where `|` represents the character offset. @since 3.17.0 - support for negotiated position encoding. """ line: Uint """ Line position in a document (zero-based). If a line number is greater than the number of lines in a document, it defaults back to the number of lines in the document. If a line number is negative, it defaults to 0. """ character: Uint """ Character offset on a line in a document (zero-based). The meaning of this offset is determined by the negotiated `PositionEncodingKind`. If the character value is greater than the line length it defaults back to the line length. """ class SelectionRangeOptions(TypedDict): workDoneProgress: NotRequired[bool] class CallHierarchyOptions(TypedDict): """Call hierarchy options used during static registration. @since 3.16.0 """ workDoneProgress: NotRequired[bool] class SemanticTokensOptions(TypedDict): """@since 3.16.0""" legend: "SemanticTokensLegend" """ The legend used by the server """ range: NotRequired[bool | dict] """ Server supports providing semantic tokens for a specific range of a document. """ full: NotRequired[Union[bool, "__SemanticTokensOptions_full_Type_2"]] """ Server supports providing semantic tokens for a full document. """ workDoneProgress: NotRequired[bool] class SemanticTokensEdit(TypedDict): """@since 3.16.0""" start: Uint """ The start offset of the edit. """ deleteCount: Uint """ The count of elements to remove. """ data: NotRequired[list[Uint]] """ The elements to insert. """ class LinkedEditingRangeOptions(TypedDict): workDoneProgress: NotRequired[bool] class FileCreate(TypedDict): """Represents information on a file/folder create. @since 3.16.0 """ uri: str """ A file:// URI for the location of the file/folder being created. """ class TextDocumentEdit(TypedDict): """Describes textual changes on a text document. A TextDocumentEdit describes all changes on a document version Si and after they are applied move the document to version Si+1. So the creator of a TextDocumentEdit doesn't need to sort the array of edits or do any kind of ordering. However the edits must be non overlapping. """ textDocument: "OptionalVersionedTextDocumentIdentifier" """ The text document to change. """ edits: list[Union["TextEdit", "AnnotatedTextEdit"]] """ The edits to be applied. @since 3.16.0 - support for AnnotatedTextEdit. This is guarded using a client capability. """ class CreateFile(TypedDict): """Create file operation.""" kind: Literal["create"] """ A create """ uri: "DocumentUri" """ The resource to create. """ options: NotRequired["CreateFileOptions"] """ Additional options """ annotationId: NotRequired["ChangeAnnotationIdentifier"] """ An optional annotation identifier describing the operation. @since 3.16.0 """ class RenameFile(TypedDict): """Rename file operation""" kind: Literal["rename"] """ A rename """ oldUri: "DocumentUri" """ The old (existing) location. """ newUri: "DocumentUri" """ The new location. """ options: NotRequired["RenameFileOptions"] """ Rename options. """ annotationId: NotRequired["ChangeAnnotationIdentifier"] """ An optional annotation identifier describing the operation. @since 3.16.0 """ class DeleteFile(TypedDict): """Delete file operation""" kind: Literal["delete"] """ A delete """ uri: "DocumentUri" """ The file to delete. """ options: NotRequired["DeleteFileOptions"] """ Delete options. """ annotationId: NotRequired["ChangeAnnotationIdentifier"] """ An optional annotation identifier describing the operation. @since 3.16.0 """ class ChangeAnnotation(TypedDict): """Additional information that describes document changes. @since 3.16.0 """ label: str """ A human-readable string describing the actual change. The string is rendered prominent in the user interface. """ needsConfirmation: NotRequired[bool] """ A flag which indicates that user confirmation is needed before applying the change. """ description: NotRequired[str] """ A human-readable string which is rendered less prominent in the user interface. """ class FileOperationFilter(TypedDict): """A filter to describe in which file operation requests or notifications the server is interested in receiving. @since 3.16.0 """ scheme: NotRequired[str] """ A Uri scheme like `file` or `untitled`. """ pattern: "FileOperationPattern" """ The actual file operation pattern. """ class FileRename(TypedDict): """Represents information on a file/folder rename. @since 3.16.0 """ oldUri: str """ A file:// URI for the original location of the file/folder being renamed. """ newUri: str """ A file:// URI for the new location of the file/folder being renamed. """ class FileDelete(TypedDict): """Represents information on a file/folder delete. @since 3.16.0 """ uri: str """ A file:// URI for the location of the file/folder being deleted. """ class MonikerOptions(TypedDict): workDoneProgress: NotRequired[bool] class TypeHierarchyOptions(TypedDict): """Type hierarchy options used during static registration. @since 3.17.0 """ workDoneProgress: NotRequired[bool] class InlineValueContext(TypedDict): """@since 3.17.0""" frameId: int """ The stack frame (as a DAP Id) where the execution has stopped. """ stoppedLocation: "Range" """ The document range where execution has stopped. Typically the end position of the range denotes the line where the inline values are shown. """ class InlineValueText(TypedDict): """Provide inline value as text. @since 3.17.0 """ range: "Range" """ The document range for which the inline value applies. """ text: str """ The text of the inline value. """ class InlineValueVariableLookup(TypedDict): """Provide inline value through a variable lookup. If only a range is specified, the variable name will be extracted from the underlying document. An optional variable name can be used to override the extracted name. @since 3.17.0 """ range: "Range" """ The document range for which the inline value applies. The range is used to extract the variable name from the underlying document. """ variableName: NotRequired[str] """ If specified the name of the variable to look up. """ caseSensitiveLookup: bool """ How to perform the lookup. """ class InlineValueEvaluatableExpression(TypedDict): """Provide an inline value through an expression evaluation. If only a range is specified, the expression will be extracted from the underlying document. An optional expression can be used to override the extracted expression. @since 3.17.0 """ range: "Range" """ The document range for which the inline value applies. The range is used to extract the evaluatable expression from the underlying document. """ expression: NotRequired[str] """ If specified the expression overrides the extracted expression. """ class InlineValueOptions(TypedDict): """Inline value options used during static registration. @since 3.17.0 """ workDoneProgress: NotRequired[bool] class InlayHintLabelPart(TypedDict): """An inlay hint label part allows for interactive and composite labels of inlay hints. @since 3.17.0 """ value: str """ The value of this label part. """ tooltip: NotRequired[Union[str, "MarkupContent"]] """ The tooltip text when you hover over this label part. Depending on the client capability `inlayHint.resolveSupport` clients might resolve this property late using the resolve request. """ location: NotRequired["Location"] """ An optional source code location that represents this label part. The editor will use this location for the hover and for code navigation features: This part will become a clickable link that resolves to the definition of the symbol at the given location (not necessarily the location itself), it shows the hover that shows at the given location, and it shows a context menu with further code navigation commands. Depending on the client capability `inlayHint.resolveSupport` clients might resolve this property late using the resolve request. """ command: NotRequired["Command"] """ An optional command for this label part. Depending on the client capability `inlayHint.resolveSupport` clients might resolve this property late using the resolve request. """ class MarkupContent(TypedDict): r"""A `MarkupContent` literal represents a string value which content is interpreted base on its kind flag. Currently the protocol supports `plaintext` and `markdown` as markup kinds. If the kind is `markdown` then the value can contain fenced code blocks like in GitHub issues. See https://help.github.com/articles/creating-and-highlighting-code-blocks/#syntax-highlighting Here is an example how such a string can be constructed using JavaScript / TypeScript: ```ts let markdown: MarkdownContent = { kind: MarkupKind.Markdown, value: [ '# Header', 'Some text', '```typescript', 'someCode();', '```' ].join('\n') }; ``` *Please Note* that clients might sanitize the return markdown. A client could decide to remove HTML from the markdown to avoid script execution. """ kind: "MarkupKind" """ The type of the Markup """ value: str """ The content itself """ class InlayHintOptions(TypedDict): """Inlay hint options used during static registration. @since 3.17.0 """ resolveProvider: NotRequired[bool] """ The server provides support to resolve additional information for an inlay hint item. """ workDoneProgress: NotRequired[bool] class RelatedFullDocumentDiagnosticReport(TypedDict): """A full diagnostic report with a set of related documents. @since 3.17.0 """ relatedDocuments: NotRequired[ dict[ "DocumentUri", Union["FullDocumentDiagnosticReport", "UnchangedDocumentDiagnosticReport"], ] ] """ Diagnostics of related documents. This information is useful in programming languages where code in a file A can generate diagnostics in a file B which A depends on. An example of such a language is C/C++ where marco definitions in a file a.cpp and result in errors in a header file b.hpp. @since 3.17.0 """ kind: Literal["full"] """ A full document diagnostic report. """ resultId: NotRequired[str] """ An optional result id. If provided it will be sent on the next diagnostic request for the same document. """ items: list["Diagnostic"] """ The actual items. """ class RelatedUnchangedDocumentDiagnosticReport(TypedDict): """An unchanged diagnostic report with a set of related documents. @since 3.17.0 """ relatedDocuments: NotRequired[ dict[ "DocumentUri", Union["FullDocumentDiagnosticReport", "UnchangedDocumentDiagnosticReport"], ] ] """ Diagnostics of related documents. This information is useful in programming languages where code in a file A can generate diagnostics in a file B which A depends on. An example of such a language is C/C++ where marco definitions in a file a.cpp and result in errors in a header file b.hpp. @since 3.17.0 """ kind: Literal["unchanged"] """ A document diagnostic report indicating no changes to the last result. A server can only return `unchanged` if result ids are provided. """ resultId: str """ A result id which will be sent on the next diagnostic request for the same document. """ class FullDocumentDiagnosticReport(TypedDict): """A diagnostic report with a full set of problems. @since 3.17.0 """ kind: Literal["full"] """ A full document diagnostic report. """ resultId: NotRequired[str] """ An optional result id. If provided it will be sent on the next diagnostic request for the same document. """ items: list["Diagnostic"] """ The actual items. """ class UnchangedDocumentDiagnosticReport(TypedDict): """A diagnostic report indicating that the last returned report is still accurate. @since 3.17.0 """ kind: Literal["unchanged"] """ A document diagnostic report indicating no changes to the last result. A server can only return `unchanged` if result ids are provided. """ resultId: str """ A result id which will be sent on the next diagnostic request for the same document. """ class DiagnosticOptions(TypedDict): """Diagnostic options. @since 3.17.0 """ identifier: NotRequired[str] """ An optional identifier under which the diagnostics are managed by the client. """ interFileDependencies: bool """ Whether the language has inter file dependencies meaning that editing code in one file can result in a different diagnostic set in another file. Inter file dependencies are common for most programming languages and typically uncommon for linters. """ workspaceDiagnostics: bool """ The server provides support for workspace diagnostics as well. """ workDoneProgress: NotRequired[bool] class PreviousResultId(TypedDict): """A previous result id in a workspace pull request. @since 3.17.0 """ uri: "DocumentUri" """ The URI for which the client knowns a result id. """ value: str """ The value of the previous result id. """ class NotebookDocument(TypedDict): """A notebook document. @since 3.17.0 """ uri: "URI" """ The notebook document's uri. """ notebookType: str """ The type of the notebook. """ version: int """ The version number of this document (it will increase after each change, including undo/redo). """ metadata: NotRequired["LSPObject"] """ Additional metadata stored with the notebook document. Note: should always be an object literal (e.g. LSPObject) """ cells: list["NotebookCell"] """ The cells of a notebook. """ class TextDocumentItem(TypedDict): """An item to transfer a text document from the client to the server. """ uri: "DocumentUri" """ The text document's uri. """ languageId: str """ The text document's language identifier. """ version: int """ The version number of this document (it will increase after each change, including undo/redo). """ text: str """ The content of the opened text document. """ class VersionedNotebookDocumentIdentifier(TypedDict): """A versioned notebook document identifier. @since 3.17.0 """ version: int """ The version number of this notebook document. """ uri: "URI" """ The notebook document's uri. """ class NotebookDocumentChangeEvent(TypedDict): """A change event for a notebook document. @since 3.17.0 """ metadata: NotRequired["LSPObject"] """ The changed meta data if any. Note: should always be an object literal (e.g. LSPObject) """ cells: NotRequired["__NotebookDocumentChangeEvent_cells_Type_1"] """ Changes to cells """ class NotebookDocumentIdentifier(TypedDict): """A literal to identify a notebook document in the client. @since 3.17.0 """ uri: "URI" """ The notebook document's uri. """ class Registration(TypedDict): """General parameters to to register for an notification or to register a provider.""" id: str """ The id used to register the request. The id can be used to deregister the request again. """ method: str """ The method / capability to register for. """ registerOptions: NotRequired["LSPAny"] """ Options necessary for the registration. """ class Unregistration(TypedDict): """General parameters to unregister a request or notification.""" id: str """ The id used to unregister the request or notification. Usually an id provided during the register request. """ method: str """ The method to unregister for. """ class WorkspaceFoldersInitializeParams(TypedDict): workspaceFolders: NotRequired[list["WorkspaceFolder"] | None] """ The workspace folders configured in the client when the server starts. This property is only available if the client supports workspace folders. It can be `null` if the client supports workspace folders but none are configured. @since 3.6.0 """ class ServerCapabilities(TypedDict): """Defines the capabilities provided by a language server. """ positionEncoding: NotRequired["PositionEncodingKind"] """ The position encoding the server picked from the encodings offered by the client via the client capability `general.positionEncodings`. If the client didn't provide any position encodings the only valid value that a server can return is 'utf-16'. If omitted it defaults to 'utf-16'. @since 3.17.0 """ textDocumentSync: NotRequired[Union["TextDocumentSyncOptions", "TextDocumentSyncKind"]] """ Defines how text documents are synced. Is either a detailed structure defining each notification or for backwards compatibility the TextDocumentSyncKind number. """ notebookDocumentSync: NotRequired[Union["NotebookDocumentSyncOptions", "NotebookDocumentSyncRegistrationOptions"]] """ Defines how notebook documents are synced. @since 3.17.0 """ completionProvider: NotRequired["CompletionOptions"] """ The server provides completion support. """ hoverProvider: NotRequired[Union[bool, "HoverOptions"]] """ The server provides hover support. """ signatureHelpProvider: NotRequired["SignatureHelpOptions"] """ The server provides signature help support. """ declarationProvider: NotRequired[Union[bool, "DeclarationOptions", "DeclarationRegistrationOptions"]] """ The server provides Goto Declaration support. """ definitionProvider: NotRequired[Union[bool, "DefinitionOptions"]] """ The server provides goto definition support. """ typeDefinitionProvider: NotRequired[Union[bool, "TypeDefinitionOptions", "TypeDefinitionRegistrationOptions"]] """ The server provides Goto Type Definition support. """ implementationProvider: NotRequired[Union[bool, "ImplementationOptions", "ImplementationRegistrationOptions"]] """ The server provides Goto Implementation support. """ referencesProvider: NotRequired[Union[bool, "ReferenceOptions"]] """ The server provides find references support. """ documentHighlightProvider: NotRequired[Union[bool, "DocumentHighlightOptions"]] """ The server provides document highlight support. """ documentSymbolProvider: NotRequired[Union[bool, "DocumentSymbolOptions"]] """ The server provides document symbol support. """ codeActionProvider: NotRequired[Union[bool, "CodeActionOptions"]] """ The server provides code actions. CodeActionOptions may only be specified if the client states that it supports `codeActionLiteralSupport` in its initial `initialize` request. """ codeLensProvider: NotRequired["CodeLensOptions"] """ The server provides code lens. """ documentLinkProvider: NotRequired["DocumentLinkOptions"] """ The server provides document link support. """ colorProvider: NotRequired[Union[bool, "DocumentColorOptions", "DocumentColorRegistrationOptions"]] """ The server provides color provider support. """ workspaceSymbolProvider: NotRequired[Union[bool, "WorkspaceSymbolOptions"]] """ The server provides workspace symbol support. """ documentFormattingProvider: NotRequired[Union[bool, "DocumentFormattingOptions"]] """ The server provides document formatting. """ documentRangeFormattingProvider: NotRequired[Union[bool, "DocumentRangeFormattingOptions"]] """ The server provides document range formatting. """ documentOnTypeFormattingProvider: NotRequired["DocumentOnTypeFormattingOptions"] """ The server provides document formatting on typing. """ renameProvider: NotRequired[Union[bool, "RenameOptions"]] """ The server provides rename support. RenameOptions may only be specified if the client states that it supports `prepareSupport` in its initial `initialize` request. """ foldingRangeProvider: NotRequired[Union[bool, "FoldingRangeOptions", "FoldingRangeRegistrationOptions"]] """ The server provides folding provider support. """ selectionRangeProvider: NotRequired[Union[bool, "SelectionRangeOptions", "SelectionRangeRegistrationOptions"]] """ The server provides selection range support. """ executeCommandProvider: NotRequired["ExecuteCommandOptions"] """ The server provides execute command support. """ callHierarchyProvider: NotRequired[Union[bool, "CallHierarchyOptions", "CallHierarchyRegistrationOptions"]] """ The server provides call hierarchy support. @since 3.16.0 """ linkedEditingRangeProvider: NotRequired[Union[bool, "LinkedEditingRangeOptions", "LinkedEditingRangeRegistrationOptions"]] """ The server provides linked editing range support. @since 3.16.0 """ semanticTokensProvider: NotRequired[Union["SemanticTokensOptions", "SemanticTokensRegistrationOptions"]] """ The server provides semantic tokens support. @since 3.16.0 """ monikerProvider: NotRequired[Union[bool, "MonikerOptions", "MonikerRegistrationOptions"]] """ The server provides moniker support. @since 3.16.0 """ typeHierarchyProvider: NotRequired[Union[bool, "TypeHierarchyOptions", "TypeHierarchyRegistrationOptions"]] """ The server provides type hierarchy support. @since 3.17.0 """ inlineValueProvider: NotRequired[Union[bool, "InlineValueOptions", "InlineValueRegistrationOptions"]] """ The server provides inline values. @since 3.17.0 """ inlayHintProvider: NotRequired[Union[bool, "InlayHintOptions", "InlayHintRegistrationOptions"]] """ The server provides inlay hints. @since 3.17.0 """ diagnosticProvider: NotRequired[Union["DiagnosticOptions", "DiagnosticRegistrationOptions"]] """ The server has support for pull model diagnostics. @since 3.17.0 """ workspace: NotRequired["__ServerCapabilities_workspace_Type_1"] """ Workspace specific server capabilities. """ experimental: NotRequired["LSPAny"] """ Experimental server capabilities. """ class VersionedTextDocumentIdentifier(TypedDict): """A text document identifier to denote a specific version of a text document.""" version: int """ The version number of this document. """ uri: "DocumentUri" """ The text document's uri. """ class SaveOptions(TypedDict): """Save options.""" includeText: NotRequired[bool] """ The client is supposed to include the content on save. """ class FileEvent(TypedDict): """An event describing a file change.""" uri: "DocumentUri" """ The file's uri. """ type: "FileChangeType" """ The change type. """ class FileSystemWatcher(TypedDict): globPattern: "GlobPattern" """ The glob pattern to watch. See {@link GlobPattern glob pattern} for more detail. @since 3.17.0 support for relative patterns. """ kind: NotRequired["WatchKind"] """ The kind of events of interest. If omitted it defaults to WatchKind.Create | WatchKind.Change | WatchKind.Delete which is 7. """ class Diagnostic(TypedDict): """Represents a diagnostic, such as a compiler error or warning. Diagnostic objects are only valid in the scope of a resource. """ range: "Range" """ The range at which the message applies """ severity: NotRequired["DiagnosticSeverity"] """ The diagnostic's severity. Can be omitted. If omitted it is up to the client to interpret diagnostics as error, warning, info or hint. """ code: NotRequired[int | str] """ The diagnostic's code, which usually appear in the user interface. """ codeDescription: NotRequired["CodeDescription"] """ An optional property to describe the error code. Requires the code field (above) to be present/not null. @since 3.16.0 """ source: NotRequired[str] """ A human-readable string describing the source of this diagnostic, e.g. 'typescript' or 'super lint'. It usually appears in the user interface. """ message: str """ The diagnostic's message. It usually appears in the user interface """ tags: NotRequired[list["DiagnosticTag"]] """ Additional metadata about the diagnostic. @since 3.15.0 """ relatedInformation: NotRequired[list["DiagnosticRelatedInformation"]] """ An array of related diagnostic information, e.g. when symbol-names within a scope collide all definitions can be marked via this property. """ data: NotRequired["LSPAny"] """ A data entry field that is preserved between a `textDocument/publishDiagnostics` notification and `textDocument/codeAction` request. @since 3.16.0 """ class CompletionContext(TypedDict): """Contains additional information about the context in which a completion request is triggered.""" triggerKind: "CompletionTriggerKind" """ How the completion was triggered. """ triggerCharacter: NotRequired[str] """ The trigger character (a single character) that has trigger code complete. Is undefined if `triggerKind !== CompletionTriggerKind.TriggerCharacter` """ class CompletionItemLabelDetails(TypedDict): """Additional details for a completion item label. @since 3.17.0 """ detail: NotRequired[str] """ An optional string which is rendered less prominently directly after {@link CompletionItem.label label}, without any spacing. Should be used for function signatures and type annotations. """ description: NotRequired[str] """ An optional string which is rendered less prominently after {@link CompletionItem.detail}. Should be used for fully qualified names and file paths. """ class InsertReplaceEdit(TypedDict): """A special text edit to provide an insert and a replace operation. @since 3.16.0 """ newText: str """ The string to be inserted. """ insert: "Range" """ The range if the insert is requested """ replace: "Range" """ The range if the replace is requested. """ class CompletionOptions(TypedDict): """Completion options.""" triggerCharacters: NotRequired[list[str]] """ Most tools trigger completion request automatically without explicitly requesting it using a keyboard shortcut (e.g. Ctrl+Space). Typically they do so when the user starts to type an identifier. For example if the user types `c` in a JavaScript file code complete will automatically pop up present `console` besides others as a completion item. Characters that make up identifiers don't need to be listed here. If code complete should automatically be trigger on characters not being valid inside an identifier (for example `.` in JavaScript) list them in `triggerCharacters`. """ allCommitCharacters: NotRequired[list[str]] """ The list of all possible characters that commit a completion. This field can be used if clients don't support individual commit characters per completion item. See `ClientCapabilities.textDocument.completion.completionItem.commitCharactersSupport` If a server provides both `allCommitCharacters` and commit characters on an individual completion item the ones on the completion item win. @since 3.2.0 """ resolveProvider: NotRequired[bool] """ The server provides support to resolve additional information for a completion item. """ completionItem: NotRequired["__CompletionOptions_completionItem_Type_2"] """ The server supports the following `CompletionItem` specific capabilities. @since 3.17.0 """ workDoneProgress: NotRequired[bool] class HoverOptions(TypedDict): """Hover options.""" workDoneProgress: NotRequired[bool] class SignatureHelpContext(TypedDict): """Additional information about the context in which a signature help request was triggered. @since 3.15.0 """ triggerKind: "SignatureHelpTriggerKind" """ Action that caused signature help to be triggered. """ triggerCharacter: NotRequired[str] """ Character that caused signature help to be triggered. This is undefined when `triggerKind !== SignatureHelpTriggerKind.TriggerCharacter` """ isRetrigger: bool """ `true` if signature help was already showing when it was triggered. Retriggers occurs when the signature help is already active and can be caused by actions such as typing a trigger character, a cursor move, or document content changes. """ activeSignatureHelp: NotRequired["SignatureHelp"] """ The currently active `SignatureHelp`. The `activeSignatureHelp` has its `SignatureHelp.activeSignature` field updated based on the user navigating through available signatures. """ class SignatureInformation(TypedDict): """Represents the signature of something callable. A signature can have a label, like a function-name, a doc-comment, and a set of parameters. """ label: str """ The label of this signature. Will be shown in the UI. """ documentation: NotRequired[Union[str, "MarkupContent"]] """ The human-readable doc-comment of this signature. Will be shown in the UI but can be omitted. """ parameters: NotRequired[list["ParameterInformation"]] """ The parameters of this signature. """ activeParameter: NotRequired[Uint] """ The index of the active parameter. If provided, this is used in place of `SignatureHelp.activeParameter`. @since 3.16.0 """ class SignatureHelpOptions(TypedDict): """Server Capabilities for a {@link SignatureHelpRequest}.""" triggerCharacters: NotRequired[list[str]] """ List of characters that trigger signature help automatically. """ retriggerCharacters: NotRequired[list[str]] """ List of characters that re-trigger signature help. These trigger characters are only active when signature help is already showing. All trigger characters are also counted as re-trigger characters. @since 3.15.0 """ workDoneProgress: NotRequired[bool] class DefinitionOptions(TypedDict): """Server Capabilities for a {@link DefinitionRequest}.""" workDoneProgress: NotRequired[bool] class ReferenceContext(TypedDict): """Value-object that contains additional information when requesting references. """ includeDeclaration: bool """ Include the declaration of the current symbol. """ class ReferenceOptions(TypedDict): """Reference options.""" workDoneProgress: NotRequired[bool] class DocumentHighlightOptions(TypedDict): """Provider options for a {@link DocumentHighlightRequest}.""" workDoneProgress: NotRequired[bool] class BaseSymbolInformation(TypedDict): """A base for all symbol information.""" name: str """ The name of this symbol. """ kind: "SymbolKind" """ The kind of this symbol. """ tags: NotRequired[list["SymbolTag"]] """ Tags for this symbol. @since 3.16.0 """ containerName: NotRequired[str] """ The name of the symbol containing this symbol. This information is for user interface purposes (e.g. to render a qualifier in the user interface if necessary). It can't be used to re-infer a hierarchy for the document symbols. """ class DocumentSymbolOptions(TypedDict): """Provider options for a {@link DocumentSymbolRequest}.""" label: NotRequired[str] """ A human-readable string that is shown when multiple outlines trees are shown for the same document. @since 3.16.0 """ workDoneProgress: NotRequired[bool] class CodeActionContext(TypedDict): """Contains additional diagnostic information about the context in which a {@link CodeActionProvider.provideCodeActions code action} is run. """ diagnostics: list["Diagnostic"] """ An array of diagnostics known on the client side overlapping the range provided to the `textDocument/codeAction` request. They are provided so that the server knows which errors are currently presented to the user for the given range. There is no guarantee that these accurately reflect the error state of the resource. The primary parameter to compute code actions is the provided range. """ only: NotRequired[list["CodeActionKind"]] """ Requested kind of actions to return. Actions not of this kind are filtered out by the client before being shown. So servers can omit computing them. """ triggerKind: NotRequired["CodeActionTriggerKind"] """ The reason why code actions were requested. @since 3.17.0 """ class CodeActionOptions(TypedDict): """Provider options for a {@link CodeActionRequest}.""" codeActionKinds: NotRequired[list["CodeActionKind"]] """ CodeActionKinds that this server may return. The list of kinds may be generic, such as `CodeActionKind.Refactor`, or the server may list out every specific kind they provide. """ resolveProvider: NotRequired[bool] """ The server provides support to resolve additional information for a code action. @since 3.16.0 """ workDoneProgress: NotRequired[bool] class WorkspaceSymbolOptions(TypedDict): """Server capabilities for a {@link WorkspaceSymbolRequest}.""" resolveProvider: NotRequired[bool] """ The server provides support to resolve additional information for a workspace symbol. @since 3.17.0 """ workDoneProgress: NotRequired[bool] class CodeLensOptions(TypedDict): """Code Lens provider options of a {@link CodeLensRequest}.""" resolveProvider: NotRequired[bool] """ Code lens has a resolve provider as well. """ workDoneProgress: NotRequired[bool] class DocumentLinkOptions(TypedDict): """Provider options for a {@link DocumentLinkRequest}.""" resolveProvider: NotRequired[bool] """ Document links have a resolve provider as well. """ workDoneProgress: NotRequired[bool] class FormattingOptions(TypedDict): """Value-object describing what options formatting should use.""" tabSize: Uint """ Size of a tab in spaces. """ insertSpaces: bool """ Prefer spaces over tabs. """ trimTrailingWhitespace: NotRequired[bool] """ Trim trailing whitespace on a line. @since 3.15.0 """ insertFinalNewline: NotRequired[bool] """ Insert a newline character at the end of the file if one does not exist. @since 3.15.0 """ trimFinalNewlines: NotRequired[bool] """ Trim all newlines after the final newline at the end of the file. @since 3.15.0 """ class DocumentFormattingOptions(TypedDict): """Provider options for a {@link DocumentFormattingRequest}.""" workDoneProgress: NotRequired[bool] class DocumentRangeFormattingOptions(TypedDict): """Provider options for a {@link DocumentRangeFormattingRequest}.""" workDoneProgress: NotRequired[bool] class DocumentOnTypeFormattingOptions(TypedDict): """Provider options for a {@link DocumentOnTypeFormattingRequest}.""" firstTriggerCharacter: str """ A character on which formatting should be triggered, like `{`. """ moreTriggerCharacter: NotRequired[list[str]] """ More trigger characters. """ class RenameOptions(TypedDict): """Provider options for a {@link RenameRequest}.""" prepareProvider: NotRequired[bool] """ Renames should be checked and tested before being executed. @since version 3.12.0 """ workDoneProgress: NotRequired[bool] class ExecuteCommandOptions(TypedDict): """The server capabilities of a {@link ExecuteCommandRequest}.""" commands: list[str] """ The commands to be executed on the server """ workDoneProgress: NotRequired[bool] class SemanticTokensLegend(TypedDict): """@since 3.16.0""" tokenTypes: list[str] """ The token types a server uses. """ tokenModifiers: list[str] """ The token modifiers a server uses. """ class OptionalVersionedTextDocumentIdentifier(TypedDict): """A text document identifier to optionally denote a specific version of a text document.""" version: int | None """ The version number of this document. If a versioned text document identifier is sent from the server to the client and the file is not open in the editor (the server has not received an open notification before) the server can send `null` to indicate that the version is unknown and the content on disk is the truth (as specified with document content ownership). """ uri: "DocumentUri" """ The text document's uri. """ class AnnotatedTextEdit(TypedDict): """A special text edit with an additional change annotation. @since 3.16.0. """ annotationId: "ChangeAnnotationIdentifier" """ The actual identifier of the change annotation """ range: "Range" """ The range of the text document to be manipulated. To insert text into a document create a range where start === end. """ newText: str """ The string to be inserted. For delete operations use an empty string. """ class ResourceOperation(TypedDict): """A generic resource operation.""" kind: str """ The resource operation kind. """ annotationId: NotRequired["ChangeAnnotationIdentifier"] """ An optional annotation identifier describing the operation. @since 3.16.0 """ class CreateFileOptions(TypedDict): """Options to create a file.""" overwrite: NotRequired[bool] """ Overwrite existing file. Overwrite wins over `ignoreIfExists` """ ignoreIfExists: NotRequired[bool] """ Ignore if exists. """ class RenameFileOptions(TypedDict): """Rename file options""" overwrite: NotRequired[bool] """ Overwrite target if existing. Overwrite wins over `ignoreIfExists` """ ignoreIfExists: NotRequired[bool] """ Ignores if target exists. """ class DeleteFileOptions(TypedDict): """Delete file options""" recursive: NotRequired[bool] """ Delete the content recursively if a folder is denoted. """ ignoreIfNotExists: NotRequired[bool] """ Ignore the operation if the file doesn't exist. """ class FileOperationPattern(TypedDict): """A pattern to describe in which file operation requests or notifications the server is interested in receiving. @since 3.16.0 """ glob: str """ The glob pattern to match. Glob patterns can have the following syntax: - `*` to match one or more characters in a path segment - `?` to match on one character in a path segment - `**` to match any number of path segments, including none - `{}` to group sub patterns into an OR expression. (e.g. `**\u200b/*.{ts,js}` matches all TypeScript and JavaScript files) - `[]` to declare a range of characters to match in a path segment (e.g., `example.[0-9]` to match on `example.0`, `example.1`, …) - `[!...]` to negate a range of characters to match in a path segment (e.g., `example.[!0-9]` to match on `example.a`, `example.b`, but not `example.0`) """ matches: NotRequired["FileOperationPatternKind"] """ Whether to match files or folders with this pattern. Matches both if undefined. """ options: NotRequired["FileOperationPatternOptions"] """ Additional options used during matching. """ class WorkspaceFullDocumentDiagnosticReport(TypedDict): """A full document diagnostic report for a workspace diagnostic result. @since 3.17.0 """ uri: "DocumentUri" """ The URI for which diagnostic information is reported. """ version: int | None """ The version number for which the diagnostics are reported. If the document is not marked as open `null` can be provided. """ kind: Literal["full"] """ A full document diagnostic report. """ resultId: NotRequired[str] """ An optional result id. If provided it will be sent on the next diagnostic request for the same document. """ items: list["Diagnostic"] """ The actual items. """ class WorkspaceUnchangedDocumentDiagnosticReport(TypedDict): """An unchanged document diagnostic report for a workspace diagnostic result. @since 3.17.0 """ uri: "DocumentUri" """ The URI for which diagnostic information is reported. """ version: int | None """ The version number for which the diagnostics are reported. If the document is not marked as open `null` can be provided. """ kind: Literal["unchanged"] """ A document diagnostic report indicating no changes to the last result. A server can only return `unchanged` if result ids are provided. """ resultId: str """ A result id which will be sent on the next diagnostic request for the same document. """ class NotebookCell(TypedDict): """A notebook cell. A cell's document URI must be unique across ALL notebook cells and can therefore be used to uniquely identify a notebook cell or the cell's text document. @since 3.17.0 """ kind: "NotebookCellKind" """ The cell's kind """ document: "DocumentUri" """ The URI of the cell's text document content. """ metadata: NotRequired["LSPObject"] """ Additional metadata stored with the cell. Note: should always be an object literal (e.g. LSPObject) """ executionSummary: NotRequired["ExecutionSummary"] """ Additional execution summary information if supported by the client. """ class NotebookCellArrayChange(TypedDict): """A change describing how to move a `NotebookCell` array from state S to S'. @since 3.17.0 """ start: Uint """ The start oftest of the cell that changed. """ deleteCount: Uint """ The deleted cells """ cells: NotRequired[list["NotebookCell"]] """ The new cells, if any """ class ClientCapabilities(TypedDict): """Defines the capabilities provided by the client.""" workspace: NotRequired["WorkspaceClientCapabilities"] """ Workspace specific client capabilities. """ textDocument: NotRequired["TextDocumentClientCapabilities"] """ Text document specific client capabilities. """ notebookDocument: NotRequired["NotebookDocumentClientCapabilities"] """ Capabilities specific to the notebook document support. @since 3.17.0 """ window: NotRequired["WindowClientCapabilities"] """ Window specific client capabilities. """ general: NotRequired["GeneralClientCapabilities"] """ General client capabilities. @since 3.16.0 """ experimental: NotRequired["LSPAny"] """ Experimental client capabilities. """ class TextDocumentSyncOptions(TypedDict): openClose: NotRequired[bool] """ Open and close notifications are sent to the server. If omitted open close notification should not be sent. """ change: NotRequired["TextDocumentSyncKind"] """ Change notifications are sent to the server. See TextDocumentSyncKind.None, TextDocumentSyncKind.Full and TextDocumentSyncKind.Incremental. If omitted it defaults to TextDocumentSyncKind.None. """ willSave: NotRequired[bool] """ If present will save notifications are sent to the server. If omitted the notification should not be sent. """ willSaveWaitUntil: NotRequired[bool] """ If present will save wait until requests are sent to the server. If omitted the request should not be sent. """ save: NotRequired[Union[bool, "SaveOptions"]] """ If present save notifications are sent to the server. If omitted the notification should not be sent. """ class NotebookDocumentSyncOptions(TypedDict): """Options specific to a notebook plus its cells to be synced to the server. If a selector provides a notebook document filter but no cell selector all cells of a matching notebook document will be synced. If a selector provides no notebook document filter but only a cell selector all notebook document that contain at least one matching cell will be synced. @since 3.17.0 """ notebookSelector: list[ Union[ "__NotebookDocumentSyncOptions_notebookSelector_Type_1", "__NotebookDocumentSyncOptions_notebookSelector_Type_2", ] ] """ The notebooks to be synced """ save: NotRequired[bool] """ Whether save notification should be forwarded to the server. Will only be honored if mode === `notebook`. """ class NotebookDocumentSyncRegistrationOptions(TypedDict): """Registration options specific to a notebook. @since 3.17.0 """ notebookSelector: list[ Union[ "__NotebookDocumentSyncOptions_notebookSelector_Type_3", "__NotebookDocumentSyncOptions_notebookSelector_Type_4", ] ] """ The notebooks to be synced """ save: NotRequired[bool] """ Whether save notification should be forwarded to the server. Will only be honored if mode === `notebook`. """ id: NotRequired[str] """ The id used to register the request. The id can be used to deregister the request again. See also Registration#id. """ class WorkspaceFoldersServerCapabilities(TypedDict): supported: NotRequired[bool] """ The server has support for workspace folders """ changeNotifications: NotRequired[str | bool] """ Whether the server wants to receive workspace folder change notifications. If a string is provided the string is treated as an ID under which the notification is registered on the client side. The ID can be used to unregister for these events using the `client/unregisterCapability` request. """ class FileOperationOptions(TypedDict): """Options for notifications/requests for user operations on files. @since 3.16.0 """ didCreate: NotRequired["FileOperationRegistrationOptions"] """ The server is interested in receiving didCreateFiles notifications. """ willCreate: NotRequired["FileOperationRegistrationOptions"] """ The server is interested in receiving willCreateFiles requests. """ didRename: NotRequired["FileOperationRegistrationOptions"] """ The server is interested in receiving didRenameFiles notifications. """ willRename: NotRequired["FileOperationRegistrationOptions"] """ The server is interested in receiving willRenameFiles requests. """ didDelete: NotRequired["FileOperationRegistrationOptions"] """ The server is interested in receiving didDeleteFiles file notifications. """ willDelete: NotRequired["FileOperationRegistrationOptions"] """ The server is interested in receiving willDeleteFiles file requests. """ class CodeDescription(TypedDict): """Structure to capture a description for an error code. @since 3.16.0 """ href: "URI" """ An URI to open with more information about the diagnostic error. """ class DiagnosticRelatedInformation(TypedDict): """Represents a related message and source code location for a diagnostic. This should be used to point to code locations that cause or related to a diagnostics, e.g when duplicating a symbol in a scope. """ location: "Location" """ The location of this related diagnostic information. """ message: str """ The message of this related diagnostic information. """ class ParameterInformation(TypedDict): """Represents a parameter of a callable-signature. A parameter can have a label and a doc-comment. """ label: str | list[Uint | Uint] """ The label of this parameter information. Either a string or an inclusive start and exclusive end offsets within its containing signature label. (see SignatureInformation.label). The offsets are based on a UTF-16 string representation as `Position` and `Range` does. *Note*: a label of type string should be a substring of its containing signature label. Its intended use case is to highlight the parameter label part in the `SignatureInformation.label`. """ documentation: NotRequired[Union[str, "MarkupContent"]] """ The human-readable doc-comment of this parameter. Will be shown in the UI but can be omitted. """ class NotebookCellTextDocumentFilter(TypedDict): """A notebook cell text document filter denotes a cell text document by different properties. @since 3.17.0 """ notebook: Union[str, "NotebookDocumentFilter"] """ A filter that matches against the notebook containing the notebook cell. If a string value is provided it matches against the notebook type. '*' matches every notebook. """ language: NotRequired[str] """ A language id like `python`. Will be matched against the language id of the notebook cell document. '*' matches every language. """ class FileOperationPatternOptions(TypedDict): """Matching options for the file operation pattern. @since 3.16.0 """ ignoreCase: NotRequired[bool] """ The pattern should be matched ignoring casing. """ class ExecutionSummary(TypedDict): executionOrder: Uint """ A strict monotonically increasing value indicating the execution order of a cell inside a notebook. """ success: NotRequired[bool] """ Whether the execution was successful or not if known by the client. """ class WorkspaceClientCapabilities(TypedDict): """Workspace specific client capabilities.""" applyEdit: NotRequired[bool] """ The client supports applying batch edits to the workspace by supporting the request 'workspace/applyEdit' """ workspaceEdit: NotRequired["WorkspaceEditClientCapabilities"] """ Capabilities specific to `WorkspaceEdit`s. """ didChangeConfiguration: NotRequired["DidChangeConfigurationClientCapabilities"] """ Capabilities specific to the `workspace/didChangeConfiguration` notification. """ didChangeWatchedFiles: NotRequired["DidChangeWatchedFilesClientCapabilities"] """ Capabilities specific to the `workspace/didChangeWatchedFiles` notification. """ symbol: NotRequired["WorkspaceSymbolClientCapabilities"] """ Capabilities specific to the `workspace/symbol` request. """ executeCommand: NotRequired["ExecuteCommandClientCapabilities"] """ Capabilities specific to the `workspace/executeCommand` request. """ workspaceFolders: NotRequired[bool] """ The client has support for workspace folders. @since 3.6.0 """ configuration: NotRequired[bool] """ The client supports `workspace/configuration` requests. @since 3.6.0 """ semanticTokens: NotRequired["SemanticTokensWorkspaceClientCapabilities"] """ Capabilities specific to the semantic token requests scoped to the workspace. @since 3.16.0. """ codeLens: NotRequired["CodeLensWorkspaceClientCapabilities"] """ Capabilities specific to the code lens requests scoped to the workspace. @since 3.16.0. """ fileOperations: NotRequired["FileOperationClientCapabilities"] """ The client has support for file notifications/requests for user operations on files. Since 3.16.0 """ inlineValue: NotRequired["InlineValueWorkspaceClientCapabilities"] """ Capabilities specific to the inline values requests scoped to the workspace. @since 3.17.0. """ inlayHint: NotRequired["InlayHintWorkspaceClientCapabilities"] """ Capabilities specific to the inlay hint requests scoped to the workspace. @since 3.17.0. """ diagnostics: NotRequired["DiagnosticWorkspaceClientCapabilities"] """ Capabilities specific to the diagnostic requests scoped to the workspace. @since 3.17.0. """ class TextDocumentClientCapabilities(TypedDict): """Text document specific client capabilities.""" synchronization: NotRequired["TextDocumentSyncClientCapabilities"] """ Defines which synchronization capabilities the client supports. """ completion: NotRequired["CompletionClientCapabilities"] """ Capabilities specific to the `textDocument/completion` request. """ hover: NotRequired["HoverClientCapabilities"] """ Capabilities specific to the `textDocument/hover` request. """ signatureHelp: NotRequired["SignatureHelpClientCapabilities"] """ Capabilities specific to the `textDocument/signatureHelp` request. """ declaration: NotRequired["DeclarationClientCapabilities"] """ Capabilities specific to the `textDocument/declaration` request. @since 3.14.0 """ definition: NotRequired["DefinitionClientCapabilities"] """ Capabilities specific to the `textDocument/definition` request. """ typeDefinition: NotRequired["TypeDefinitionClientCapabilities"] """ Capabilities specific to the `textDocument/typeDefinition` request. @since 3.6.0 """ implementation: NotRequired["ImplementationClientCapabilities"] """ Capabilities specific to the `textDocument/implementation` request. @since 3.6.0 """ references: NotRequired["ReferenceClientCapabilities"] """ Capabilities specific to the `textDocument/references` request. """ documentHighlight: NotRequired["DocumentHighlightClientCapabilities"] """ Capabilities specific to the `textDocument/documentHighlight` request. """ documentSymbol: NotRequired["DocumentSymbolClientCapabilities"] """ Capabilities specific to the `textDocument/documentSymbol` request. """ codeAction: NotRequired["CodeActionClientCapabilities"] """ Capabilities specific to the `textDocument/codeAction` request. """ codeLens: NotRequired["CodeLensClientCapabilities"] """ Capabilities specific to the `textDocument/codeLens` request. """ documentLink: NotRequired["DocumentLinkClientCapabilities"] """ Capabilities specific to the `textDocument/documentLink` request. """ colorProvider: NotRequired["DocumentColorClientCapabilities"] """ Capabilities specific to the `textDocument/documentColor` and the `textDocument/colorPresentation` request. @since 3.6.0 """ formatting: NotRequired["DocumentFormattingClientCapabilities"] """ Capabilities specific to the `textDocument/formatting` request. """ rangeFormatting: NotRequired["DocumentRangeFormattingClientCapabilities"] """ Capabilities specific to the `textDocument/rangeFormatting` request. """ onTypeFormatting: NotRequired["DocumentOnTypeFormattingClientCapabilities"] """ Capabilities specific to the `textDocument/onTypeFormatting` request. """ rename: NotRequired["RenameClientCapabilities"] """ Capabilities specific to the `textDocument/rename` request. """ foldingRange: NotRequired["FoldingRangeClientCapabilities"] """ Capabilities specific to the `textDocument/foldingRange` request. @since 3.10.0 """ selectionRange: NotRequired["SelectionRangeClientCapabilities"] """ Capabilities specific to the `textDocument/selectionRange` request. @since 3.15.0 """ publishDiagnostics: NotRequired["PublishDiagnosticsClientCapabilities"] """ Capabilities specific to the `textDocument/publishDiagnostics` notification. """ callHierarchy: NotRequired["CallHierarchyClientCapabilities"] """ Capabilities specific to the various call hierarchy requests. @since 3.16.0 """ semanticTokens: NotRequired["SemanticTokensClientCapabilities"] """ Capabilities specific to the various semantic token request. @since 3.16.0 """ linkedEditingRange: NotRequired["LinkedEditingRangeClientCapabilities"] """ Capabilities specific to the `textDocument/linkedEditingRange` request. @since 3.16.0 """ moniker: NotRequired["MonikerClientCapabilities"] """ Client capabilities specific to the `textDocument/moniker` request. @since 3.16.0 """ typeHierarchy: NotRequired["TypeHierarchyClientCapabilities"] """ Capabilities specific to the various type hierarchy requests. @since 3.17.0 """ inlineValue: NotRequired["InlineValueClientCapabilities"] """ Capabilities specific to the `textDocument/inlineValue` request. @since 3.17.0 """ inlayHint: NotRequired["InlayHintClientCapabilities"] """ Capabilities specific to the `textDocument/inlayHint` request. @since 3.17.0 """ diagnostic: NotRequired["DiagnosticClientCapabilities"] """ Capabilities specific to the diagnostic pull model. @since 3.17.0 """ class NotebookDocumentClientCapabilities(TypedDict): """Capabilities specific to the notebook document support. @since 3.17.0 """ synchronization: "NotebookDocumentSyncClientCapabilities" """ Capabilities specific to notebook document synchronization @since 3.17.0 """ class WindowClientCapabilities(TypedDict): workDoneProgress: NotRequired[bool] """ It indicates whether the client supports server initiated progress using the `window/workDoneProgress/create` request. The capability also controls Whether client supports handling of progress notifications. If set servers are allowed to report a `workDoneProgress` property in the request specific server capabilities. @since 3.15.0 """ showMessage: NotRequired["ShowMessageRequestClientCapabilities"] """ Capabilities specific to the showMessage request. @since 3.16.0 """ showDocument: NotRequired["ShowDocumentClientCapabilities"] """ Capabilities specific to the showDocument request. @since 3.16.0 """ class GeneralClientCapabilities(TypedDict): """General client capabilities. @since 3.16.0 """ staleRequestSupport: NotRequired["__GeneralClientCapabilities_staleRequestSupport_Type_1"] """ Client capability that signals how the client handles stale requests (e.g. a request for which the client will not process the response anymore since the information is outdated). @since 3.17.0 """ regularExpressions: NotRequired["RegularExpressionsClientCapabilities"] """ Client capabilities specific to regular expressions. @since 3.16.0 """ markdown: NotRequired["MarkdownClientCapabilities"] """ Client capabilities specific to the client's markdown parser. @since 3.16.0 """ positionEncodings: NotRequired[list["PositionEncodingKind"]] """ The position encodings supported by the client. Client and server have to agree on the same position encoding to ensure that offsets (e.g. character position in a line) are interpreted the same on both sides. To keep the protocol backwards compatible the following applies: if the value 'utf-16' is missing from the array of position encodings servers can assume that the client supports UTF-16. UTF-16 is therefore a mandatory encoding. If omitted it defaults to ['utf-16']. Implementation considerations: since the conversion from one encoding into another requires the content of the file / line the conversion is best done where the file is read which is usually on the server side. @since 3.17.0 """ class RelativePattern(TypedDict): """A relative pattern is a helper to construct glob patterns that are matched relatively to a base URI. The common value for a `baseUri` is a workspace folder root, but it can be another absolute URI as well. @since 3.17.0 """ baseUri: Union["WorkspaceFolder", "URI"] """ A workspace folder or a base URI to which this pattern will be matched against relatively. """ pattern: "Pattern" """ The actual glob pattern; """ class WorkspaceEditClientCapabilities(TypedDict): documentChanges: NotRequired[bool] """ The client supports versioned document changes in `WorkspaceEdit`s """ resourceOperations: NotRequired[list["ResourceOperationKind"]] """ The resource operations the client supports. Clients should at least support 'create', 'rename' and 'delete' files and folders. @since 3.13.0 """ failureHandling: NotRequired["FailureHandlingKind"] """ The failure handling strategy of a client if applying the workspace edit fails. @since 3.13.0 """ normalizesLineEndings: NotRequired[bool] """ Whether the client normalizes line endings to the client specific setting. If set to `true` the client will normalize line ending characters in a workspace edit to the client-specified new line character. @since 3.16.0 """ changeAnnotationSupport: NotRequired["__WorkspaceEditClientCapabilities_changeAnnotationSupport_Type_1"] """ Whether the client in general supports change annotations on text edits, create file, rename file and delete file changes. @since 3.16.0 """ class DidChangeConfigurationClientCapabilities(TypedDict): dynamicRegistration: NotRequired[bool] """ Did change configuration notification supports dynamic registration. """ class DidChangeWatchedFilesClientCapabilities(TypedDict): dynamicRegistration: NotRequired[bool] """ Did change watched files notification supports dynamic registration. Please note that the current protocol doesn't support static configuration for file changes from the server side. """ relativePatternSupport: NotRequired[bool] """ Whether the client has support for {@link RelativePattern relative pattern} or not. @since 3.17.0 """ class WorkspaceSymbolClientCapabilities(TypedDict): """Client capabilities for a {@link WorkspaceSymbolRequest}.""" dynamicRegistration: NotRequired[bool] """ Symbol request supports dynamic registration. """ symbolKind: NotRequired["__WorkspaceSymbolClientCapabilities_symbolKind_Type_1"] """ Specific capabilities for the `SymbolKind` in the `workspace/symbol` request. """ tagSupport: NotRequired["__WorkspaceSymbolClientCapabilities_tagSupport_Type_1"] """ The client supports tags on `SymbolInformation`. Clients supporting tags have to handle unknown tags gracefully. @since 3.16.0 """ resolveSupport: NotRequired["__WorkspaceSymbolClientCapabilities_resolveSupport_Type_1"] """ The client support partial workspace symbols. The client will send the request `workspaceSymbol/resolve` to the server to resolve additional properties. @since 3.17.0 """ class ExecuteCommandClientCapabilities(TypedDict): """The client capabilities of a {@link ExecuteCommandRequest}.""" dynamicRegistration: NotRequired[bool] """ Execute command supports dynamic registration. """ class SemanticTokensWorkspaceClientCapabilities(TypedDict): """@since 3.16.0""" refreshSupport: NotRequired[bool] """ Whether the client implementation supports a refresh request sent from the server to the client. Note that this event is global and will force the client to refresh all semantic tokens currently shown. It should be used with absolute care and is useful for situation where a server for example detects a project wide change that requires such a calculation. """ class CodeLensWorkspaceClientCapabilities(TypedDict): """@since 3.16.0""" refreshSupport: NotRequired[bool] """ Whether the client implementation supports a refresh request sent from the server to the client. Note that this event is global and will force the client to refresh all code lenses currently shown. It should be used with absolute care and is useful for situation where a server for example detect a project wide change that requires such a calculation. """ class FileOperationClientCapabilities(TypedDict): """Capabilities relating to events from file operations by the user in the client. These events do not come from the file system, they come from user operations like renaming a file in the UI. @since 3.16.0 """ dynamicRegistration: NotRequired[bool] """ Whether the client supports dynamic registration for file requests/notifications. """ didCreate: NotRequired[bool] """ The client has support for sending didCreateFiles notifications. """ willCreate: NotRequired[bool] """ The client has support for sending willCreateFiles requests. """ didRename: NotRequired[bool] """ The client has support for sending didRenameFiles notifications. """ willRename: NotRequired[bool] """ The client has support for sending willRenameFiles requests. """ didDelete: NotRequired[bool] """ The client has support for sending didDeleteFiles notifications. """ willDelete: NotRequired[bool] """ The client has support for sending willDeleteFiles requests. """ class InlineValueWorkspaceClientCapabilities(TypedDict): """Client workspace capabilities specific to inline values. @since 3.17.0 """ refreshSupport: NotRequired[bool] """ Whether the client implementation supports a refresh request sent from the server to the client. Note that this event is global and will force the client to refresh all inline values currently shown. It should be used with absolute care and is useful for situation where a server for example detects a project wide change that requires such a calculation. """ class InlayHintWorkspaceClientCapabilities(TypedDict): """Client workspace capabilities specific to inlay hints. @since 3.17.0 """ refreshSupport: NotRequired[bool] """ Whether the client implementation supports a refresh request sent from the server to the client. Note that this event is global and will force the client to refresh all inlay hints currently shown. It should be used with absolute care and is useful for situation where a server for example detects a project wide change that requires such a calculation. """ class DiagnosticWorkspaceClientCapabilities(TypedDict): """Workspace client capabilities specific to diagnostic pull requests. @since 3.17.0 """ refreshSupport: NotRequired[bool] """ Whether the client implementation supports a refresh request sent from the server to the client. Note that this event is global and will force the client to refresh all pulled diagnostics currently shown. It should be used with absolute care and is useful for situation where a server for example detects a project wide change that requires such a calculation. """ class TextDocumentSyncClientCapabilities(TypedDict): dynamicRegistration: NotRequired[bool] """ Whether text document synchronization supports dynamic registration. """ willSave: NotRequired[bool] """ The client supports sending will save notifications. """ willSaveWaitUntil: NotRequired[bool] """ The client supports sending a will save request and waits for a response providing text edits which will be applied to the document before it is saved. """ didSave: NotRequired[bool] """ The client supports did save notifications. """ class CompletionClientCapabilities(TypedDict): """Completion client capabilities""" dynamicRegistration: NotRequired[bool] """ Whether completion supports dynamic registration. """ completionItem: NotRequired["__CompletionClientCapabilities_completionItem_Type_1"] """ The client supports the following `CompletionItem` specific capabilities. """ completionItemKind: NotRequired["__CompletionClientCapabilities_completionItemKind_Type_1"] insertTextMode: NotRequired["InsertTextMode"] """ Defines how the client handles whitespace and indentation when accepting a completion item that uses multi line text in either `insertText` or `textEdit`. @since 3.17.0 """ contextSupport: NotRequired[bool] """ The client supports to send additional context information for a `textDocument/completion` request. """ completionList: NotRequired["__CompletionClientCapabilities_completionList_Type_1"] """ The client supports the following `CompletionList` specific capabilities. @since 3.17.0 """ class HoverClientCapabilities(TypedDict): dynamicRegistration: NotRequired[bool] """ Whether hover supports dynamic registration. """ contentFormat: NotRequired[list["MarkupKind"]] """ Client supports the following content formats for the content property. The order describes the preferred format of the client. """ class SignatureHelpClientCapabilities(TypedDict): """Client Capabilities for a {@link SignatureHelpRequest}.""" dynamicRegistration: NotRequired[bool] """ Whether signature help supports dynamic registration. """ signatureInformation: NotRequired["__SignatureHelpClientCapabilities_signatureInformation_Type_1"] """ The client supports the following `SignatureInformation` specific properties. """ contextSupport: NotRequired[bool] """ The client supports to send additional context information for a `textDocument/signatureHelp` request. A client that opts into contextSupport will also support the `retriggerCharacters` on `SignatureHelpOptions`. @since 3.15.0 """ class DeclarationClientCapabilities(TypedDict): """@since 3.14.0""" dynamicRegistration: NotRequired[bool] """ Whether declaration supports dynamic registration. If this is set to `true` the client supports the new `DeclarationRegistrationOptions` return value for the corresponding server capability as well. """ linkSupport: NotRequired[bool] """ The client supports additional metadata in the form of declaration links. """ class DefinitionClientCapabilities(TypedDict): """Client Capabilities for a {@link DefinitionRequest}.""" dynamicRegistration: NotRequired[bool] """ Whether definition supports dynamic registration. """ linkSupport: NotRequired[bool] """ The client supports additional metadata in the form of definition links. @since 3.14.0 """ class TypeDefinitionClientCapabilities(TypedDict): """Since 3.6.0""" dynamicRegistration: NotRequired[bool] """ Whether implementation supports dynamic registration. If this is set to `true` the client supports the new `TypeDefinitionRegistrationOptions` return value for the corresponding server capability as well. """ linkSupport: NotRequired[bool] """ The client supports additional metadata in the form of definition links. Since 3.14.0 """ class ImplementationClientCapabilities(TypedDict): """@since 3.6.0""" dynamicRegistration: NotRequired[bool] """ Whether implementation supports dynamic registration. If this is set to `true` the client supports the new `ImplementationRegistrationOptions` return value for the corresponding server capability as well. """ linkSupport: NotRequired[bool] """ The client supports additional metadata in the form of definition links. @since 3.14.0 """ class ReferenceClientCapabilities(TypedDict): """Client Capabilities for a {@link ReferencesRequest}.""" dynamicRegistration: NotRequired[bool] """ Whether references supports dynamic registration. """ class DocumentHighlightClientCapabilities(TypedDict): """Client Capabilities for a {@link DocumentHighlightRequest}.""" dynamicRegistration: NotRequired[bool] """ Whether document highlight supports dynamic registration. """ class DocumentSymbolClientCapabilities(TypedDict): """Client Capabilities for a {@link DocumentSymbolRequest}.""" dynamicRegistration: NotRequired[bool] """ Whether document symbol supports dynamic registration. """ symbolKind: NotRequired["__DocumentSymbolClientCapabilities_symbolKind_Type_1"] """ Specific capabilities for the `SymbolKind` in the `textDocument/documentSymbol` request. """ hierarchicalDocumentSymbolSupport: NotRequired[bool] """ The client supports hierarchical document symbols. """ tagSupport: NotRequired["__DocumentSymbolClientCapabilities_tagSupport_Type_1"] """ The client supports tags on `SymbolInformation`. Tags are supported on `DocumentSymbol` if `hierarchicalDocumentSymbolSupport` is set to true. Clients supporting tags have to handle unknown tags gracefully. @since 3.16.0 """ labelSupport: NotRequired[bool] """ The client supports an additional label presented in the UI when registering a document symbol provider. @since 3.16.0 """ class CodeActionClientCapabilities(TypedDict): """The Client Capabilities of a {@link CodeActionRequest}.""" dynamicRegistration: NotRequired[bool] """ Whether code action supports dynamic registration. """ codeActionLiteralSupport: NotRequired["__CodeActionClientCapabilities_codeActionLiteralSupport_Type_1"] """ The client support code action literals of type `CodeAction` as a valid response of the `textDocument/codeAction` request. If the property is not set the request can only return `Command` literals. @since 3.8.0 """ isPreferredSupport: NotRequired[bool] """ Whether code action supports the `isPreferred` property. @since 3.15.0 """ disabledSupport: NotRequired[bool] """ Whether code action supports the `disabled` property. @since 3.16.0 """ dataSupport: NotRequired[bool] """ Whether code action supports the `data` property which is preserved between a `textDocument/codeAction` and a `codeAction/resolve` request. @since 3.16.0 """ resolveSupport: NotRequired["__CodeActionClientCapabilities_resolveSupport_Type_1"] """ Whether the client supports resolving additional code action properties via a separate `codeAction/resolve` request. @since 3.16.0 """ honorsChangeAnnotations: NotRequired[bool] """ Whether the client honors the change annotations in text edits and resource operations returned via the `CodeAction#edit` property by for example presenting the workspace edit in the user interface and asking for confirmation. @since 3.16.0 """ class CodeLensClientCapabilities(TypedDict): """The client capabilities of a {@link CodeLensRequest}.""" dynamicRegistration: NotRequired[bool] """ Whether code lens supports dynamic registration. """ class DocumentLinkClientCapabilities(TypedDict): """The client capabilities of a {@link DocumentLinkRequest}.""" dynamicRegistration: NotRequired[bool] """ Whether document link supports dynamic registration. """ tooltipSupport: NotRequired[bool] """ Whether the client supports the `tooltip` property on `DocumentLink`. @since 3.15.0 """ class DocumentColorClientCapabilities(TypedDict): dynamicRegistration: NotRequired[bool] """ Whether implementation supports dynamic registration. If this is set to `true` the client supports the new `DocumentColorRegistrationOptions` return value for the corresponding server capability as well. """ class DocumentFormattingClientCapabilities(TypedDict): """Client capabilities of a {@link DocumentFormattingRequest}.""" dynamicRegistration: NotRequired[bool] """ Whether formatting supports dynamic registration. """ class DocumentRangeFormattingClientCapabilities(TypedDict): """Client capabilities of a {@link DocumentRangeFormattingRequest}.""" dynamicRegistration: NotRequired[bool] """ Whether range formatting supports dynamic registration. """ class DocumentOnTypeFormattingClientCapabilities(TypedDict): """Client capabilities of a {@link DocumentOnTypeFormattingRequest}.""" dynamicRegistration: NotRequired[bool] """ Whether on type formatting supports dynamic registration. """ class RenameClientCapabilities(TypedDict): dynamicRegistration: NotRequired[bool] """ Whether rename supports dynamic registration. """ prepareSupport: NotRequired[bool] """ Client supports testing for validity of rename operations before execution. @since 3.12.0 """ prepareSupportDefaultBehavior: NotRequired["PrepareSupportDefaultBehavior"] """ Client supports the default behavior result. The value indicates the default behavior used by the client. @since 3.16.0 """ honorsChangeAnnotations: NotRequired[bool] """ Whether the client honors the change annotations in text edits and resource operations returned via the rename request's workspace edit by for example presenting the workspace edit in the user interface and asking for confirmation. @since 3.16.0 """ class FoldingRangeClientCapabilities(TypedDict): dynamicRegistration: NotRequired[bool] """ Whether implementation supports dynamic registration for folding range providers. If this is set to `true` the client supports the new `FoldingRangeRegistrationOptions` return value for the corresponding server capability as well. """ rangeLimit: NotRequired[Uint] """ The maximum number of folding ranges that the client prefers to receive per document. The value serves as a hint, servers are free to follow the limit. """ lineFoldingOnly: NotRequired[bool] """ If set, the client signals that it only supports folding complete lines. If set, client will ignore specified `startCharacter` and `endCharacter` properties in a FoldingRange. """ foldingRangeKind: NotRequired["__FoldingRangeClientCapabilities_foldingRangeKind_Type_1"] """ Specific options for the folding range kind. @since 3.17.0 """ foldingRange: NotRequired["__FoldingRangeClientCapabilities_foldingRange_Type_1"] """ Specific options for the folding range. @since 3.17.0 """ class SelectionRangeClientCapabilities(TypedDict): dynamicRegistration: NotRequired[bool] """ Whether implementation supports dynamic registration for selection range providers. If this is set to `true` the client supports the new `SelectionRangeRegistrationOptions` return value for the corresponding server capability as well. """ class PublishDiagnosticsClientCapabilities(TypedDict): """The publish diagnostic client capabilities.""" relatedInformation: NotRequired[bool] """ Whether the clients accepts diagnostics with related information. """ tagSupport: NotRequired["__PublishDiagnosticsClientCapabilities_tagSupport_Type_1"] """ Client supports the tag property to provide meta data about a diagnostic. Clients supporting tags have to handle unknown tags gracefully. @since 3.15.0 """ versionSupport: NotRequired[bool] """ Whether the client interprets the version property of the `textDocument/publishDiagnostics` notification's parameter. @since 3.15.0 """ codeDescriptionSupport: NotRequired[bool] """ Client supports a codeDescription property @since 3.16.0 """ dataSupport: NotRequired[bool] """ Whether code action supports the `data` property which is preserved between a `textDocument/publishDiagnostics` and `textDocument/codeAction` request. @since 3.16.0 """ class CallHierarchyClientCapabilities(TypedDict): """@since 3.16.0""" dynamicRegistration: NotRequired[bool] """ Whether implementation supports dynamic registration. If this is set to `true` the client supports the new `(TextDocumentRegistrationOptions & StaticRegistrationOptions)` return value for the corresponding server capability as well. """ class SemanticTokensClientCapabilities(TypedDict): """@since 3.16.0""" dynamicRegistration: NotRequired[bool] """ Whether implementation supports dynamic registration. If this is set to `true` the client supports the new `(TextDocumentRegistrationOptions & StaticRegistrationOptions)` return value for the corresponding server capability as well. """ requests: "__SemanticTokensClientCapabilities_requests_Type_1" """ Which requests the client supports and might send to the server depending on the server's capability. Please note that clients might not show semantic tokens or degrade some of the user experience if a range or full request is advertised by the client but not provided by the server. If for example the client capability `requests.full` and `request.range` are both set to true but the server only provides a range provider the client might not render a minimap correctly or might even decide to not show any semantic tokens at all. """ tokenTypes: list[str] """ The token types that the client supports. """ tokenModifiers: list[str] """ The token modifiers that the client supports. """ formats: list["TokenFormat"] """ The token formats the clients supports. """ overlappingTokenSupport: NotRequired[bool] """ Whether the client supports tokens that can overlap each other. """ multilineTokenSupport: NotRequired[bool] """ Whether the client supports tokens that can span multiple lines. """ serverCancelSupport: NotRequired[bool] """ Whether the client allows the server to actively cancel a semantic token request, e.g. supports returning LSPErrorCodes.ServerCancelled. If a server does the client needs to retrigger the request. @since 3.17.0 """ augmentsSyntaxTokens: NotRequired[bool] """ Whether the client uses semantic tokens to augment existing syntax tokens. If set to `true` client side created syntax tokens and semantic tokens are both used for colorization. If set to `false` the client only uses the returned semantic tokens for colorization. If the value is `undefined` then the client behavior is not specified. @since 3.17.0 """ class LinkedEditingRangeClientCapabilities(TypedDict): """Client capabilities for the linked editing range request. @since 3.16.0 """ dynamicRegistration: NotRequired[bool] """ Whether implementation supports dynamic registration. If this is set to `true` the client supports the new `(TextDocumentRegistrationOptions & StaticRegistrationOptions)` return value for the corresponding server capability as well. """ class MonikerClientCapabilities(TypedDict): """Client capabilities specific to the moniker request. @since 3.16.0 """ dynamicRegistration: NotRequired[bool] """ Whether moniker supports dynamic registration. If this is set to `true` the client supports the new `MonikerRegistrationOptions` return value for the corresponding server capability as well. """ class TypeHierarchyClientCapabilities(TypedDict): """@since 3.17.0""" dynamicRegistration: NotRequired[bool] """ Whether implementation supports dynamic registration. If this is set to `true` the client supports the new `(TextDocumentRegistrationOptions & StaticRegistrationOptions)` return value for the corresponding server capability as well. """ class InlineValueClientCapabilities(TypedDict): """Client capabilities specific to inline values. @since 3.17.0 """ dynamicRegistration: NotRequired[bool] """ Whether implementation supports dynamic registration for inline value providers. """ class InlayHintClientCapabilities(TypedDict): """Inlay hint client capabilities. @since 3.17.0 """ dynamicRegistration: NotRequired[bool] """ Whether inlay hints support dynamic registration. """ resolveSupport: NotRequired["__InlayHintClientCapabilities_resolveSupport_Type_1"] """ Indicates which properties a client can resolve lazily on an inlay hint. """ class DiagnosticClientCapabilities(TypedDict): """Client capabilities specific to diagnostic pull requests. @since 3.17.0 """ dynamicRegistration: NotRequired[bool] """ Whether implementation supports dynamic registration. If this is set to `true` the client supports the new `(TextDocumentRegistrationOptions & StaticRegistrationOptions)` return value for the corresponding server capability as well. """ relatedDocumentSupport: NotRequired[bool] """ Whether the clients supports related documents for document diagnostic pulls. """ class NotebookDocumentSyncClientCapabilities(TypedDict): """Notebook specific client capabilities. @since 3.17.0 """ dynamicRegistration: NotRequired[bool] """ Whether implementation supports dynamic registration. If this is set to `true` the client supports the new `(TextDocumentRegistrationOptions & StaticRegistrationOptions)` return value for the corresponding server capability as well. """ executionSummarySupport: NotRequired[bool] """ The client supports sending execution summary data per cell. """ class ShowMessageRequestClientCapabilities(TypedDict): """Show message request client capabilities""" messageActionItem: NotRequired["__ShowMessageRequestClientCapabilities_messageActionItem_Type_1"] """ Capabilities specific to the `MessageActionItem` type. """ class ShowDocumentClientCapabilities(TypedDict): """Client capabilities for the showDocument request. @since 3.16.0 """ support: bool """ The client has support for the showDocument request. """ class RegularExpressionsClientCapabilities(TypedDict): """Client capabilities specific to regular expressions. @since 3.16.0 """ engine: str """ The engine's name. """ version: NotRequired[str] """ The engine's version. """ class MarkdownClientCapabilities(TypedDict): """Client capabilities specific to the used markdown parser. @since 3.16.0 """ parser: str """ The name of the parser. """ version: NotRequired[str] """ The version of the parser. """ allowedTags: NotRequired[list[str]] """ A list of HTML tags that the client allows / supports in Markdown. @since 3.17.0 """ class __CodeActionClientCapabilities_codeActionLiteralSupport_Type_1(TypedDict): codeActionKind: "__CodeActionClientCapabilities_codeActionLiteralSupport_codeActionKind_Type_1" """ The code action kind is support with the following value set. """ class __CodeActionClientCapabilities_codeActionLiteralSupport_codeActionKind_Type_1(TypedDict): valueSet: list["CodeActionKind"] """ The code action kind values the client supports. When this property exists the client also guarantees that it will handle values outside its set gracefully and falls back to a default value when unknown. """ class __CodeActionClientCapabilities_resolveSupport_Type_1(TypedDict): properties: list[str] """ The properties that a client can resolve lazily. """ class __CodeAction_disabled_Type_1(TypedDict): reason: str """ Human readable description of why the code action is currently disabled. This is displayed in the code actions UI. """ class __CompletionClientCapabilities_completionItemKind_Type_1(TypedDict): valueSet: NotRequired[list["CompletionItemKind"]] """ The completion item kind values the client supports. When this property exists the client also guarantees that it will handle values outside its set gracefully and falls back to a default value when unknown. If this property is not present the client only supports the completion items kinds from `Text` to `Reference` as defined in the initial version of the protocol. """ class __CompletionClientCapabilities_completionItem_Type_1(TypedDict): snippetSupport: NotRequired[bool] """ Client supports snippets as insert text. A snippet can define tab stops and placeholders with `$1`, `$2` and `${3:foo}`. `$0` defines the final tab stop, it defaults to the end of the snippet. Placeholders with equal identifiers are linked, that is typing in one will update others too. """ commitCharactersSupport: NotRequired[bool] """ Client supports commit characters on a completion item. """ documentationFormat: NotRequired[list["MarkupKind"]] """ Client supports the following content formats for the documentation property. The order describes the preferred format of the client. """ deprecatedSupport: NotRequired[bool] """ Client supports the deprecated property on a completion item. """ preselectSupport: NotRequired[bool] """ Client supports the preselect property on a completion item. """ tagSupport: NotRequired["__CompletionClientCapabilities_completionItem_tagSupport_Type_1"] """ Client supports the tag property on a completion item. Clients supporting tags have to handle unknown tags gracefully. Clients especially need to preserve unknown tags when sending a completion item back to the server in a resolve call. @since 3.15.0 """ insertReplaceSupport: NotRequired[bool] """ Client support insert replace edit to control different behavior if a completion item is inserted in the text or should replace text. @since 3.16.0 """ resolveSupport: NotRequired["__CompletionClientCapabilities_completionItem_resolveSupport_Type_1"] """ Indicates which properties a client can resolve lazily on a completion item. Before version 3.16.0 only the predefined properties `documentation` and `details` could be resolved lazily. @since 3.16.0 """ insertTextModeSupport: NotRequired["__CompletionClientCapabilities_completionItem_insertTextModeSupport_Type_1"] """ The client supports the `insertTextMode` property on a completion item to override the whitespace handling mode as defined by the client (see `insertTextMode`). @since 3.16.0 """ labelDetailsSupport: NotRequired[bool] """ The client has support for completion item label details (see also `CompletionItemLabelDetails`). @since 3.17.0 """ class __CompletionClientCapabilities_completionItem_insertTextModeSupport_Type_1(TypedDict): valueSet: list["InsertTextMode"] class __CompletionClientCapabilities_completionItem_resolveSupport_Type_1(TypedDict): properties: list[str] """ The properties that a client can resolve lazily. """ class __CompletionClientCapabilities_completionItem_tagSupport_Type_1(TypedDict): valueSet: list["CompletionItemTag"] """ The tags supported by the client. """ class __CompletionClientCapabilities_completionList_Type_1(TypedDict): itemDefaults: NotRequired[list[str]] """ The client supports the following itemDefaults on a completion list. The value lists the supported property names of the `CompletionList.itemDefaults` object. If omitted no properties are supported. @since 3.17.0 """ class __CompletionList_itemDefaults_Type_1(TypedDict): commitCharacters: NotRequired[list[str]] """ A default commit character set. @since 3.17.0 """ editRange: NotRequired[Union["Range", "__CompletionList_itemDefaults_editRange_Type_1"]] """ A default edit range. @since 3.17.0 """ insertTextFormat: NotRequired["InsertTextFormat"] """ A default insert text format. @since 3.17.0 """ insertTextMode: NotRequired["InsertTextMode"] """ A default insert text mode. @since 3.17.0 """ data: NotRequired["LSPAny"] """ A default data value. @since 3.17.0 """ class __CompletionList_itemDefaults_editRange_Type_1(TypedDict): insert: "Range" replace: "Range" class __CompletionOptions_completionItem_Type_1(TypedDict): labelDetailsSupport: NotRequired[bool] """ The server has support for completion item label details (see also `CompletionItemLabelDetails`) when receiving a completion item in a resolve call. @since 3.17.0 """ class __CompletionOptions_completionItem_Type_2(TypedDict): labelDetailsSupport: NotRequired[bool] """ The server has support for completion item label details (see also `CompletionItemLabelDetails`) when receiving a completion item in a resolve call. @since 3.17.0 """ class __DocumentSymbolClientCapabilities_symbolKind_Type_1(TypedDict): valueSet: NotRequired[list["SymbolKind"]] """ The symbol kind values the client supports. When this property exists the client also guarantees that it will handle values outside its set gracefully and falls back to a default value when unknown. If this property is not present the client only supports the symbol kinds from `File` to `Array` as defined in the initial version of the protocol. """ class __DocumentSymbolClientCapabilities_tagSupport_Type_1(TypedDict): valueSet: list["SymbolTag"] """ The tags supported by the client. """ class __FoldingRangeClientCapabilities_foldingRangeKind_Type_1(TypedDict): valueSet: NotRequired[list["FoldingRangeKind"]] """ The folding range kind values the client supports. When this property exists the client also guarantees that it will handle values outside its set gracefully and falls back to a default value when unknown. """ class __FoldingRangeClientCapabilities_foldingRange_Type_1(TypedDict): collapsedText: NotRequired[bool] """ If set, the client signals that it supports setting collapsedText on folding ranges to display custom labels instead of the default text. @since 3.17.0 """ class __GeneralClientCapabilities_staleRequestSupport_Type_1(TypedDict): cancel: bool """ The client will actively cancel the request. """ retryOnContentModified: list[str] """ The list of requests for which the client will retry the request if it receives a response with error code `ContentModified` """ class __InitializeResult_serverInfo_Type_1(TypedDict): name: str """ The name of the server as defined by the server. """ version: NotRequired[str] """ The server's version as defined by the server. """ class __InlayHintClientCapabilities_resolveSupport_Type_1(TypedDict): properties: list[str] """ The properties that a client can resolve lazily. """ class __MarkedString_Type_1(TypedDict): language: str value: str class __NotebookDocumentChangeEvent_cells_Type_1(TypedDict): structure: NotRequired["__NotebookDocumentChangeEvent_cells_structure_Type_1"] """ Changes to the cell structure to add or remove cells. """ data: NotRequired[list["NotebookCell"]] """ Changes to notebook cells properties like its kind, execution summary or metadata. """ textContent: NotRequired[list["__NotebookDocumentChangeEvent_cells_textContent_Type_1"]] """ Changes to the text content of notebook cells. """ class __NotebookDocumentChangeEvent_cells_structure_Type_1(TypedDict): array: "NotebookCellArrayChange" """ The change to the cell array. """ didOpen: NotRequired[list["TextDocumentItem"]] """ Additional opened cell text documents. """ didClose: NotRequired[list["TextDocumentIdentifier"]] """ Additional closed cell text documents. """ class __NotebookDocumentChangeEvent_cells_textContent_Type_1(TypedDict): document: "VersionedTextDocumentIdentifier" changes: list["TextDocumentContentChangeEvent"] class __NotebookDocumentFilter_Type_1(TypedDict): notebookType: str """ The type of the enclosing notebook. """ scheme: NotRequired[str] """ A Uri {@link Uri.scheme scheme}, like `file` or `untitled`. """ pattern: NotRequired[str] """ A glob pattern. """ class __NotebookDocumentFilter_Type_2(TypedDict): notebookType: NotRequired[str] """ The type of the enclosing notebook. """ scheme: str """ A Uri {@link Uri.scheme scheme}, like `file` or `untitled`. """ pattern: NotRequired[str] """ A glob pattern. """ class __NotebookDocumentFilter_Type_3(TypedDict): notebookType: NotRequired[str] """ The type of the enclosing notebook. """ scheme: NotRequired[str] """ A Uri {@link Uri.scheme scheme}, like `file` or `untitled`. """ pattern: str """ A glob pattern. """ class __NotebookDocumentSyncOptions_notebookSelector_Type_1(TypedDict): notebook: Union[str, "NotebookDocumentFilter"] """ The notebook to be synced If a string value is provided it matches against the notebook type. '*' matches every notebook. """ cells: NotRequired[list["__NotebookDocumentSyncOptions_notebookSelector_cells_Type_1"]] """ The cells of the matching notebook to be synced. """ class __NotebookDocumentSyncOptions_notebookSelector_Type_2(TypedDict): notebook: NotRequired[Union[str, "NotebookDocumentFilter"]] """ The notebook to be synced If a string value is provided it matches against the notebook type. '*' matches every notebook. """ cells: list["__NotebookDocumentSyncOptions_notebookSelector_cells_Type_2"] """ The cells of the matching notebook to be synced. """ class __NotebookDocumentSyncOptions_notebookSelector_Type_3(TypedDict): notebook: Union[str, "NotebookDocumentFilter"] """ The notebook to be synced If a string value is provided it matches against the notebook type. '*' matches every notebook. """ cells: NotRequired[list["__NotebookDocumentSyncOptions_notebookSelector_cells_Type_3"]] """ The cells of the matching notebook to be synced. """ class __NotebookDocumentSyncOptions_notebookSelector_Type_4(TypedDict): notebook: NotRequired[Union[str, "NotebookDocumentFilter"]] """ The notebook to be synced If a string value is provided it matches against the notebook type. '*' matches every notebook. """ cells: list["__NotebookDocumentSyncOptions_notebookSelector_cells_Type_4"] """ The cells of the matching notebook to be synced. """ class __NotebookDocumentSyncOptions_notebookSelector_cells_Type_1(TypedDict): language: str class __NotebookDocumentSyncOptions_notebookSelector_cells_Type_2(TypedDict): language: str class __NotebookDocumentSyncOptions_notebookSelector_cells_Type_3(TypedDict): language: str class __NotebookDocumentSyncOptions_notebookSelector_cells_Type_4(TypedDict): language: str class __PrepareRenameResult_Type_1(TypedDict): range: "Range" placeholder: str class __PrepareRenameResult_Type_2(TypedDict): defaultBehavior: bool class __PublishDiagnosticsClientCapabilities_tagSupport_Type_1(TypedDict): valueSet: list["DiagnosticTag"] """ The tags supported by the client. """ class __SemanticTokensClientCapabilities_requests_Type_1(TypedDict): range: NotRequired[bool | dict] """ The client will send the `textDocument/semanticTokens/range` request if the server provides a corresponding handler. """ full: NotRequired[Union[bool, "__SemanticTokensClientCapabilities_requests_full_Type_1"]] """ The client will send the `textDocument/semanticTokens/full` request if the server provides a corresponding handler. """ class __SemanticTokensClientCapabilities_requests_full_Type_1(TypedDict): delta: NotRequired[bool] """ The client will send the `textDocument/semanticTokens/full/delta` request if the server provides a corresponding handler. """ class __SemanticTokensOptions_full_Type_1(TypedDict): delta: NotRequired[bool] """ The server supports deltas for full documents. """ class __SemanticTokensOptions_full_Type_2(TypedDict): delta: NotRequired[bool] """ The server supports deltas for full documents. """ class __ServerCapabilities_workspace_Type_1(TypedDict): workspaceFolders: NotRequired["WorkspaceFoldersServerCapabilities"] """ The server supports workspace folder. @since 3.6.0 """ fileOperations: NotRequired["FileOperationOptions"] """ The server is interested in notifications/requests for operations on files. @since 3.16.0 """ class __ShowMessageRequestClientCapabilities_messageActionItem_Type_1(TypedDict): additionalPropertiesSupport: NotRequired[bool] """ Whether the client supports additional attributes which are preserved and send back to the server in the request's response. """ class __SignatureHelpClientCapabilities_signatureInformation_Type_1(TypedDict): documentationFormat: NotRequired[list["MarkupKind"]] """ Client supports the following content formats for the documentation property. The order describes the preferred format of the client. """ parameterInformation: NotRequired["__SignatureHelpClientCapabilities_signatureInformation_parameterInformation_Type_1"] """ Client capabilities specific to parameter information. """ activeParameterSupport: NotRequired[bool] """ The client supports the `activeParameter` property on `SignatureInformation` literal. @since 3.16.0 """ class __SignatureHelpClientCapabilities_signatureInformation_parameterInformation_Type_1(TypedDict): labelOffsetSupport: NotRequired[bool] """ The client supports processing label offsets instead of a simple label string. @since 3.14.0 """ class __TextDocumentContentChangeEvent_Type_1(TypedDict): range: "Range" """ The range of the document that changed. """ rangeLength: NotRequired[Uint] """ The optional length of the range that got replaced. @deprecated use range instead. """ text: str """ The new text for the provided range. """ class __TextDocumentContentChangeEvent_Type_2(TypedDict): text: str """ The new text of the whole document. """ class __TextDocumentFilter_Type_1(TypedDict): language: str """ A language id, like `typescript`. """ scheme: NotRequired[str] """ A Uri {@link Uri.scheme scheme}, like `file` or `untitled`. """ pattern: NotRequired[str] """ A glob pattern, like `*.{ts,js}`. """ class __TextDocumentFilter_Type_2(TypedDict): language: NotRequired[str] """ A language id, like `typescript`. """ scheme: str """ A Uri {@link Uri.scheme scheme}, like `file` or `untitled`. """ pattern: NotRequired[str] """ A glob pattern, like `*.{ts,js}`. """ class __TextDocumentFilter_Type_3(TypedDict): language: NotRequired[str] """ A language id, like `typescript`. """ scheme: NotRequired[str] """ A Uri {@link Uri.scheme scheme}, like `file` or `untitled`. """ pattern: str """ A glob pattern, like `*.{ts,js}`. """ class __WorkspaceEditClientCapabilities_changeAnnotationSupport_Type_1(TypedDict): groupsOnLabel: NotRequired[bool] """ Whether the client groups edits with equal labels into tree nodes, for instance all edits labelled with "Changes in Strings" would be a tree node. """ class __WorkspaceSymbolClientCapabilities_resolveSupport_Type_1(TypedDict): properties: list[str] """ The properties that a client can resolve lazily. Usually `location.range` """ class __WorkspaceSymbolClientCapabilities_symbolKind_Type_1(TypedDict): valueSet: NotRequired[list["SymbolKind"]] """ The symbol kind values the client supports. When this property exists the client also guarantees that it will handle values outside its set gracefully and falls back to a default value when unknown. If this property is not present the client only supports the symbol kinds from `File` to `Array` as defined in the initial version of the protocol. """ class __WorkspaceSymbolClientCapabilities_tagSupport_Type_1(TypedDict): valueSet: list["SymbolTag"] """ The tags supported by the client. """ class __WorkspaceSymbol_location_Type_1(TypedDict): uri: "DocumentUri" class ___InitializeParams_clientInfo_Type_1(TypedDict): name: str """ The name of the client as defined by the client. """ version: NotRequired[str] """ The client's version as defined by the client. """ ================================================ FILE: src/solidlsp/lsp_protocol_handler/server.py ================================================ """ This file provides the implementation of the JSON-RPC client, that launches and communicates with the language server. The initial implementation of this file was obtained from https://github.com/predragnikolic/OLSP under the MIT License with the following terms: MIT License Copyright (c) 2023 Предраг Николић Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ import dataclasses import json import logging import os from typing import Any, Union from .lsp_types import ErrorCodes StringDict = dict[str, Any] PayloadLike = Union[list[StringDict], StringDict, None, bool] CONTENT_LENGTH = "Content-Length: " ENCODING = "utf-8" log = logging.getLogger(__name__) @dataclasses.dataclass class ProcessLaunchInfo: """ This class is used to store the information required to launch a (language server) process. """ cmd: str | list[str] """ the command used to launch the process. Specification as a list is preferred (as it is more robust and avoids incorrect quoting of arguments); the string variant is supported for backward compatibility only """ env: dict[str, str] = dataclasses.field(default_factory=dict) """ the environment variables to set for the process """ cwd: str = os.getcwd() """ the working directory for the process """ class LSPError(Exception): def __init__(self, code: ErrorCodes, message: str) -> None: super().__init__(message) self.code = code def to_lsp(self) -> StringDict: return {"code": self.code, "message": super().__str__()} @classmethod def from_lsp(cls, d: StringDict) -> "LSPError": return LSPError(d["code"], d["message"]) def __str__(self) -> str: return f"{super().__str__()} ({self.code})" def make_response(request_id: Any, params: PayloadLike) -> StringDict: return {"jsonrpc": "2.0", "id": request_id, "result": params} def make_error_response(request_id: Any, err: LSPError) -> StringDict: return {"jsonrpc": "2.0", "id": request_id, "error": err.to_lsp()} # LSP methods that expect NO params field at all (not even empty object). # These methods use Void/unit type in their protocol definition. # - shutdown: HLS uses Haskell's Void type, rust-analyzer expects unit # - exit: Similar - notification with no params # Sending params:{} to these methods causes parse errors like "Cannot parse Void" # See: https://www.jsonrpc.org/specification ("params MAY be omitted") _NO_PARAMS_METHODS = frozenset({"shutdown", "exit"}) def _build_params_field(method: str, params: PayloadLike) -> StringDict: """Build the params portion of a JSON-RPC message based on LSP method requirements. LSP methods with Void/unit type (shutdown, exit) must omit params field entirely to satisfy HLS and rust-analyzer. Other methods send empty {} for None params to maintain Delphi/FPC LSP compatibility (PR #851). Returns a dict that can be merged into the message using ** unpacking. """ if method in _NO_PARAMS_METHODS: return {} # Omit params entirely for Void-type methods elif params is not None: return {"params": params} else: return {"params": {}} # Keep {} for Delphi/FPC compatibility def make_notification(method: str, params: PayloadLike) -> StringDict: """Create a JSON-RPC 2.0 notification message.""" return {"jsonrpc": "2.0", "method": method, **_build_params_field(method, params)} def make_request(method: str, request_id: Any, params: PayloadLike) -> StringDict: """Create a JSON-RPC 2.0 request message.""" return {"jsonrpc": "2.0", "method": method, "id": request_id, **_build_params_field(method, params)} class StopLoopException(Exception): pass def create_message(payload: PayloadLike) -> tuple[bytes, bytes, bytes]: body = json.dumps(payload, check_circular=False, ensure_ascii=False, separators=(",", ":")).encode(ENCODING) return ( f"Content-Length: {len(body)}\r\n".encode(ENCODING), "Content-Type: application/vscode-jsonrpc; charset=utf-8\r\n\r\n".encode(ENCODING), body, ) class MessageType: error = 1 warning = 2 info = 3 log = 4 def content_length(line: bytes) -> int | None: if line.startswith(b"Content-Length: "): _, value = line.split(b"Content-Length: ") value = value.strip() try: return int(value) except ValueError: raise ValueError(f"Invalid Content-Length header: {value!r}") return None ================================================ FILE: src/solidlsp/settings.py ================================================ """ Defines settings for Solid-LSP """ import logging import os import pathlib from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any from sensai.util.string import ToStringMixin if TYPE_CHECKING: from solidlsp.ls_config import Language log = logging.getLogger(__name__) @dataclass class SolidLSPSettings: solidlsp_dir: str = str(pathlib.Path.home() / ".solidlsp") """ Path to the directory in which to store global Solid-LSP data (which is not project-specific) """ project_data_path: str = "" """ Absolute path to a directory where Solid-LSP can store project-specific data, e.g. cache files. For instance, if this is "/home/user/myproject/.solidlsp", then Solid-LSP will store project-specific data (e.g. caches) in that directory. """ ls_specific_settings: dict["Language", dict[str, Any]] = field(default_factory=dict) """ Advanced configuration option allowing to configure language server implementation specific options. Have a look at the docstring of the constructors of the corresponding LS implementations within solidlsp to see which options are available. No documentation on options means no options are available. """ def __post_init__(self) -> None: os.makedirs(str(self.solidlsp_dir), exist_ok=True) os.makedirs(str(self.ls_resources_dir), exist_ok=True) @property def ls_resources_dir(self) -> str: return os.path.join(str(self.solidlsp_dir), "language_servers", "static") class CustomLSSettings(ToStringMixin): def __init__(self, settings: dict[str, Any] | None) -> None: self.settings = settings or {} def get(self, key: str, default_value: Any = None) -> Any: """ Returns the custom setting for the given key or the default value if not set. If a custom value is set for the given key, the retrieval is logged. :param key: the key :param default_value: the default value to use if no custom value is set :return: the value """ if key in self.settings: value = self.settings[key] log.info("Using custom LS setting %s for key '%s'", value, key) else: value = default_value return value def get_ls_specific_settings(self, language: "Language") -> CustomLSSettings: """ Get the language server specific settings for the given language. :param language: The programming language. :return: A dictionary of settings for the language server. """ return self.CustomLSSettings(self.ls_specific_settings.get(language)) ================================================ FILE: src/solidlsp/util/cache.py ================================================ import logging from typing import Any, Optional from sensai.util.pickle import dump_pickle, load_pickle log = logging.getLogger(__name__) def load_cache(path: str, version: Any) -> Optional[Any]: data = load_pickle(path) if not isinstance(data, dict) or "__cache_version" not in data: log.info("Cache is outdated (expected version %s). Ignoring cache at %s", version, path) return None saved_version = data["__cache_version"] if saved_version != version: log.info("Cache is outdated (expected version %s, got %s). Ignoring cache at %s", version, saved_version, path) return None return data["obj"] def save_cache(path: str, version: Any, obj: Any) -> None: data = {"__cache_version": version, "obj": obj} dump_pickle(data, path) ================================================ FILE: src/solidlsp/util/metals_db_utils.py ================================================ """ Utilities for detecting and managing Scala Metals H2 database state. This module provides functions to detect existing Metals LSP instances by checking the H2 database lock file, and to clean up stale locks from crashed processes. Metals uses H2 AUTO_SERVER mode (enabled by default) to support multiple concurrent instances sharing the same database. However, if a Metals process crashes without proper cleanup, it can leave a stale lock file that prevents proper AUTO_SERVER coordination, causing new instances to fall back to in-memory database mode. """ from __future__ import annotations import logging import re from dataclasses import dataclass from enum import Enum from pathlib import Path from typing import TYPE_CHECKING if TYPE_CHECKING: pass log = logging.getLogger(__name__) class MetalsDbStatus(Enum): """Status of the Metals H2 database for a project.""" NO_DATABASE = "no_database" """No .metals directory or database exists (fresh project).""" NO_LOCK = "no_lock" """Database exists but no lock file (safe to start).""" ACTIVE_INSTANCE = "active_instance" """Lock held by a running process (will share via AUTO_SERVER).""" STALE_LOCK = "stale_lock" """Lock held by a dead process (needs cleanup).""" @dataclass class MetalsLockInfo: """Information extracted from an H2 database lock file.""" pid: int | None """Process ID that holds the lock, if parseable.""" port: int | None """TCP port for AUTO_SERVER connection, if parseable.""" lock_path: Path """Path to the lock file.""" is_stale: bool """True if the owning process is no longer running.""" raw_content: str """Raw content of the lock file for debugging.""" def parse_h2_lock_file(lock_path: Path) -> MetalsLockInfo | None: """ Parse an H2 database lock file to extract connection information. The H2 lock file format varies by version but typically contains server connection information. Common formats include: - Text format: "server:localhost:9092" or similar - Binary format with embedded PID Args: lock_path: Path to the .lock.db file Returns: MetalsLockInfo if the file can be parsed, None if file doesn't exist or is completely unparsable. """ if not lock_path.exists(): return None try: # Try reading as text first (most common for H2 AUTO_SERVER) content = lock_path.read_text(encoding="utf-8", errors="replace") except OSError as e: log.debug(f"Could not read lock file {lock_path}: {e}") return None pid: int | None = None port: int | None = None # Try to extract port from common H2 lock file formats # Format 1: "server:localhost:PORT" server_match = re.search(r"server:[\w.]+:(\d+)", content, re.IGNORECASE) if server_match: port = int(server_match.group(1)) # Format 2: Look for standalone port numbers (H2 uses ports in 9000+ range typically) if port is None: port_match = re.search(r"\b(9\d{3})\b", content) if port_match: port = int(port_match.group(1)) # Try to extract PID - H2 may embed this in various formats pid_match = re.search(r"pid[=:]?\s*(\d+)", content, re.IGNORECASE) if pid_match: pid = int(pid_match.group(1)) # Check if the process is still alive is_stale = False if pid is not None: is_stale = not is_metals_process_alive(pid) elif port is not None: # If we have a port but no PID, try to find a Metals process using that port is_stale = not _is_port_in_use_by_metals(port) else: # Can't determine - assume stale if lock exists but we can't parse it # and no Metals processes are running for this project log.debug(f"Could not parse PID or port from lock file: {lock_path}") is_stale = True # Conservative: treat unparsable as stale return MetalsLockInfo( pid=pid, port=port, lock_path=lock_path, is_stale=is_stale, raw_content=content[:200], # Truncate for logging ) def is_metals_process_alive(pid: int) -> bool: """ Check if a process with the given PID is alive and is a Metals process. Args: pid: Process ID to check Returns: True if the process exists and appears to be a Metals LSP server. """ try: import psutil proc = psutil.Process(pid) if not proc.is_running(): return False # Check if this is actually a Metals process cmdline = " ".join(proc.cmdline()).lower() return _is_metals_cmdline(cmdline) except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): return False except Exception as e: log.debug(f"Error checking process {pid}: {e}") return False def _is_metals_cmdline(cmdline: str) -> bool: """Check if a command line string appears to be a Metals LSP server.""" cmdline_lower = cmdline.lower() # Metals is a Scala/Java application if "java" not in cmdline_lower: return False # Look for Metals-specific identifiers return any( marker in cmdline_lower for marker in [ "metals", "org.scalameta", "-dmetals.client", ] ) def _is_port_in_use_by_metals(port: int) -> bool: """Check if the given port is in use by a Metals process.""" try: import psutil for conn in psutil.net_connections(kind="tcp"): if conn.laddr.port == port and conn.status == "LISTEN": try: proc = psutil.Process(conn.pid) cmdline = " ".join(proc.cmdline()).lower() if _is_metals_cmdline(cmdline): return True except (psutil.NoSuchProcess, psutil.AccessDenied): pass return False except (psutil.AccessDenied, OSError) as e: # On some systems, net_connections requires elevated privileges log.debug(f"Could not check port {port}: {e}") return False def check_metals_db_status(project_path: Path) -> tuple[MetalsDbStatus, MetalsLockInfo | None]: """ Check the status of the Metals H2 database for a project. This function determines whether it's safe to start a new Metals instance and whether any cleanup is needed. Args: project_path: Path to the project root directory Returns: A tuple of (status, lock_info) where lock_info is populated for ACTIVE_INSTANCE and STALE_LOCK statuses. """ metals_dir = project_path / ".metals" db_path = metals_dir / "metals.mv.db" lock_path = metals_dir / "metals.mv.db.lock.db" if not metals_dir.exists(): log.debug(f"No .metals directory found at {metals_dir}") return MetalsDbStatus.NO_DATABASE, None if not db_path.exists(): log.debug(f"No Metals database found at {db_path}") return MetalsDbStatus.NO_DATABASE, None if not lock_path.exists(): log.debug(f"Metals database exists but no lock file at {lock_path}") return MetalsDbStatus.NO_LOCK, None # Lock file exists - parse it to determine status lock_info = parse_h2_lock_file(lock_path) if lock_info is None: # Lock file exists but couldn't be read - treat as stale log.warning(f"Could not read lock file at {lock_path}, treating as stale") return MetalsDbStatus.STALE_LOCK, MetalsLockInfo( pid=None, port=None, lock_path=lock_path, is_stale=True, raw_content="", ) if lock_info.is_stale: log.debug(f"Stale Metals lock detected: {lock_info}") return MetalsDbStatus.STALE_LOCK, lock_info else: log.debug(f"Active Metals instance detected: {lock_info}") return MetalsDbStatus.ACTIVE_INSTANCE, lock_info def cleanup_stale_lock(lock_path: Path) -> bool: """ Remove a stale H2 database lock file. This should only be called when we've verified the owning process is dead. Removing a lock file from a running process could cause database corruption. Args: lock_path: Path to the .lock.db file to remove Returns: True if cleanup succeeded, False otherwise. """ if not lock_path.exists(): log.debug(f"Lock file already removed: {lock_path}") return True try: lock_path.unlink() log.info(f"Cleaned up stale Metals lock file: {lock_path}") return True except PermissionError as e: log.warning(f"Permission denied removing stale lock file {lock_path}: {e}") return False except OSError as e: log.warning(f"Could not remove stale lock file {lock_path}: {e}") return False ================================================ FILE: src/solidlsp/util/subprocess_util.py ================================================ import platform import subprocess def subprocess_kwargs() -> dict: """ Returns a dictionary of keyword arguments for subprocess calls, adding platform-specific flags that we want to use consistently. """ kwargs = {} if platform.system() == "Windows": kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW # type: ignore return kwargs def quote_arg(arg: str) -> str: """ Adds quotes around an argument if it contains spaces. """ if " " not in arg: return arg return f'"{arg}"' ================================================ FILE: src/solidlsp/util/zip.py ================================================ import fnmatch import logging import os import sys import zipfile from pathlib import Path from typing import Optional log = logging.getLogger(__name__) class SafeZipExtractor: """ A utility class for extracting ZIP archives safely. Features: - Handles long file paths on Windows - Skips files that fail to extract, continuing with the rest - Creates necessary directories automatically - Optional include/exclude pattern filters """ def __init__( self, archive_path: Path, extract_dir: Path, verbose: bool = True, include_patterns: Optional[list[str]] = None, exclude_patterns: Optional[list[str]] = None, ) -> None: """ Initialize the SafeZipExtractor. :param archive_path: Path to the ZIP archive file :param extract_dir: Directory where files will be extracted :param verbose: Whether to log status messages :param include_patterns: List of glob patterns for files to extract (None = all files) :param exclude_patterns: List of glob patterns for files to skip """ self.archive_path = Path(archive_path) self.extract_dir = Path(extract_dir) self.verbose = verbose self.include_patterns = include_patterns or [] self.exclude_patterns = exclude_patterns or [] def extract_all(self) -> None: """ Extract all files from the archive, skipping any that fail. """ if not self.archive_path.exists(): raise FileNotFoundError(f"Archive not found: {self.archive_path}") if self.verbose: log.info(f"Extracting from: {self.archive_path} to {self.extract_dir}") with zipfile.ZipFile(self.archive_path, "r") as zip_ref: for member in zip_ref.infolist(): if self._should_extract(member.filename): self._extract_member(zip_ref, member) elif self.verbose: log.info(f"Skipped: {member.filename}") def _should_extract(self, filename: str) -> bool: """ Determine whether a file should be extracted based on include/exclude patterns. :param filename: The file name from the archive :return: True if the file should be extracted """ # If include_patterns is set, only extract if it matches at least one pattern if self.include_patterns: if not any(fnmatch.fnmatch(filename, pattern) for pattern in self.include_patterns): return False # If exclude_patterns is set, skip if it matches any pattern if self.exclude_patterns: if any(fnmatch.fnmatch(filename, pattern) for pattern in self.exclude_patterns): return False return True def _extract_member(self, zip_ref: zipfile.ZipFile, member: zipfile.ZipInfo) -> None: """ Extract a single member from the archive with error handling. :param zip_ref: Open ZipFile object :param member: ZipInfo object representing the file """ try: target_path = self.extract_dir / member.filename # Ensure directory structure exists target_path.parent.mkdir(parents=True, exist_ok=True) # Handle long paths on Windows final_path = self._normalize_path(target_path) # Extract file with zip_ref.open(member) as source, open(final_path, "wb") as target: target.write(source.read()) if self.verbose: log.info(f"Extracted: {member.filename}") except Exception as e: log.error(f"Failed to extract {member.filename}: {e}") @staticmethod def _normalize_path(path: Path) -> Path: """ Adjust path to handle long paths on Windows. :param path: Original path :return: Normalized path """ if sys.platform.startswith("win"): return Path(rf"\\?\{os.path.abspath(path)}") return path # type: ignore # Example usage: # extractor = SafeZipExtractor( # archive_path=Path("file.nupkg"), # extract_dir=Path("extract_dir"), # include_patterns=["*.dll", "*.xml"], # exclude_patterns=["*.pdb"] # ) # extractor.extract_all() ================================================ FILE: sync.py ================================================ import os from repo_dir_sync import LibRepo, OtherRepo r = LibRepo(name="serena", libDirectory="src") r.add(OtherRepo(name="mux", branch="mux", pathToLib=os.path.join("..", "serena-multiplexer", "src-serena"))) r.runMain() ================================================ FILE: test/__init__.py ================================================ ================================================ FILE: test/conftest.py ================================================ import logging import os import platform import shutil as _sh from collections.abc import Iterator from contextlib import contextmanager from pathlib import Path from typing import Any import pytest from sensai.util.logging import configure from serena.config.serena_config import SerenaConfig, SerenaPaths from serena.constants import SERENA_MANAGED_DIR_NAME from serena.project import Project from serena.util.file_system import GitignoreParser from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.settings import SolidLSPSettings from .solidlsp.clojure import is_clojure_cli_available configure(level=logging.INFO) log = logging.getLogger(__name__) @pytest.fixture(scope="session") def resources_dir() -> Path: """Path to the test resources directory.""" current_dir = Path(__file__).parent return current_dir / "resources" class LanguageParamRequest: param: Language _LANGUAGE_REPO_ALIASES: dict[Language, Language] = { Language.CPP_CCLS: Language.CPP, Language.PHP_PHPACTOR: Language.PHP, Language.PYTHON_JEDI: Language.PYTHON, Language.RUBY_SOLARGRAPH: Language.RUBY, } def get_repo_path(language: Language) -> Path: repo_language = _LANGUAGE_REPO_ALIASES.get(language, language) return Path(__file__).parent / "resources" / "repos" / repo_language / "test_repo" def _create_ls( language: Language, repo_path: str | None = None, ignored_paths: list[str] | None = None, trace_lsp_communication: bool = False, ls_specific_settings: dict[Language, dict[str, Any]] | None = None, solidlsp_dir: Path | None = None, ) -> SolidLanguageServer: ignored_paths = ignored_paths or [] if repo_path is None: repo_path = str(get_repo_path(language)) gitignore_parser = GitignoreParser(str(repo_path)) for spec in gitignore_parser.get_ignore_specs(): ignored_paths.extend(spec.patterns) config = LanguageServerConfig( code_language=language, ignored_paths=ignored_paths, trace_lsp_communication=trace_lsp_communication, ) effective_solidlsp_dir = solidlsp_dir if solidlsp_dir is not None else SerenaPaths().serena_user_home_dir project_data_path = os.path.join(repo_path, SERENA_MANAGED_DIR_NAME) return SolidLanguageServer.create( config, repo_path, solidlsp_settings=SolidLSPSettings( solidlsp_dir=effective_solidlsp_dir, project_data_path=project_data_path, ls_specific_settings=ls_specific_settings or {}, ), ) @contextmanager def start_ls_context( language: Language, repo_path: str | None = None, ignored_paths: list[str] | None = None, trace_lsp_communication: bool = False, ls_specific_settings: dict[Language, dict[str, Any]] | None = None, solidlsp_dir: Path | None = None, ) -> Iterator[SolidLanguageServer]: ls = _create_ls(language, repo_path, ignored_paths, trace_lsp_communication, ls_specific_settings, solidlsp_dir) log.info(f"Starting language server for {language} {repo_path}") ls.start() try: log.info(f"Language server started for {language} {repo_path}") yield ls finally: log.info(f"Stopping language server for {language} {repo_path}") try: ls.stop(shutdown_timeout=5) except Exception as e: log.warning(f"Warning: Error stopping language server: {e}") # try to force cleanup if hasattr(ls, "server") and hasattr(ls.server, "process"): try: ls.server.process.terminate() except: pass @contextmanager def start_default_ls_context(language: Language) -> Iterator[SolidLanguageServer]: with start_ls_context(language) as ls: yield ls def create_default_serena_config(): return SerenaConfig(gui_log_window=False, web_dashboard=False) def _create_default_project(language: Language, repo_root_override: str | None = None) -> Project: repo_path = str(get_repo_path(language)) if repo_root_override is None else repo_root_override return Project.load(repo_path, serena_config=create_default_serena_config()) @pytest.fixture(scope="session") def repo_path(request: LanguageParamRequest) -> Path: """Get the repository path for a specific language. This fixture requires a language parameter via pytest.mark.parametrize: Example: ``` @pytest.mark.parametrize("repo_path", [Language.PYTHON], indirect=True) def test_python_repo(repo_path): assert (repo_path / "src").exists() ``` """ if not hasattr(request, "param"): raise ValueError("Language parameter must be provided via pytest.mark.parametrize") language = request.param return get_repo_path(language) # Note: using module scope here to avoid restarting LS for each test function but still terminate between test modules @pytest.fixture(scope="module") def language_server(request: LanguageParamRequest): """Create a language server instance configured for the specified language. This fixture requires a language parameter via pytest.mark.parametrize: Example: ``` @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) def test_python_server(language_server: SyncLanguageServer) -> None: # Use the Python language server pass ``` You can also test multiple languages in a single test: ``` @pytest.mark.parametrize("language_server", [Language.PYTHON, Language.TYPESCRIPT], indirect=True) def test_multiple_languages(language_server: SyncLanguageServer) -> None: # This test will run once for each language pass ``` """ if not hasattr(request, "param"): raise ValueError("Language parameter must be provided via pytest.mark.parametrize") language = request.param with start_default_ls_context(language) as ls: yield ls @contextmanager def project_context(language: Language, repo_root_override: str | None = None) -> Iterator[Project]: """Context manager that creates a Project for the specified language and ensures proper cleanup.""" project = _create_default_project(language, repo_root_override) try: yield project finally: project.shutdown(timeout=5) @pytest.fixture(scope="module") def project(request: LanguageParamRequest, repo_root_override: str | None = None) -> Iterator[Project]: """Create a Project for the specified language. This fixture requires a language parameter via pytest.mark.parametrize: Example: ``` @pytest.mark.parametrize("project", [Language.PYTHON], indirect=True) def test_python_project(project: Project) -> None: # Use the Python project to test something pass ``` You can also test multiple languages in a single test: ``` @pytest.mark.parametrize("project", [Language.PYTHON, Language.TYPESCRIPT], indirect=True) def test_multiple_languages(project: SyncLanguageServer) -> None: # This test will run once for each language pass ``` """ if not hasattr(request, "param"): raise ValueError("Language parameter must be provided via pytest.mark.parametrize") language = request.param with project_context(language, repo_root_override) as project: yield project @contextmanager def project_with_ls_context(language: Language, repo_root_override: str | None = None) -> Iterator[Project]: """Context manager that creates a Project with an active language server for the specified language.""" with project_context(language, repo_root_override) as project: project.create_language_server_manager() yield project @pytest.fixture(scope="module") def project_with_ls(request: LanguageParamRequest) -> Iterator[Project]: if not hasattr(request, "param"): raise ValueError("Language parameter must be provided via pytest.mark.parametrize") language = request.param with project_with_ls_context(language) as project: yield project is_ci = os.getenv("CI") == "true" or os.getenv("GITHUB_ACTIONS") == "true" """ Flag indicating whether the tests are running in the GitHub CI environment. """ is_windows = platform.system() == "Windows" def _determine_disabled_languages() -> list[Language]: """ Determine which language tests should be disabled (based on the environment) :return: the list of disabled languages """ result: list[Language] = [] java_tests_enabled = True if not java_tests_enabled: result.append(Language.JAVA) clojure_tests_enabled = is_clojure_cli_available() if not clojure_tests_enabled: result.append(Language.CLOJURE) # Disable CPP_CCLS tests if ccls is not available ccls_tests_enabled = _sh.which("ccls") is not None if not ccls_tests_enabled: result.append(Language.CPP_CCLS) # Disable CPP (clangd) tests if clangd is not available clangd_tests_enabled = _sh.which("clangd") is not None if not clangd_tests_enabled: result.append(Language.CPP) # Disable PHP_PHPACTOR tests if php is not available php_tests_enabled = _sh.which("php") is not None if not php_tests_enabled: result.append(Language.PHP_PHPACTOR) al_tests_enabled = True if not al_tests_enabled: result.append(Language.AL) return result _disabled_languages = _determine_disabled_languages() def language_tests_enabled(language: Language) -> bool: """ Check if tests for the given language are enabled in the current environment. :param language: the language to check :return: True if tests for the language are enabled, False otherwise """ return language not in _disabled_languages ================================================ FILE: test/resources/repos/al/test_repo/app.json ================================================ { "id": "00000001-0000-0000-0000-000000000001", "name": "Test AL Project", "publisher": "Serena Test Publisher", "version": "1.0.0.0", "brief": "Test project for AL Language Server in Serena", "description": "This project contains AL code samples for testing language server features", "privacyStatement": "", "EULA": "", "help": "", "url": "https://github.com/oraios/serena", "logo": "", "dependencies": [], "screenshots": [], "platform": "1.0.0.0", "application": "26.0.0.0", "idRanges": [ { "from": 50000, "to": 50100 } ], "resourceExposurePolicy": { "allowDebugging": true, "allowDownloadingSource": true, "includeSourceInSymbolFile": true }, "runtime": "15.0", "features": ["NoImplicitWith"], "target": "Cloud" } ================================================ FILE: test/resources/repos/al/test_repo/src/Codeunits/CustomerMgt.Codeunit.al ================================================ codeunit 50000 CustomerMgt { Permissions = tabledata "TEST Customer" = rimd; trigger OnRun() begin Message('Customer Management Codeunit'); end; procedure CreateNewCustomer() var Customer: Record "TEST Customer"; CustomerCard: Page "TEST Customer Card"; begin Customer.Init(); Customer.Insert(true); CustomerCard.SetRecord(Customer); CustomerCard.Run(); end; procedure CreateCustomer(CustomerNo: Code[20]; CustomerName: Text[100]; CustomerType: Enum CustomerType): Boolean var Customer: Record "TEST Customer"; begin if Customer.Get(CustomerNo) then exit(false); Customer.Init(); Customer."No." := CustomerNo; Customer.Name := CustomerName; Customer."Customer Type" := CustomerType; Customer.UpdateSearchName(); Customer.Insert(true); exit(true); end; procedure AssistEdit(var Customer: Record "TEST Customer"): Boolean var NoSeriesMgt: Codeunit NoSeriesManagement; begin with Customer do begin if NoSeriesMgt.SelectSeries(GetNoSeriesCode(), '', "No. Series") then begin NoSeriesMgt.SetSeries("No."); exit(true); end; end; exit(false); end; procedure TestNoSeries() var SalesSetup: Record "Sales & Receivables Setup"; begin SalesSetup.Get(); SalesSetup.TestField("Customer Nos."); end; procedure InitNo(var Customer: Record "TEST Customer") var NoSeriesMgt: Codeunit NoSeriesManagement; begin TestNoSeries(); NoSeriesMgt.InitSeries(GetNoSeriesCode(), Customer."No. Series", 0D, Customer."No.", Customer."No. Series"); end; procedure GetNoSeriesCode(): Code[20] var SalesSetup: Record "Sales & Receivables Setup"; begin SalesSetup.Get(); exit(SalesSetup."Customer Nos."); end; procedure CalculateTotalBalance(): Decimal var Customer: Record "TEST Customer"; TotalBalance: Decimal; begin TotalBalance := 0; if Customer.FindSet() then repeat Customer.CalcFields(Balance); TotalBalance += Customer.Balance; until Customer.Next() = 0; exit(TotalBalance); end; procedure GetCustomerCount(CustomerType: Enum CustomerType): Integer var Customer: Record "TEST Customer"; begin Customer.SetRange("Customer Type", CustomerType); exit(Customer.Count()); end; procedure BlockCustomersOverCreditLimit() var Customer: Record "TEST Customer"; BlockedCount: Integer; begin BlockedCount := 0; if Customer.FindSet() then repeat Customer.CalcFields(Balance); if (Customer."Credit Limit" > 0) and (Customer.Balance > Customer."Credit Limit") then begin Customer.Blocked := true; Customer.Modify(true); BlockedCount += 1; end; until Customer.Next() = 0; if BlockedCount > 0 then Message('%1 customers blocked due to credit limit exceeded', BlockedCount); end; procedure GetPaymentProcessor(): Interface IPaymentProcessor var PaymentProcessorImpl: Codeunit PaymentProcessorImpl; begin exit(PaymentProcessorImpl); end; procedure SendCustomerStatement(CustomerNo: Code[20]) var Customer: Record "TEST Customer"; ReportSelections: Record "Report Selections"; begin if not Customer.Get(CustomerNo) then Error('Customer %1 not found', CustomerNo); Customer.SetRecFilter(); ReportSelections.SetRange(Usage, ReportSelections.Usage::"C.Statement"); ReportSelections.PrintForCust(ReportSelections.Usage::"C.Statement", Customer, 1); end; procedure ValidateEmail(Email: Text[80]): Boolean var MailMgt: Codeunit "Mail Management"; begin exit(MailMgt.CheckValidEmailAddress(Email)); end; procedure MergeCustomers(FromCustomerNo: Code[20]; ToCustomerNo: Code[20]) var FromCustomer: Record "TEST Customer"; ToCustomer: Record "TEST Customer"; CustomerLedgerEntry: Record "Cust. Ledger Entry"; begin if not FromCustomer.Get(FromCustomerNo) then Error('Source customer %1 not found', FromCustomerNo); if not ToCustomer.Get(ToCustomerNo) then Error('Target customer %1 not found', ToCustomerNo); // Transfer ledger entries CustomerLedgerEntry.SetRange("Customer No.", FromCustomerNo); if CustomerLedgerEntry.FindSet() then repeat CustomerLedgerEntry."Customer No." := ToCustomerNo; CustomerLedgerEntry.Modify(); until CustomerLedgerEntry.Next() = 0; // Delete source customer FromCustomer.Delete(true); Message('Customer %1 merged into %2', FromCustomerNo, ToCustomerNo); end; [EventSubscriber(ObjectType::Table, Database::"TEST Customer", OnAfterInsertEvent, '', true, true)] local procedure OnAfterInsertCustomer(var Rec: Record "TEST Customer") begin LogCustomerChange(Rec, 'INSERT'); end; [EventSubscriber(ObjectType::Table, Database::"TEST Customer", OnAfterModifyEvent, '', true, true)] local procedure OnAfterModifyCustomer(var Rec: Record "TEST Customer") begin LogCustomerChange(Rec, 'MODIFY'); end; local procedure LogCustomerChange(Customer: Record "TEST Customer"; ChangeType: Text[10]) var ChangeLogEntry: Record "Change Log Entry"; begin // Log customer changes for audit purposes ChangeLogEntry.Init(); ChangeLogEntry."Table No." := Database::"TEST Customer"; ChangeLogEntry."Primary Key Field 1 Value" := Customer."No."; ChangeLogEntry."Type of Change" := ChangeLogEntry."Type of Change"::Modification; ChangeLogEntry."User ID" := UserId; ChangeLogEntry."Date and Time" := CurrentDateTime; if ChangeLogEntry.Insert() then; end; } ================================================ FILE: test/resources/repos/al/test_repo/src/Codeunits/PaymentProcessorImpl.Codeunit.al ================================================ codeunit 50001 PaymentProcessorImpl implements IPaymentProcessor { procedure ProcessPayment(Customer: Record "TEST Customer"): Boolean var //PaymentEntry: Record "Payment Entry"; begin // Implementation of payment processing Customer.CalcFields(Balance); if Customer.Balance <= 0 then exit(false); // PaymentEntry.Init(); // PaymentEntry."Customer No." := Customer."No."; // PaymentEntry.Amount := Customer.Balance; // PaymentEntry."Payment Date" := Today(); // PaymentEntry.Status := PaymentEntry.Status::Processed; // if PaymentEntry.Insert(true) then begin // Message('Payment processed for customer %1', Customer.Name); // exit(true); // end; exit(false); end; procedure ValidatePaymentMethod(PaymentMethodCode: Code[10]): Boolean var PaymentMethod: Record "Payment Method"; begin if PaymentMethodCode = '' then exit(false); exit(PaymentMethod.Get(PaymentMethodCode)); end; procedure GetTransactionFee(Amount: Decimal): Decimal var FeePercentage: Decimal; MinimumFee: Decimal; begin FeePercentage := 2.9; // 2.9% transaction fee MinimumFee := 0.30; // Minimum fee exit(Maximum(Amount * FeePercentage / 100, MinimumFee)); end; procedure RefundPayment(TransactionID: Text[50]): Boolean var // PaymentEntry: Record "Payment Entry"; begin // PaymentEntry.SetRange("Transaction ID", TransactionID); // if PaymentEntry.FindFirst() then begin // PaymentEntry.Status := PaymentEntry.Status::Refunded; // PaymentEntry."Refund Date" := Today(); // PaymentEntry.Modify(true); // Message('Payment refunded for transaction %1', TransactionID); // exit(true); // end; Error('Transaction %1 not found', TransactionID); end; local procedure Maximum(Value1: Decimal; Value2: Decimal): Decimal begin if Value1 > Value2 then exit(Value1) else exit(Value2); end; } ================================================ FILE: test/resources/repos/al/test_repo/src/Enums/CustomerType.Enum.al ================================================ enum 50000 CustomerType { Extensible = true; Caption = 'Customer Type'; value(0; "") { Caption = ''; } value(1; Regular) { Caption = 'Regular'; } value(2; Premium) { Caption = 'Premium'; } value(3; VIP) { Caption = 'VIP'; } value(4; Corporate) { Caption = 'Corporate'; } value(5; Government) { Caption = 'Government'; } } ================================================ FILE: test/resources/repos/al/test_repo/src/Interfaces/IPaymentProcessor.Interface.al ================================================ interface IPaymentProcessor { procedure ProcessPayment(Customer: Record "TEST Customer"): Boolean; procedure ValidatePaymentMethod(PaymentMethodCode: Code[10]): Boolean; procedure GetTransactionFee(Amount: Decimal): Decimal; procedure RefundPayment(TransactionID: Text[50]): Boolean; } ================================================ FILE: test/resources/repos/al/test_repo/src/Pages/CustomerCard.Page.al ================================================ page 50001 "TEST Customer Card" { Caption = 'Customer Card'; PageType = Card; SourceTable = "TEST Customer"; RefreshOnActivate = true; layout { area(Content) { group(General) { Caption = 'General'; field("No."; Rec."No.") { ApplicationArea = All; ToolTip = 'Specifies the customer number.'; trigger OnAssistEdit() begin if CustomerMgt.AssistEdit(Rec) then CurrPage.Update(); end; } field(Name; Rec.Name) { ApplicationArea = All; ToolTip = 'Specifies the customer name.'; ShowMandatory = true; } field("Search Name"; Rec."Search Name") { ApplicationArea = All; ToolTip = 'Specifies the search name.'; Visible = false; } field("Customer Type"; Rec."Customer Type") { ApplicationArea = All; ToolTip = 'Specifies the type of customer.'; } field(Blocked; Rec.Blocked) { ApplicationArea = All; ToolTip = 'Specifies if the customer is blocked.'; } field("Last Date Modified"; Rec."Last Date Modified") { ApplicationArea = All; ToolTip = 'Specifies when the customer was last modified.'; Editable = false; } } group(AddressAndContact) { Caption = 'Address & Contact'; field(Address; Rec.Address) { ApplicationArea = All; ToolTip = 'Specifies the customer address.'; } field("Address 2"; Rec."Address 2") { ApplicationArea = All; ToolTip = 'Specifies additional address information.'; } field(City; Rec.City) { ApplicationArea = All; ToolTip = 'Specifies the city.'; } field("Phone No."; Rec."Phone No.") { ApplicationArea = All; ToolTip = 'Specifies the phone number.'; } field("E-Mail"; Rec."E-Mail") { ApplicationArea = All; ToolTip = 'Specifies the email address.'; ExtendedDatatype = EMail; } } group(Invoicing) { Caption = 'Invoicing'; field("Credit Limit"; Rec."Credit Limit") { ApplicationArea = All; ToolTip = 'Specifies the credit limit.'; } field(Balance; Rec.Balance) { ApplicationArea = All; ToolTip = 'Specifies the customer balance.'; DrillDownPageId = "Customer Ledger Entries"; } field("Payment Terms Code"; Rec."Payment Terms Code") { ApplicationArea = All; ToolTip = 'Specifies the payment terms.'; } field("Currency Code"; Rec."Currency Code") { ApplicationArea = All; ToolTip = 'Specifies the currency code.'; } } } area(FactBoxes) { part(CustomerPicture; "Customer Picture") { ApplicationArea = All; SubPageLink = "No." = field("No."); } systempart(Links; Links) { ApplicationArea = RecordLinks; } systempart(Notes; Notes) { ApplicationArea = Notes; } } } actions { area(Navigation) { group(Customer) { Caption = '&Customer'; action(LedgerEntries) { ApplicationArea = All; Caption = 'Ledger E&ntries'; Image = CustomerLedger; RunObject = page "Customer Ledger Entries"; RunPageLink = "Customer No." = field("No."); RunPageView = sorting("Customer No."); ShortcutKey = 'Ctrl+F7'; ToolTip = 'View the history of transactions for the customer.'; } action(Statistics) { ApplicationArea = All; Caption = 'Statistics'; Image = Statistics; RunObject = page "Customer Statistics"; RunPageLink = "No." = field("No."); ShortcutKey = 'F7'; ToolTip = 'View statistical information about the customer.'; } } } area(Processing) { group(Functions) { Caption = 'F&unctions'; action(CheckCreditLimit) { ApplicationArea = All; Caption = 'Check Credit Limit'; Image = Check; ToolTip = 'Check if the customer has exceeded their credit limit.'; trigger OnAction() begin Rec.CheckCreditLimit(); end; } action(ProcessPayment) { ApplicationArea = All; Caption = 'Process Payment'; Image = Payment; ToolTip = 'Process a payment for this customer.'; trigger OnAction() var PaymentProcessor: Interface IPaymentProcessor; begin PaymentProcessor := CustomerMgt.GetPaymentProcessor(); PaymentProcessor.ProcessPayment(Rec); end; } } } area(Promoted) { group(Category_Process) { Caption = 'Process'; actionref(CheckCreditLimit_Promoted; CheckCreditLimit) { } actionref(ProcessPayment_Promoted; ProcessPayment) { } } group(Category_Customer) { Caption = 'Customer'; actionref(Statistics_Promoted; Statistics) { } actionref(LedgerEntries_Promoted; LedgerEntries) { } } } } var CustomerMgt: Codeunit CustomerMgt; trigger OnOpenPage() begin Rec.SetRange("Customer Type"); end; trigger OnAfterGetRecord() begin CheckCreditStatus(); end; local procedure CheckCreditStatus() begin if Rec."Credit Limit" = 0 then exit; Rec.CalcFields(Balance); if Rec.Balance > Rec."Credit Limit" then Message('Warning: Customer has exceeded credit limit'); end; } ================================================ FILE: test/resources/repos/al/test_repo/src/Pages/CustomerList.Page.al ================================================ page 50002 "TEST Customer List" { Caption = 'Customer List'; PageType = List; ApplicationArea = All; UsageCategory = Lists; SourceTable = "TEST Customer"; CardPageId = "TEST Customer Card"; Editable = false; layout { area(Content) { repeater(Group) { field("No."; Rec."No.") { ApplicationArea = All; ToolTip = 'Specifies the customer number.'; } field(Name; Rec.Name) { ApplicationArea = All; ToolTip = 'Specifies the customer name.'; } field(City; Rec.City) { ApplicationArea = All; ToolTip = 'Specifies the city.'; } field("Customer Type"; Rec."Customer Type") { ApplicationArea = All; ToolTip = 'Specifies the type of customer.'; } field("Phone No."; Rec."Phone No.") { ApplicationArea = All; ToolTip = 'Specifies the phone number.'; } field("E-Mail"; Rec."E-Mail") { ApplicationArea = All; ToolTip = 'Specifies the email address.'; } field(Balance; Rec.Balance) { ApplicationArea = All; ToolTip = 'Specifies the customer balance.'; StyleExpr = BalanceStyleExpr; } field("Credit Limit"; Rec."Credit Limit") { ApplicationArea = All; ToolTip = 'Specifies the credit limit.'; } field(Blocked; Rec.Blocked) { ApplicationArea = All; ToolTip = 'Specifies if the customer is blocked.'; } } } area(FactBoxes) { systempart(Links; Links) { ApplicationArea = RecordLinks; } systempart(Notes; Notes) { ApplicationArea = Notes; } } } actions { area(Processing) { action(NewCustomer) { ApplicationArea = All; Caption = 'New'; Image = NewCustomer; ToolTip = 'Create a new customer.'; trigger OnAction() begin CustomerMgt.CreateNewCustomer(); end; } action(ExportToExcel) { ApplicationArea = All; Caption = 'Export to Excel'; Image = ExportToExcel; ToolTip = 'Export the customer list to Excel.'; trigger OnAction() begin ExportCustomersToExcel(); end; } } area(Navigation) { action(ViewStatistics) { ApplicationArea = All; Caption = 'Statistics'; Image = Statistics; RunObject = page "Customer Statistics"; RunPageLink = "No." = field("No."); ToolTip = 'View customer statistics.'; } } area(Promoted) { group(Category_New) { Caption = 'New'; actionref(NewCustomer_Promoted; NewCustomer) { } } group(Category_Process) { Caption = 'Process'; actionref(ExportToExcel_Promoted; ExportToExcel) { } } } } trigger OnAfterGetRecord() begin SetBalanceStyle(); end; var CustomerMgt: Codeunit CustomerMgt; BalanceStyleExpr: Text; local procedure SetBalanceStyle() begin BalanceStyleExpr := ''; Rec.CalcFields(Balance); if (Rec."Credit Limit" <> 0) and (Rec.Balance > Rec."Credit Limit") then BalanceStyleExpr := 'Unfavorable'; end; local procedure ExportCustomersToExcel() var ExcelBuffer: Record "Excel Buffer" temporary; RowNo: Integer; begin ExcelBuffer.Reset(); ExcelBuffer.DeleteAll(); // Add headers RowNo := 1; ExcelBuffer.AddColumn('Customer No.', false, '', false, false, false, '', ExcelBuffer."Cell Type"::Text); ExcelBuffer.AddColumn('Name', false, '', false, false, false, '', ExcelBuffer."Cell Type"::Text); ExcelBuffer.AddColumn('City', false, '', false, false, false, '', ExcelBuffer."Cell Type"::Text); ExcelBuffer.AddColumn('Balance', false, '', false, false, false, '', ExcelBuffer."Cell Type"::Number); ExcelBuffer.NewRow(); // Add data if rec.FindSet() then repeat RowNo += 1; rec.CalcFields(Balance); ExcelBuffer.AddColumn(rec."No.", false, '', false, false, false, '', ExcelBuffer."Cell Type"::Text); ExcelBuffer.AddColumn(rec.Name, false, '', false, false, false, '', ExcelBuffer."Cell Type"::Text); ExcelBuffer.AddColumn(rec.City, false, '', false, false, false, '', ExcelBuffer."Cell Type"::Text); ExcelBuffer.AddColumn(rec.Balance, false, '', false, false, false, '', ExcelBuffer."Cell Type"::Number); ExcelBuffer.NewRow(); until rec.Next() = 0; ExcelBuffer.CreateNewBook('Customers'); ExcelBuffer.WriteSheet('Customer List', CompanyName, UserId); ExcelBuffer.CloseBook(); ExcelBuffer.OpenExcel(); end; } ================================================ FILE: test/resources/repos/al/test_repo/src/TableExtensions/Item.TableExt.al ================================================ tableextension 50000 ItemExt extends Item { fields { field(50000; "Customer No."; Code[20]) { Caption = 'Preferred Customer No.'; DataClassification = CustomerContent; TableRelation = "TEST Customer"; trigger OnValidate() var Customer: Record "TEST Customer"; begin if "Customer No." <> '' then begin Customer.Get("Customer No."); "Customer Name" := Customer.Name; end else "Customer Name" := ''; end; } field(50001; "Customer Name"; Text[100]) { Caption = 'Preferred Customer Name'; DataClassification = CustomerContent; Editable = false; } field(50002; "Special Discount %"; Decimal) { Caption = 'Special Discount %'; DataClassification = CustomerContent; MinValue = 0; MaxValue = 100; } field(50003; "Last Sale Date"; Date) { Caption = 'Last Sale Date'; DataClassification = CustomerContent; Editable = false; } field(50004; "Total Sales Qty"; Decimal) { Caption = 'Total Sales Quantity'; FieldClass = FlowField; CalcFormula = sum("Sales Line".Quantity where("No." = field("No."), Type = const(Item))); Editable = false; } } keys { key(CustomerKey; "Customer No.") { } } procedure UpdateLastSaleDate() begin "Last Sale Date" := Today(); Modify(); end; procedure GetSpecialPrice(Customer: Record "TEST Customer"): Decimal var BasePrice: Decimal; begin BasePrice := "Unit Price"; if "Customer No." = Customer."No." then BasePrice := BasePrice * (1 - "Special Discount %" / 100); exit(BasePrice); end; } ================================================ FILE: test/resources/repos/al/test_repo/src/Tables/Customer.Table.al ================================================ table 50000 "TEST Customer" { Caption = 'Customer'; DataClassification = CustomerContent; fields { field(1; "No."; Code[20]) { Caption = 'No.'; DataClassification = CustomerContent; trigger OnValidate() begin if "No." <> xRec."No." then begin CustomerMgt.TestNoSeries(); "No. Series" := ''; end; end; } field(2; Name; Text[100]) { Caption = 'Name'; DataClassification = CustomerContent; trigger OnValidate() begin if Name <> xRec.Name then UpdateSearchName(); end; } field(3; "Search Name"; Code[100]) { Caption = 'Search Name'; DataClassification = CustomerContent; } field(4; Address; Text[100]) { Caption = 'Address'; DataClassification = CustomerContent; } field(5; "Address 2"; Text[50]) { Caption = 'Address 2'; DataClassification = CustomerContent; } field(6; City; Text[30]) { Caption = 'City'; DataClassification = CustomerContent; } field(7; "Phone No."; Text[30]) { Caption = 'Phone No.'; DataClassification = CustomerContent; } field(8; "E-Mail"; Text[80]) { Caption = 'E-Mail'; DataClassification = CustomerContent; trigger OnValidate() var MailMgt: Codeunit "Mail Management"; begin MailMgt.CheckValidEmailAddresses("E-Mail"); end; } field(10; "Customer Type"; Enum CustomerType) { Caption = 'Customer Type'; DataClassification = CustomerContent; } field(11; Balance; Decimal) { Caption = 'Balance'; Editable = false; FieldClass = FlowField; CalcFormula = sum("Cust. Ledger Entry".Amount where("Customer No." = field("No."))); } field(12; "Credit Limit"; Decimal) { Caption = 'Credit Limit'; DataClassification = CustomerContent; } field(13; Blocked; Boolean) { Caption = 'Blocked'; DataClassification = CustomerContent; } field(14; "Last Date Modified"; Date) { Caption = 'Last Date Modified'; DataClassification = CustomerContent; Editable = false; } field(15; "No. Series"; Code[20]) { Caption = 'No. Series'; DataClassification = CustomerContent; } field(20; "Payment Terms Code"; Code[10]) { Caption = 'Payment Terms Code'; DataClassification = CustomerContent; TableRelation = "Payment Terms"; } field(21; "Currency Code"; Code[10]) { Caption = 'Currency Code'; DataClassification = CustomerContent; TableRelation = Currency; } } keys { key(PK; "No.") { Clustered = true; } key(SearchName; "Search Name") { } key(CustomerType; "Customer Type", City) { } } fieldgroups { fieldgroup(DropDown; "No.", Name, City) { } fieldgroup(Brick; "No.", Name, Balance) { } } trigger OnInsert() begin if "No." = '' then begin CustomerMgt.TestNoSeries(); CustomerMgt.InitNo(Rec); end; "Last Date Modified" := Today(); end; trigger OnModify() begin "Last Date Modified" := Today(); end; trigger OnDelete() var CustomerLedgerEntry: Record "Cust. Ledger Entry"; begin CustomerLedgerEntry.SetRange("Customer No.", "No."); if not CustomerLedgerEntry.IsEmpty() then Error('Cannot delete customer %1 with ledger entries', "No."); end; trigger OnRename() begin "Last Date Modified" := Today(); end; var CustomerMgt: Codeunit CustomerMgt; procedure UpdateSearchName() begin "Search Name" := UpperCase(Name); end; procedure CheckCreditLimit() var CreditLimitExceeded: Boolean; begin CalcFields(Balance); CreditLimitExceeded := (Balance > "Credit Limit") and ("Credit Limit" <> 0); if CreditLimitExceeded then Message('Credit limit exceeded for customer %1', "No."); end; procedure GetDisplayName(): Text begin exit(Name + ' (' + "No." + ')'); end; } ================================================ FILE: test/resources/repos/ansible/test_repo/inventory/hosts.yml ================================================ --- all: children: webservers: hosts: web1.example.com: ansible_host: 192.168.1.10 web2.example.com: ansible_host: 192.168.1.11 dbservers: hosts: db1.example.com: ansible_host: 192.168.1.20 ================================================ FILE: test/resources/repos/ansible/test_repo/playbook.yml ================================================ --- - name: Configure web servers hosts: webservers become: true vars: http_port: 80 max_connections: 100 tasks: - name: Install nginx ansible.builtin.package: name: nginx state: present - name: Start nginx service ansible.builtin.service: name: nginx state: started enabled: true - name: Copy config file ansible.builtin.template: src: nginx.conf.j2 dest: /etc/nginx/nginx.conf notify: Restart nginx handlers: - name: Restart nginx ansible.builtin.service: name: nginx state: restarted ================================================ FILE: test/resources/repos/ansible/test_repo/roles/common/defaults/main.yml ================================================ --- nginx_listen_port: 80 deploy_user: deploy ================================================ FILE: test/resources/repos/ansible/test_repo/roles/common/handlers/main.yml ================================================ --- - name: Restart common services ansible.builtin.debug: msg: "Restarting common services" ================================================ FILE: test/resources/repos/ansible/test_repo/roles/common/tasks/main.yml ================================================ --- - name: Update package cache ansible.builtin.package: update_cache: true - name: Install common packages ansible.builtin.package: name: "{{ item }}" state: present loop: - curl - git - vim - name: Create deploy user ansible.builtin.user: name: deploy state: present shell: /bin/bash ================================================ FILE: test/resources/repos/bash/test_repo/config.sh ================================================ #!/bin/bash # Configuration script for project setup # Environment variables export PROJECT_NAME="bash-test-project" export PROJECT_VERSION="1.0.0" export LOG_LEVEL="INFO" export CONFIG_DIR="./config" # Default settings DEFAULT_TIMEOUT=30 DEFAULT_RETRIES=3 DEFAULT_PORT=8080 # Configuration arrays declare -A ENVIRONMENTS=( ["dev"]="development" ["prod"]="production" ["test"]="testing" ) declare -A DATABASE_CONFIGS=( ["host"]="localhost" ["port"]="5432" ["name"]="myapp_db" ["user"]="dbuser" ) # Function to load configuration load_config() { local env="${1:-dev}" local config_file="${CONFIG_DIR}/${env}.conf" if [[ -f "$config_file" ]]; then echo "Loading configuration from $config_file" source "$config_file" else echo "Warning: Configuration file $config_file not found, using defaults" fi } # Function to validate configuration validate_config() { local errors=0 if [[ -z "$PROJECT_NAME" ]]; then echo "Error: PROJECT_NAME is not set" >&2 ((errors++)) fi if [[ -z "$PROJECT_VERSION" ]]; then echo "Error: PROJECT_VERSION is not set" >&2 ((errors++)) fi if [[ $DEFAULT_PORT -lt 1024 || $DEFAULT_PORT -gt 65535 ]]; then echo "Error: Invalid port number $DEFAULT_PORT" >&2 ((errors++)) fi return $errors } # Function to print configuration print_config() { echo "=== Current Configuration ===" echo "Project Name: $PROJECT_NAME" echo "Version: $PROJECT_VERSION" echo "Log Level: $LOG_LEVEL" echo "Default Port: $DEFAULT_PORT" echo "Default Timeout: $DEFAULT_TIMEOUT" echo "Default Retries: $DEFAULT_RETRIES" echo "\n=== Environments ===" for env in "${!ENVIRONMENTS[@]}"; do echo " $env: ${ENVIRONMENTS[$env]}" done echo "\n=== Database Configuration ===" for key in "${!DATABASE_CONFIGS[@]}"; do echo " $key: ${DATABASE_CONFIGS[$key]}" done } # Initialize configuration if this script is run directly if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then load_config "$1" validate_config print_config fi ================================================ FILE: test/resources/repos/bash/test_repo/main.sh ================================================ #!/bin/bash # Main script demonstrating various bash features # Global variables readonly SCRIPT_NAME="Main Script" COUNTER=0 declare -a ITEMS=("item1" "item2" "item3") # Function definitions function greet_user() { local username="$1" local greeting_type="${2:-default}" case "$greeting_type" in "formal") echo "Good day, ${username}!" ;; "casual") echo "Hey ${username}!" ;; *) echo "Hello, ${username}!" ;; esac } function process_items() { local -n items_ref=$1 local operation="$2" for item in "${items_ref[@]}"; do case "$operation" in "count") ((COUNTER++)) echo "Processing item $COUNTER: $item" ;; "uppercase") echo "${item^^}" ;; *) echo "Unknown operation: $operation" return 1 ;; esac done } # Main execution main() { echo "Starting $SCRIPT_NAME" if [[ $# -eq 0 ]]; then echo "Usage: $0 [greeting_type]" exit 1 fi local user="$1" local greeting="${2:-default}" greet_user "$user" "$greeting" echo "Processing items..." process_items ITEMS "count" echo "Script completed successfully" } # Run main function with all arguments main "$@" ================================================ FILE: test/resources/repos/bash/test_repo/utils.sh ================================================ #!/bin/bash # Utility functions for bash scripting # String manipulation functions function to_uppercase() { echo "${1^^}" } function to_lowercase() { echo "${1,,}" } function trim_whitespace() { local var="$1" var="${var#"${var%%[![:space:]]*}"}" var="${var%"${var##*[![:space:]]}"}" echo "$var" } # File operations function backup_file() { local file="$1" local backup_dir="${2:-./backups}" if [[ ! -f "$file" ]]; then echo "Error: File '$file' does not exist" >&2 return 1 fi mkdir -p "$backup_dir" cp "$file" "${backup_dir}/$(basename "$file").$(date +%Y%m%d_%H%M%S).bak" echo "Backup created for $file" } # Array operations function contains_element() { local element="$1" shift local array=("$@") for item in "${array[@]}"; do if [[ "$item" == "$element" ]]; then return 0 fi done return 1 } # Logging functions function log_message() { local level="$1" local message="$2" local timestamp=$(date '+%Y-%m-%d %H:%M:%S') case "$level" in "ERROR") echo "[$timestamp] ERROR: $message" >&2 ;; "WARN") echo "[$timestamp] WARN: $message" >&2 ;; "INFO") echo "[$timestamp] INFO: $message" ;; "DEBUG") [[ "${DEBUG:-false}" == "true" ]] && echo "[$timestamp] DEBUG: $message" ;; *) echo "[$timestamp] $message" ;; esac } # Validation functions function is_valid_email() { local email="$1" [[ "$email" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]] } function is_number() { [[ $1 =~ ^[0-9]+$ ]] } ================================================ FILE: test/resources/repos/clojure/test_repo/deps.edn ================================================ {:paths ["src"] :deps {org.clojure/clojure {:mvn/version "1.11.1"}} :aliases {:test {:extra-paths ["test"] :extra-deps {org.clojure/test.check {:mvn/version "1.1.1"}}}}} ================================================ FILE: test/resources/repos/clojure/test_repo/src/test_app/core.clj ================================================ (ns test-app.core) (defn greet "A simple greeting function" [name] (str "Hello, " name "!")) (defn add "Adds two numbers" [a b] (+ a b)) (defn multiply "Multiplies two numbers" [a b] (* a b)) (defn -main "Main entry point" [& args] (println (greet "World")) (println "2 + 3 =" (add 2 3)) (println "4 * 5 =" (multiply 4 5))) ================================================ FILE: test/resources/repos/clojure/test_repo/src/test_app/utils.clj ================================================ (ns test-app.utils (:require [test-app.core :as core])) (defn calculate-area "Calculates the area of a rectangle" [width height] (core/multiply width height)) (defn format-greeting "Formats a greeting message" [name] (str "Welcome, " (core/greet name))) (defn sum-list "Sums a list of numbers" [numbers] (reduce core/add 0 numbers)) ================================================ FILE: test/resources/repos/cpp/test_repo/a.cpp ================================================ #include "b.hpp" int main() { int x = add(3, 4); return x; } ================================================ FILE: test/resources/repos/cpp/test_repo/b.cpp ================================================ #include "b.hpp" int add(int a, int b) { return a + b; } ================================================ FILE: test/resources/repos/cpp/test_repo/b.hpp ================================================ #pragma once int add(int a, int b); ================================================ FILE: test/resources/repos/cpp/test_repo/compile_commands.json ================================================ [ { "directory": ".", "command": "g++ -std=c++17 -I . -c a.cpp", "file": "a.cpp" }, { "directory": ".", "command": "g++ -std=c++17 -I . -c b.cpp", "file": "b.cpp" } ] ================================================ FILE: test/resources/repos/csharp/test_repo/.gitignore ================================================ # Build results [Dd]ebug/ [Dd]ebugPublic/ [Rr]elease/ [Rr]eleases/ x64/ x86/ [Aa][Rr][Mm]/ [Aa][Rr][Mm]64/ bld/ [Bb]in/ [Oo]bj/ [Ll]og/ [Ll]ogs/ # Visual Studio temporary files .vs/ # .NET Core project.lock.json project.fragment.lock.json artifacts/ # Files built by Visual Studio *.user *.userosscache *.sln.docstates # Build results *.dll *.exe *.pdb # NuGet *.nupkg *.snupkg packages/ ================================================ FILE: test/resources/repos/csharp/test_repo/Models/Person.cs ================================================ using TestProject; namespace TestProject.Models { public class Person { public string Name { get; set; } public int Age { get; set; } public string Email { get; set; } public Person(string name, int age, string email) { Name = name; Age = age; Email = email; } public override string ToString() { return $"{Name} ({Age}) - {Email}"; } public bool IsAdult() { return Age >= 18; } public int CalculateYearsUntilRetirement() { var calculator = new Calculator(); return calculator.Subtract(65, Age); } } } ================================================ FILE: test/resources/repos/csharp/test_repo/Program.cs ================================================ using System; namespace TestProject { class Program { static void Main(string[] args) { Console.WriteLine("Hello, World!"); var calculator = new Calculator(); int result = calculator.Add(5, 3); Console.WriteLine($"5 + 3 = {result}"); } } public class Calculator { public int Add(int a, int b) { return a + b; } public int Subtract(int a, int b) { return a - b; } public int Multiply(int a, int b) { return a * b; } public double Divide(int a, int b) { if (b == 0) { throw new DivideByZeroException("Cannot divide by zero"); } return (double)a / b; } } } ================================================ FILE: test/resources/repos/csharp/test_repo/TestProject.csproj ================================================ Exe net8.0 enable enable ================================================ FILE: test/resources/repos/csharp/test_repo/serena.sln ================================================ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.5.2.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{0C88DD14-F956-CE84-757C-A364CCF449FC}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "resources", "resources", "{EF7103B4-4C75-1E6D-A485-A154A88D107A}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "repos", "repos", "{E2326EEF-E677-6A44-0935-7677816F09E7}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "csharp", "csharp", "{C21E6CE7-177A-86D9-040F-A317F18B6DBF}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestProject", "test\resources\repos\csharp\test_repo\TestProject.csproj", "{A4D04E18-760A-73F9-3303-0542F6298C84}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {A4D04E18-760A-73F9-3303-0542F6298C84}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A4D04E18-760A-73F9-3303-0542F6298C84}.Debug|Any CPU.Build.0 = Debug|Any CPU {A4D04E18-760A-73F9-3303-0542F6298C84}.Release|Any CPU.ActiveCfg = Release|Any CPU {A4D04E18-760A-73F9-3303-0542F6298C84}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {EF7103B4-4C75-1E6D-A485-A154A88D107A} = {0C88DD14-F956-CE84-757C-A364CCF449FC} {E2326EEF-E677-6A44-0935-7677816F09E7} = {EF7103B4-4C75-1E6D-A485-A154A88D107A} {C21E6CE7-177A-86D9-040F-A317F18B6DBF} = {E2326EEF-E677-6A44-0935-7677816F09E7} {A4D04E18-760A-73F9-3303-0542F6298C84} = {C21E6CE7-177A-86D9-040F-A317F18B6DBF} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {BDCA748E-D888-4BAF-BF24-DAC683113BFC} EndGlobalSection EndGlobal ================================================ FILE: test/resources/repos/dart/test_repo/.gitignore ================================================ # Files and directories created by pub .dart_tool/ .packages pubspec.lock build/ # If you're building an application, you may want to check-in your pubspec.lock # pubspec.lock # Directory created by dartdoc doc/api/ # dotenv environment variables file .env* # Avoid committing generated Javascript files *.dart.js *.info.json # Produced by the --dump-info flag. *.js # When generated by dart2js. Don't specify *.js if your # project includes source files written in JavaScript. *.js_ *.js.deps *.js.map ================================================ FILE: test/resources/repos/dart/test_repo/pubspec.yaml ================================================ name: test_repo description: A test repository for Serena Dart language server testing version: 1.0.0 environment: sdk: '>=3.0.0 <4.0.0' dependencies: dev_dependencies: lints: ^3.0.0 ================================================ FILE: test/resources/repos/elixir/test_repo/.gitignore ================================================ ================================================ FILE: test/resources/repos/elixir/test_repo/lib/examples.ex ================================================ defmodule TestRepo.Examples do @moduledoc """ Examples module demonstrating usage of models and services. Similar to Python's examples directory, this shows how different modules work together. """ alias TestRepo.Models.{User, Item} alias TestRepo.Services.{UserService, ItemService, OrderService} defmodule UserManagement do @doc """ Creates a complete user workflow example. """ def run_user_example do # Start user service {:ok, user_service} = UserService.start_link() # Create users {:ok, alice} = UserService.create_user(user_service, "1", "Alice", "alice@example.com", ["admin"]) {:ok, bob} = UserService.create_user(user_service, "2", "Bob", "bob@example.com", ["user"]) # Get users {:ok, retrieved_alice} = UserService.get_user(user_service, "1") # List all users all_users = UserService.list_users(user_service) # Clean up GenServer.stop(user_service) %{ created_alice: alice, created_bob: bob, retrieved_alice: retrieved_alice, all_users: all_users } end @doc """ Demonstrates user role management. """ def manage_user_roles do user = User.new("role_user", "Role User", "role@example.com") # Add roles user_with_admin = User.add_role(user, "admin") user_with_multiple = User.add_role(user_with_admin, "moderator") # Check roles has_admin = User.has_role?(user_with_multiple, "admin") has_guest = User.has_role?(user_with_multiple, "guest") %{ original_user: user, user_with_roles: user_with_multiple, has_admin: has_admin, has_guest: has_guest } end end defmodule ShoppingExample do @doc """ Creates a complete shopping workflow. """ def run_shopping_example do # Create user and items user = User.new("customer1", "Customer One", "customer@example.com") item1 = Item.new("widget1", "Super Widget", 19.99, "electronics") item2 = Item.new("gadget1", "Cool Gadget", 29.99, "electronics") # Create order order = OrderService.create_order("order1", user) # Add items to order order_with_item1 = OrderService.add_item_to_order(order, item1) order_with_items = OrderService.add_item_to_order(order_with_item1, item2) # Process the order processed_order = OrderService.process_order(order_with_items) completed_order = OrderService.complete_order(processed_order) %{ user: user, items: [item1, item2], final_order: completed_order, total_cost: completed_order.total } end @doc """ Demonstrates item filtering and searching. """ def item_filtering_example do # Start item service {:ok, item_service} = ItemService.start_link() # Create various items ItemService.create_item(item_service, "laptop", "Gaming Laptop", 1299.99, "electronics") ItemService.create_item(item_service, "book", "Elixir Guide", 39.99, "books") ItemService.create_item(item_service, "phone", "Smartphone", 699.99, "electronics") ItemService.create_item(item_service, "novel", "Great Novel", 19.99, "books") # Get all items all_items = ItemService.list_items(item_service) # Filter by category electronics = ItemService.list_items(item_service, "electronics") books = ItemService.list_items(item_service, "books") # Clean up Agent.stop(item_service) %{ all_items: all_items, electronics: electronics, books: books, total_items: length(all_items), electronics_count: length(electronics), books_count: length(books) } end end defmodule IntegrationExample do @doc """ Runs a complete e-commerce scenario. """ def run_full_scenario do # Setup services container = TestRepo.Services.create_service_container() TestRepo.Services.setup_sample_data(container) # Get sample data {:ok, sample_user} = UserService.get_user(container.user_service, TestRepo.Services.sample_user_id()) {:ok, sample_item} = ItemService.get_item(container.item_service, TestRepo.Services.sample_item_id()) # Create additional items {:ok, premium_item} = ItemService.create_item( container.item_service, "premium", "Premium Product", 99.99, "premium" ) # Create order with multiple items order = OrderService.create_order("big_order", sample_user, [sample_item]) order_with_premium = OrderService.add_item_to_order(order, premium_item) # Process through order lifecycle processing_order = OrderService.process_order(order_with_premium) final_order = OrderService.complete_order(processing_order) # Serialize everything for output serialized_user = TestRepo.Services.serialize_model(sample_user) serialized_order = TestRepo.Services.serialize_model(final_order) # Clean up GenServer.stop(container.user_service) Agent.stop(container.item_service) %{ scenario: "full_ecommerce", user: serialized_user, order: serialized_order, total_revenue: final_order.total, items_sold: length(final_order.items) } end @doc """ Demonstrates error handling scenarios. """ def error_handling_example do {:ok, user_service} = UserService.start_link() # Try to create duplicate user {:ok, _user1} = UserService.create_user(user_service, "dup", "User", "user@example.com") duplicate_result = UserService.create_user(user_service, "dup", "Another User", "another@example.com") # Try to get non-existent user missing_user_result = UserService.get_user(user_service, "nonexistent") # Try to delete non-existent user delete_result = UserService.delete_user(user_service, "nonexistent") GenServer.stop(user_service) %{ duplicate_user_error: duplicate_result, missing_user_error: missing_user_result, delete_missing_error: delete_result } end end @doc """ Main function to run all examples. """ def run_all_examples do %{ user_management: UserManagement.run_user_example(), role_management: UserManagement.manage_user_roles(), shopping: ShoppingExample.run_shopping_example(), item_filtering: ShoppingExample.item_filtering_example(), integration: IntegrationExample.run_full_scenario(), error_handling: IntegrationExample.error_handling_example() } end end ================================================ FILE: test/resources/repos/elixir/test_repo/lib/ignored_dir/ignored_module.ex ================================================ defmodule TestRepo.IgnoredDir.IgnoredModule do @moduledoc """ This module is in a directory that should be ignored by the language server. It's used for testing directory filtering functionality. """ alias TestRepo.Models.User @doc """ This function references the User model to test that ignored directories don't show up in symbol references. """ def create_ignored_user do User.new("ignored", "Ignored User", "ignored@example.com") end @doc """ Another function that uses models. """ def process_ignored_user(user) do User.add_role(user, "ignored_role") end end ================================================ FILE: test/resources/repos/elixir/test_repo/lib/models.ex ================================================ defmodule TestRepo.Models do @moduledoc """ Models module demonstrating various Elixir patterns including structs, protocols, and behaviours. """ defprotocol Serializable do @doc "Convert model to map representation" def to_map(model) end defmodule User do @type t :: %__MODULE__{ id: String.t(), name: String.t() | nil, email: String.t(), roles: list(String.t()) } defstruct [:id, :name, :email, roles: []] @doc """ Creates a new user. ## Examples iex> TestRepo.Models.User.new("1", "Alice", "alice@example.com") %TestRepo.Models.User{id: "1", name: "Alice", email: "alice@example.com", roles: []} """ def new(id, name, email, roles \\ []) do %__MODULE__{id: id, name: name, email: email, roles: roles} end @doc """ Checks if user has a specific role. """ def has_role?(%__MODULE__{roles: roles}, role) do role in roles end @doc """ Adds a role to the user. """ def add_role(%__MODULE__{roles: roles} = user, role) do %{user | roles: [role | roles]} end end defmodule Item do @type t :: %__MODULE__{ id: String.t(), name: String.t(), price: float(), category: String.t() } defstruct [:id, :name, :price, :category] @doc """ Creates a new item. ## Examples iex> TestRepo.Models.Item.new("1", "Widget", 19.99, "electronics") %TestRepo.Models.Item{id: "1", name: "Widget", price: 19.99, category: "electronics"} """ def new(id, name, price, category) do %__MODULE__{id: id, name: name, price: price, category: category} end @doc """ Formats price for display. """ def display_price(%__MODULE__{price: price}) do "$#{:erlang.float_to_binary(price, decimals: 2)}" end @doc """ Checks if item is in a specific category. """ def in_category?(%__MODULE__{category: category}, target_category) do category == target_category end end defmodule Order do alias TestRepo.Models.{User, Item} @type t :: %__MODULE__{ id: String.t(), user: User.t(), items: list(Item.t()), total: float(), status: atom() } defstruct [:id, :user, items: [], total: 0.0, status: :pending] @doc """ Creates a new order. """ def new(id, user, items \\ []) do total = calculate_total(items) %__MODULE__{id: id, user: user, items: items, total: total} end @doc """ Adds an item to the order. """ def add_item(%__MODULE__{items: items} = order, item) do new_items = [item | items] %{order | items: new_items, total: calculate_total(new_items)} end @doc """ Updates order status. """ def update_status(%__MODULE__{} = order, status) do %{order | status: status} end defp calculate_total(items) do Enum.reduce(items, 0.0, fn item, acc -> acc + item.price end) end end # Protocol implementations defimpl Serializable, for: User do def to_map(%User{id: id, name: name, email: email, roles: roles}) do %{id: id, name: name, email: email, roles: roles} end end defimpl Serializable, for: Item do def to_map(%Item{id: id, name: name, price: price, category: category}) do %{id: id, name: name, price: price, category: category} end end defimpl Serializable, for: Order do def to_map(%Order{id: id, user: user, items: items, total: total, status: status}) do %{ id: id, user: Serializable.to_map(user), items: Enum.map(items, &Serializable.to_map/1), total: total, status: status } end end @doc """ Factory function to create a sample user. """ def create_sample_user do User.new("sample", "Sample User", "sample@example.com", ["user"]) end @doc """ Factory function to create a sample item. """ def create_sample_item do Item.new("sample", "Sample Item", 9.99, "sample") end end ================================================ FILE: test/resources/repos/elixir/test_repo/lib/services.ex ================================================ defmodule TestRepo.Services do @moduledoc """ Services module demonstrating function usage and dependencies. Similar to Python's services.py, this module uses the models defined in TestRepo.Models. """ alias TestRepo.Models.{User, Item, Order, Serializable} defmodule UserService do use GenServer # Client API @doc """ Starts the UserService GenServer. """ def start_link(opts \\ []) do GenServer.start_link(__MODULE__, %{}, opts) end @doc """ Creates a new user and stores it. """ def create_user(pid, id, name, email, roles \\ []) do GenServer.call(pid, {:create_user, id, name, email, roles}) end @doc """ Gets a user by ID. """ def get_user(pid, id) do GenServer.call(pid, {:get_user, id}) end @doc """ Lists all users. """ def list_users(pid) do GenServer.call(pid, :list_users) end @doc """ Deletes a user by ID. """ def delete_user(pid, id) do GenServer.call(pid, {:delete_user, id}) end # Server callbacks @impl true def init(_) do {:ok, %{}} end @impl true def handle_call({:create_user, id, name, email, roles}, _from, users) do if Map.has_key?(users, id) do {:reply, {:error, "User with ID #{id} already exists"}, users} else user = User.new(id, name, email, roles) new_users = Map.put(users, id, user) {:reply, {:ok, user}, new_users} end end @impl true def handle_call({:get_user, id}, _from, users) do case Map.get(users, id) do nil -> {:reply, {:error, :not_found}, users} user -> {:reply, {:ok, user}, users} end end @impl true def handle_call(:list_users, _from, users) do user_list = Map.values(users) {:reply, user_list, users} end @impl true def handle_call({:delete_user, id}, _from, users) do if Map.has_key?(users, id) do new_users = Map.delete(users, id) {:reply, :ok, new_users} else {:reply, {:error, :not_found}, users} end end end defmodule ItemService do use Agent @doc """ Starts the ItemService Agent. """ def start_link(opts \\ []) do Agent.start_link(fn -> %{} end, opts) end @doc """ Creates a new item and stores it. """ def create_item(pid, id, name, price, category) do Agent.get_and_update(pid, fn items -> if Map.has_key?(items, id) do {{:error, "Item with ID #{id} already exists"}, items} else item = Item.new(id, name, price, category) new_items = Map.put(items, id, item) {{:ok, item}, new_items} end end) end @doc """ Gets an item by ID. """ def get_item(pid, id) do Agent.get(pid, fn items -> case Map.get(items, id) do nil -> {:error, :not_found} item -> {:ok, item} end end) end @doc """ Lists all items, optionally filtered by category. """ def list_items(pid, category \\ nil) do Agent.get(pid, fn items -> item_list = Map.values(items) case category do nil -> item_list cat -> Enum.filter(item_list, &Item.in_category?(&1, cat)) end end) end @doc """ Deletes an item by ID. """ def delete_item(pid, id) do Agent.get_and_update(pid, fn items -> if Map.has_key?(items, id) do new_items = Map.delete(items, id) {:ok, new_items} else {{:error, :not_found}, items} end end) end end defmodule OrderService do @doc """ Creates a new order. """ def create_order(id, user, items \\ []) do Order.new(id, user, items) end @doc """ Adds an item to an existing order. """ def add_item_to_order(order, item) do Order.add_item(order, item) end @doc """ Updates the status of an order. """ def update_order_status(order, status) do Order.update_status(order, status) end @doc """ Processes an order (changes status to :processing). """ def process_order(order) do update_order_status(order, :processing) end @doc """ Completes an order (changes status to :completed). """ def complete_order(order) do update_order_status(order, :completed) end @doc """ Cancels an order (changes status to :cancelled). """ def cancel_order(order) do update_order_status(order, :cancelled) end end @doc """ Factory function to create a service container. """ def create_service_container do {:ok, user_service} = UserService.start_link() {:ok, item_service} = ItemService.start_link() %{ user_service: user_service, item_service: item_service, order_service: OrderService } end @doc """ Helper function to serialize any model that implements the Serializable protocol. """ def serialize_model(model) do Serializable.to_map(model) end # Module-level variables for testing @sample_user_id "sample_user" @sample_item_id "sample_item" @doc """ Gets the sample user ID. """ def sample_user_id, do: @sample_user_id @doc """ Gets the sample item ID. """ def sample_item_id, do: @sample_item_id # Create some sample data at module load time def setup_sample_data(container) do # Create sample user UserService.create_user( container.user_service, @sample_user_id, "Sample User", "sample@example.com", ["user", "customer"] ) # Create sample item ItemService.create_item( container.item_service, @sample_item_id, "Sample Widget", 29.99, "electronics" ) end end ================================================ FILE: test/resources/repos/elixir/test_repo/lib/test_repo.ex ================================================ defmodule TestRepo do @moduledoc """ Documentation for `TestRepo`. """ @doc """ Hello world. ## Examples iex> TestRepo.hello() :world """ def hello do :world end @doc """ Adds two numbers together. ## Examples iex> TestRepo.add(2, 3) 5 """ def add(a, b) do a + b end end ================================================ FILE: test/resources/repos/elixir/test_repo/lib/utils.ex ================================================ defmodule TestRepo.Utils do @moduledoc """ Utility functions for TestRepo. """ @doc """ Converts a string to uppercase. ## Examples iex> TestRepo.Utils.upcase("hello") "HELLO" """ def upcase(string) when is_binary(string) do String.upcase(string) end @doc """ Calculates the factorial of a number. ## Examples iex> TestRepo.Utils.factorial(5) 120 """ def factorial(0), do: 1 def factorial(n) when n > 0 do n * factorial(n - 1) end @doc """ Checks if a number is even. ## Examples iex> TestRepo.Utils.even?(4) true iex> TestRepo.Utils.even?(3) false """ def even?(n) when is_integer(n) do rem(n, 2) == 0 end end ================================================ FILE: test/resources/repos/elixir/test_repo/mix.exs ================================================ defmodule TestRepo.MixProject do use Mix.Project def project do [ app: :test_repo, version: "0.1.0", elixir: "~> 1.14", start_permanent: Mix.env() == :prod, deps: deps() ] end def application do [ extra_applications: [:logger] ] end defp deps do [ {:credo, "~> 1.7", only: [:dev, :test], runtime: false} ] end end ================================================ FILE: test/resources/repos/elixir/test_repo/scripts/build_script.ex ================================================ defmodule TestRepo.Scripts.BuildScript do @moduledoc """ Build script that references models. This is in the scripts directory which should be ignored in some tests. """ alias TestRepo.Models.{User, Item} @doc """ Script function that creates test data. """ def create_test_data do user = User.new("script_user", "Script User", "script@example.com") item = Item.new("script_item", "Script Item", 1.0, "script") {user, item} end @doc """ Another script function referencing User. """ def cleanup_users do # This would reference User in a real scenario IO.puts("Cleaning up users...") end end ================================================ FILE: test/resources/repos/elixir/test_repo/test/models_test.exs ================================================ defmodule TestRepo.ModelsTest do use ExUnit.Case doctest TestRepo.Models alias TestRepo.Models.{User, Item, Order, Serializable} describe "User" do test "creates a new user with default roles" do user = User.new("1", "Alice", "alice@example.com") assert user.id == "1" assert user.name == "Alice" assert user.email == "alice@example.com" assert user.roles == [] end test "creates a user with specified roles" do user = User.new("2", "Bob", "bob@example.com", ["admin", "user"]) assert user.roles == ["admin", "user"] end test "checks if user has role" do user = User.new("3", "Charlie", "charlie@example.com", ["admin"]) assert User.has_role?(user, "admin") refute User.has_role?(user, "guest") end test "adds role to user" do user = User.new("4", "David", "david@example.com") user_with_role = User.add_role(user, "moderator") assert User.has_role?(user_with_role, "moderator") assert length(user_with_role.roles) == 1 end end describe "Item" do test "creates a new item" do item = Item.new("widget1", "Super Widget", 19.99, "electronics") assert item.id == "widget1" assert item.name == "Super Widget" assert item.price == 19.99 assert item.category == "electronics" end test "formats price for display" do item = Item.new("item1", "Test Item", 29.99, "test") assert Item.display_price(item) == "$29.99" end test "checks if item is in category" do item = Item.new("book1", "Elixir Book", 39.99, "books") assert Item.in_category?(item, "books") refute Item.in_category?(item, "electronics") end end describe "Order" do setup do user = User.new("customer1", "Customer", "customer@example.com") item1 = Item.new("item1", "Item 1", 10.00, "category1") item2 = Item.new("item2", "Item 2", 20.00, "category2") %{user: user, item1: item1, item2: item2} end test "creates a new order", %{user: user} do order = Order.new("order1", user) assert order.id == "order1" assert order.user == user assert order.items == [] assert order.total == 0.0 assert order.status == :pending end test "creates order with items", %{user: user, item1: item1, item2: item2} do order = Order.new("order2", user, [item1, item2]) assert length(order.items) == 2 assert order.total == 30.0 end test "adds item to order", %{user: user, item1: item1, item2: item2} do order = Order.new("order3", user, [item1]) order_with_item = Order.add_item(order, item2) assert length(order_with_item.items) == 2 assert order_with_item.total == 30.0 end test "updates order status", %{user: user} do order = Order.new("order4", user) processed_order = Order.update_status(order, :processing) assert processed_order.status == :processing end end describe "Serializable protocol" do test "serializes User" do user = User.new("1", "Alice", "alice@example.com", ["admin"]) serialized = Serializable.to_map(user) expected = %{ id: "1", name: "Alice", email: "alice@example.com", roles: ["admin"] } assert serialized == expected end test "serializes Item" do item = Item.new("widget1", "Widget", 19.99, "electronics") serialized = Serializable.to_map(item) expected = %{ id: "widget1", name: "Widget", price: 19.99, category: "electronics" } assert serialized == expected end test "serializes Order" do user = User.new("1", "Alice", "alice@example.com") item = Item.new("widget1", "Widget", 19.99, "electronics") order = Order.new("order1", user, [item]) serialized = Serializable.to_map(order) assert serialized.id == "order1" assert serialized.total == 19.99 assert serialized.status == :pending assert is_map(serialized.user) assert is_list(serialized.items) assert length(serialized.items) == 1 end end describe "factory functions" do test "creates sample user" do user = TestRepo.Models.create_sample_user() assert user.id == "sample" assert user.name == "Sample User" assert user.email == "sample@example.com" assert "user" in user.roles end test "creates sample item" do item = TestRepo.Models.create_sample_item() assert item.id == "sample" assert item.name == "Sample Item" assert item.price == 9.99 assert item.category == "sample" end end end ================================================ FILE: test/resources/repos/elixir/test_repo/test/test_repo_test.exs ================================================ defmodule TestRepoTest do use ExUnit.Case doctest TestRepo test "greets the world" do assert TestRepo.hello() == :world end test "adds numbers correctly" do assert TestRepo.add(2, 3) == 5 assert TestRepo.add(-1, 1) == 0 assert TestRepo.add(0, 0) == 0 end end ================================================ FILE: test/resources/repos/elm/test_repo/Main.elm ================================================ module Main exposing (main, greet, calculateSum) {-| Main module for testing Elm language server functionality. This module contains basic functions to test: - Symbol discovery - Reference finding - Cross-file imports -} import Browser import Html exposing (Html, div, h1, p, text) import Utils exposing (formatMessage, addNumbers) {-| The main entry point for the application -} main : Program () Model Msg main = Browser.sandbox { init = init , view = view , update = update } type alias Model = { message : String , count : Int } init : Model init = { message = greet "World" , count = calculateSum 5 10 } type Msg = NoOp update : Msg -> Model -> Model update msg model = case msg of NoOp -> model view : Model -> Html Msg view model = div [] [ h1 [] [ text (formatMessage model.message) ] , p [] [ text ("Count: " ++ String.fromInt model.count) ] ] {-| Greet someone by name -} greet : String -> String greet name = "Hello, " ++ name ++ "!" {-| Calculate the sum of two numbers -} calculateSum : Int -> Int -> Int calculateSum a b = addNumbers a b ================================================ FILE: test/resources/repos/elm/test_repo/Utils.elm ================================================ module Utils exposing (formatMessage, addNumbers, multiplyNumbers) {-| Utility functions for the Elm test application. This module provides helper functions used by other modules. -} {-| Format a message by adding brackets around it -} formatMessage : String -> String formatMessage msg = "[ " ++ msg ++ " ]" {-| Add two numbers together -} addNumbers : Int -> Int -> Int addNumbers x y = x + y {-| Multiply two numbers -} multiplyNumbers : Int -> Int -> Int multiplyNumbers x y = x * y ================================================ FILE: test/resources/repos/elm/test_repo/elm.json ================================================ { "type": "application", "source-directories": [ "." ], "elm-version": "0.19.1", "dependencies": { "direct": { "elm/browser": "1.0.2", "elm/core": "1.0.5", "elm/html": "1.0.0" }, "indirect": { "elm/json": "1.1.3", "elm/time": "1.0.0", "elm/url": "1.0.0", "elm/virtual-dom": "1.0.3" } }, "test-dependencies": { "direct": {}, "indirect": {} } } ================================================ FILE: test/resources/repos/erlang/test_repo/hello.erl ================================================ -module(hello). -export([hello_world/0, greet/1, calculate_sum/2]). %% Simple hello world function hello_world() -> io:format("Hello, World!~n"). %% Greet a person by name greet(Name) -> io:format("Hello, ~s!~n", [Name]). %% Calculate sum of two numbers calculate_sum(A, B) -> A + B. ================================================ FILE: test/resources/repos/erlang/test_repo/ignored_dir/ignored_module.erl ================================================ %% This module should be ignored by tests -module(ignored_module). %% This is in the ignored directory and should not be processed -export([ignored_function/0]). ignored_function() -> "This should not appear in symbol searches". ================================================ FILE: test/resources/repos/erlang/test_repo/include/records.hrl ================================================ %% Common record definitions for the test repository -ifndef(RECORDS_HRL). -define(RECORDS_HRL, true). %% User record definition -record(user, { id :: integer(), name :: string(), email :: string(), age :: integer(), active = true :: boolean() }). %% Order record definition -record(order, { id :: integer(), user_id :: integer(), items = [] :: list(), total :: float(), status = pending :: pending | processing | completed | cancelled }). %% Item record definition -record(item, { id :: integer(), name :: string(), price :: float(), category :: string() }). %% Configuration record -record(config, { database_url :: string(), port :: integer(), debug = false :: boolean() }). -endif. ================================================ FILE: test/resources/repos/erlang/test_repo/include/types.hrl ================================================ %% Type definitions for the test repository -ifndef(TYPES_HRL). -define(TYPES_HRL, true). %% Custom types -type user_id() :: pos_integer(). -type email() :: string(). -type status() :: active | inactive | suspended. -type price() :: float(). -type quantity() :: non_neg_integer(). %% Complex types -type order_line() :: {item_id :: pos_integer(), quantity :: quantity(), price :: price()}. -type search_result() :: {ok, list()} | {error, term()}. %% Callback types for behaviors -type init_result() :: {ok, term()} | {stop, term()}. -type handle_call_result() :: {reply, term(), term()} | {stop, term(), term()}. -endif. ================================================ FILE: test/resources/repos/erlang/test_repo/math_utils.erl ================================================ -module(math_utils). -export([add/2, multiply/2, factorial/1]). %% Add two numbers add(X, Y) -> X + Y. %% Multiply two numbers multiply(X, Y) -> X * Y. %% Calculate factorial factorial(0) -> 1; factorial(N) when N > 0 -> N * factorial(N - 1). ================================================ FILE: test/resources/repos/erlang/test_repo/rebar.config ================================================ %% Rebar3 configuration for test repository {erl_opts, [ debug_info, warnings_as_errors, warn_export_all, warn_unused_import, {i, "include"} ]}. {deps, [ {eunit, ".*", {git, "https://github.com/richcarl/eunit.git", {tag, "2.3.6"}}} ]}. {profiles, [ {test, [ {erl_opts, [debug_info]}, {deps, [ {proper, "1.3.0"} ]} ]} ]}. {cover_enabled, true}. {cover_print_enabled, true}. {dialyzer, [ {warnings, [ unmatched_returns, error_handling, race_conditions, underspecs ]} ]}. ================================================ FILE: test/resources/repos/erlang/test_repo/src/app.erl ================================================ %% Main application module -module(app). -behaviour(application). -include("../include/records.hrl"). %% Application callbacks -export([ start/2, stop/1 ]). %% API exports -export([ start_services/0, stop_services/0, get_config/0, health_check/0 ]). %%%=================================================================== %%% Application callbacks %%%=================================================================== -spec start(application:start_type(), term()) -> {ok, pid()} | {error, term()}. start(_StartType, _StartArgs) -> io:format("Starting test application~n"), case start_services() of ok -> supervisor:start_link({local, app_sup}, ?MODULE, []); {error, Reason} -> {error, Reason} end. -spec stop(term()) -> ok. stop(_State) -> io:format("Stopping test application~n"), stop_services(), ok. %%%=================================================================== %%% API functions %%%=================================================================== -spec start_services() -> ok | {error, term()}. start_services() -> try {ok, _Pid} = services:start_link(), io:format("Services started successfully~n"), ok catch error:Reason -> io:format("Failed to start services: ~p~n", [Reason]), {error, Reason} end. -spec stop_services() -> ok. stop_services() -> try services:stop(), io:format("Services stopped successfully~n"), ok catch error:Reason -> io:format("Error stopping services: ~p~n", [Reason]), ok end. -spec get_config() -> #config{}. get_config() -> #config{ database_url = "postgresql://localhost:5432/testdb", port = 8080, debug = true }. -spec health_check() -> {ok, #{atom() => term()}} | {error, term()}. health_check() -> try Stats = services:get_statistics(), Config = get_config(), HealthInfo = #{ status => healthy, timestamp => utils:timestamp(), config => Config, statistics => Stats, uptime => erlang:statistics(wall_clock) }, {ok, HealthInfo} catch error:Reason -> {error, {health_check_failed, Reason}} end. %%%=================================================================== %%% Supervisor callbacks (simple implementation) %%%=================================================================== init([]) -> %% Simple supervisor strategy SupFlags = #{ strategy => one_for_one, intensity => 5, period => 10 }, ChildSpecs = [ #{ id => services, start => {services, start_link, []}, restart => permanent, shutdown => 5000, type => worker, modules => [services] } ], {ok, {SupFlags, ChildSpecs}}. ================================================ FILE: test/resources/repos/erlang/test_repo/src/models.erl ================================================ %% Models module with record operations and business logic -module(models). -include("../include/records.hrl"). -include("../include/types.hrl"). %% Export functions -export([ create_user/4, update_user/2, get_user_by_id/1, create_order/3, add_item_to_order/3, calculate_order_total/1, validate_email/1, format_user_info/1 ]). %% User operations -spec create_user(integer(), string(), email(), integer()) -> #user{}. create_user(Id, Name, Email, Age) -> #user{ id = Id, name = Name, email = Email, age = Age, active = true }. -spec update_user(#user{}, [{atom(), term()}]) -> #user{}. update_user(User, Updates) -> lists:foldl(fun update_user_field/2, User, Updates). -spec get_user_by_id(user_id()) -> {ok, #user{}} | {error, not_found}. get_user_by_id(Id) -> %% Simulate database lookup case Id of 1 -> {ok, create_user(1, "John Doe", "john@example.com", 30)}; 2 -> {ok, create_user(2, "Jane Smith", "jane@example.com", 25)}; _ -> {error, not_found} end. %% Order operations -spec create_order(integer(), user_id(), list()) -> #order{}. create_order(Id, UserId, Items) -> #order{ id = Id, user_id = UserId, items = Items, total = 0.0, status = pending }. -spec add_item_to_order(#order{}, #item{}, quantity()) -> #order{}. add_item_to_order(Order, Item, Quantity) -> NewItem = Item#item{id = Quantity}, % Store quantity in id field for simplicity Order#order{items = [NewItem | Order#order.items]}. -spec calculate_order_total(#order{}) -> float(). calculate_order_total(#order{items = Items}) -> lists:foldl(fun(#item{price = Price, id = Qty}, Acc) -> Acc + (Price * Qty) end, 0.0, Items). %% Helper functions -spec update_user_field({atom(), term()}, #user{}) -> #user{}. update_user_field({name, Name}, User) -> User#user{name = Name}; update_user_field({email, Email}, User) -> User#user{email = Email}; update_user_field({age, Age}, User) -> User#user{age = Age}; update_user_field({active, Active}, User) -> User#user{active = Active}; update_user_field(_, User) -> User. -spec validate_email(string()) -> boolean(). validate_email(Email) -> string:str(Email, "@") > 0 andalso string:str(Email, ".") > 0. -spec format_user_info(#user{}) -> string(). format_user_info(#user{name = Name, email = Email, age = Age, active = Active}) -> Status = case Active of true -> "active"; false -> "inactive" end, lists:flatten(io_lib:format("~s (~s) - Age: ~w - Status: ~s", [Name, Email, Age, Status])). ================================================ FILE: test/resources/repos/erlang/test_repo/src/services.erl ================================================ %% Services module implementing gen_server behavior -module(services). -behaviour(gen_server). -include("../include/records.hrl"). -include("../include/types.hrl"). %% API exports -export([ start_link/0, stop/0, register_user/4, get_user/1, create_order/2, update_order_status/2, get_statistics/0 ]). %% gen_server callbacks -export([ init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3 ]). %% State record -record(state, { users = #{} :: map(), orders = #{} :: map(), next_user_id = 1 :: integer(), next_order_id = 1 :: integer() }). %%%=================================================================== %%% API %%%=================================================================== -spec start_link() -> {ok, pid()} | ignore | {error, term()}. start_link() -> gen_server:start_link({local, ?MODULE}, ?MODULE, [], []). -spec stop() -> ok. stop() -> gen_server:stop(?MODULE). -spec register_user(string(), email(), integer(), boolean()) -> {ok, user_id()} | {error, term()}. register_user(Name, Email, Age, Active) -> gen_server:call(?MODULE, {register_user, Name, Email, Age, Active}). -spec get_user(user_id()) -> {ok, #user{}} | {error, not_found}. get_user(UserId) -> gen_server:call(?MODULE, {get_user, UserId}). -spec create_order(user_id(), list()) -> {ok, integer()} | {error, term()}. create_order(UserId, Items) -> gen_server:call(?MODULE, {create_order, UserId, Items}). -spec update_order_status(integer(), atom()) -> ok | {error, term()}. update_order_status(OrderId, Status) -> gen_server:call(?MODULE, {update_order_status, OrderId, Status}). -spec get_statistics() -> #{atom() => integer()}. get_statistics() -> gen_server:call(?MODULE, get_statistics). %%%=================================================================== %%% gen_server callbacks %%%=================================================================== -spec init([]) -> {ok, #state{}}. init([]) -> {ok, #state{}}. -spec handle_call(term(), {pid(), term()}, #state{}) -> handle_call_result(). handle_call({register_user, Name, Email, Age, Active}, _From, State) -> UserId = State#state.next_user_id, User = models:create_user(UserId, Name, Email, Age), UpdatedUser = models:update_user(User, [{active, Active}]), NewUsers = maps:put(UserId, UpdatedUser, State#state.users), NewState = State#state{ users = NewUsers, next_user_id = UserId + 1 }, {reply, {ok, UserId}, NewState}; handle_call({get_user, UserId}, _From, State) -> case maps:get(UserId, State#state.users, not_found) of not_found -> {reply, {error, not_found}, State}; User -> {reply, {ok, User}, State} end; handle_call({create_order, UserId, Items}, _From, State) -> case maps:get(UserId, State#state.users, not_found) of not_found -> {reply, {error, user_not_found}, State}; _User -> OrderId = State#state.next_order_id, Order = models:create_order(OrderId, UserId, Items), NewOrders = maps:put(OrderId, Order, State#state.orders), NewState = State#state{ orders = NewOrders, next_order_id = OrderId + 1 }, {reply, {ok, OrderId}, NewState} end; handle_call({update_order_status, OrderId, Status}, _From, State) -> case maps:get(OrderId, State#state.orders, not_found) of not_found -> {reply, {error, order_not_found}, State}; Order -> UpdatedOrder = Order#order{status = Status}, NewOrders = maps:put(OrderId, UpdatedOrder, State#state.orders), NewState = State#state{orders = NewOrders}, {reply, ok, NewState} end; handle_call(get_statistics, _From, State) -> Stats = #{ total_users => maps:size(State#state.users), total_orders => maps:size(State#state.orders), next_user_id => State#state.next_user_id, next_order_id => State#state.next_order_id }, {reply, Stats, State}; handle_call(_Request, _From, State) -> {reply, {error, unknown_request}, State}. -spec handle_cast(term(), #state{}) -> {noreply, #state{}}. handle_cast(_Msg, State) -> {noreply, State}. -spec handle_info(term(), #state{}) -> {noreply, #state{}}. handle_info(_Info, State) -> {noreply, State}. -spec terminate(term(), #state{}) -> ok. terminate(_Reason, _State) -> ok. -spec code_change(term() | {down, term()}, #state{}, term()) -> {ok, #state{}}. code_change(_OldVsn, State, _Extra) -> {ok, State}. ================================================ FILE: test/resources/repos/erlang/test_repo/src/utils.erl ================================================ %% Utility functions module -module(utils). -include("../include/types.hrl"). %% String utilities -export([ capitalize/1, trim/1, split_string/2, format_currency/1, validate_input/2 ]). %% List utilities -export([ find_by_id/2, group_by/2, partition_by/2, safe_nth/2 ]). %% Math utilities -export([ calculate_discount/2, round_to_decimal/2, percentage/2 ]). %% Date/Time utilities -export([ timestamp/0, format_datetime/1, days_between/2 ]). %%%=================================================================== %%% String utilities %%%=================================================================== -spec capitalize(string()) -> string(). capitalize([]) -> []; capitalize([H|T]) -> [string:to_upper(H) | T]. -spec trim(string()) -> string(). trim(String) -> string:strip(string:strip(String, right), left). -spec split_string(string(), string()) -> [string()]. split_string(String, Delimiter) -> string:tokens(String, Delimiter). -spec format_currency(float()) -> string(). format_currency(Amount) -> lists:flatten(io_lib:format("$~.2f", [Amount])). -spec validate_input(atom(), term()) -> boolean(). validate_input(email, Email) when is_list(Email) -> models:validate_email(Email); validate_input(age, Age) when is_integer(Age) -> Age >= 0 andalso Age =< 150; validate_input(name, Name) when is_list(Name) -> length(Name) > 0 andalso length(Name) =< 100; validate_input(_, _) -> false. %%%=================================================================== %%% List utilities %%%=================================================================== -spec find_by_id(integer(), [tuple()]) -> {ok, tuple()} | {error, not_found}. find_by_id(_Id, []) -> {error, not_found}; find_by_id(Id, [H|T]) when element(2, H) =:= Id -> {ok, H}; find_by_id(Id, [_|T]) -> find_by_id(Id, T). -spec group_by(fun((term()) -> term()), [term()]) -> [{term(), [term()]}]. group_by(Fun, List) -> Dict = lists:foldl(fun(Item, Acc) -> Key = Fun(Item), case lists:keyfind(Key, 1, Acc) of {Key, Values} -> lists:keyreplace(Key, 1, Acc, {Key, [Item|Values]}); false -> [{Key, [Item]}|Acc] end end, [], List), [{K, lists:reverse(V)} || {K, V} <- Dict]. -spec partition_by(fun((term()) -> boolean()), [term()]) -> {[term()], [term()]}. partition_by(Predicate, List) -> lists:partition(Predicate, List). -spec safe_nth(integer(), [term()]) -> {ok, term()} | {error, out_of_bounds}. safe_nth(N, List) when N > 0 andalso N =< length(List) -> {ok, lists:nth(N, List)}; safe_nth(_, _) -> {error, out_of_bounds}. %%%=================================================================== %%% Math utilities %%%=================================================================== -spec calculate_discount(float(), float()) -> float(). calculate_discount(Price, DiscountPercent) when DiscountPercent >= 0 andalso DiscountPercent =< 100 -> Price * (100 - DiscountPercent) / 100. -spec round_to_decimal(float(), integer()) -> float(). round_to_decimal(Number, Decimals) -> Factor = math:pow(10, Decimals), round(Number * Factor) / Factor. -spec percentage(number(), number()) -> float(). percentage(Part, Total) when Total =/= 0 -> (Part / Total) * 100; percentage(_, 0) -> 0.0. %%%=================================================================== %%% Date/Time utilities %%%=================================================================== -spec timestamp() -> integer(). timestamp() -> {MegaSecs, Secs, _MicroSecs} = os:timestamp(), MegaSecs * 1000000 + Secs. -spec format_datetime(integer()) -> string(). format_datetime(Timestamp) -> {{Year, Month, Day}, {Hour, Minute, Second}} = calendar:gregorian_seconds_to_datetime(Timestamp + 62167219200), lists:flatten(io_lib:format("~4..0w-~2..0w-~2..0w ~2..0w:~2..0w:~2..0w", [Year, Month, Day, Hour, Minute, Second])). -spec days_between(integer(), integer()) -> integer(). days_between(Timestamp1, Timestamp2) -> abs(Timestamp2 - Timestamp1) div (24 * 3600). ================================================ FILE: test/resources/repos/erlang/test_repo/test/models_tests.erl ================================================ %% Unit tests for models module -module(models_tests). -include_lib("eunit/include/eunit.hrl"). -include("../include/records.hrl"). %%%=================================================================== %%% Test fixtures %%%=================================================================== sample_user() -> models:create_user(1, "John Doe", "john@example.com", 30). sample_order() -> Items = [ #item{id = 1, name = "Widget", price = 10.99, category = "tools"}, #item{id = 2, name = "Gadget", price = 25.50, category = "electronics"} ], models:create_order(1, 1, Items). %%%=================================================================== %%% User tests %%%=================================================================== create_user_test() -> User = models:create_user(1, "John Doe", "john@example.com", 30), ?assertEqual(1, User#user.id), ?assertEqual("John Doe", User#user.name), ?assertEqual("john@example.com", User#user.email), ?assertEqual(30, User#user.age), ?assertEqual(true, User#user.active). update_user_test() -> User = sample_user(), UpdatedUser = models:update_user(User, [{name, "Jane Doe"}, {age, 25}]), ?assertEqual("Jane Doe", UpdatedUser#user.name), ?assertEqual(25, UpdatedUser#user.age), ?assertEqual("john@example.com", UpdatedUser#user.email). % unchanged get_user_by_id_test() -> ?assertEqual({ok, #user{id = 1, name = "John Doe"}}, models:get_user_by_id(1)), ?assertEqual({error, not_found}, models:get_user_by_id(999)). %%%=================================================================== %%% Order tests %%%=================================================================== create_order_test() -> Order = models:create_order(1, 1, []), ?assertEqual(1, Order#order.id), ?assertEqual(1, Order#order.user_id), ?assertEqual([], Order#order.items), ?assertEqual(pending, Order#order.status). calculate_order_total_test() -> Order = sample_order(), Total = models:calculate_order_total(Order), ?assertEqual(36.49, Total). % 10.99 * 1 + 25.50 * 2 %%%=================================================================== %%% Validation tests %%%=================================================================== validate_email_test() -> ?assertEqual(true, models:validate_email("user@example.com")), ?assertEqual(true, models:validate_email("test.email@domain.co.uk")), ?assertEqual(false, models:validate_email("invalid-email")), ?assertEqual(false, models:validate_email("@domain.com")), ?assertEqual(false, models:validate_email("user@")). format_user_info_test() -> User = sample_user(), Info = models:format_user_info(User), ?assert(string:str(Info, "John Doe") > 0), ?assert(string:str(Info, "john@example.com") > 0), ?assert(string:str(Info, "30") > 0), ?assert(string:str(Info, "active") > 0). ================================================ FILE: test/resources/repos/erlang/test_repo/test/utils_tests.erl ================================================ %% Unit tests for utils module -module(utils_tests). -include_lib("eunit/include/eunit.hrl"). %%%=================================================================== %%% String utility tests %%%=================================================================== capitalize_test() -> ?assertEqual("Hello", utils:capitalize("hello")), ?assertEqual("Test", utils:capitalize("test")), ?assertEqual("", utils:capitalize("")). trim_test() -> ?assertEqual("hello", utils:trim(" hello ")), ?assertEqual("test", utils:trim("test")), ?assertEqual("", utils:trim(" ")). format_currency_test() -> ?assertEqual("$10.50", utils:format_currency(10.5)), ?assertEqual("$0.99", utils:format_currency(0.99)), ?assertEqual("$100.00", utils:format_currency(100.0)). validate_input_test() -> ?assertEqual(true, utils:validate_input(email, "test@example.com")), ?assertEqual(false, utils:validate_input(email, "invalid")), ?assertEqual(true, utils:validate_input(age, 25)), ?assertEqual(false, utils:validate_input(age, -5)), ?assertEqual(true, utils:validate_input(name, "John")), ?assertEqual(false, utils:validate_input(name, "")). %%%=================================================================== %%% List utility tests %%%=================================================================== find_by_id_test() -> Items = [{item, 1, "first"}, {item, 2, "second"}, {item, 3, "third"}], ?assertEqual({ok, {item, 2, "second"}}, utils:find_by_id(2, Items)), ?assertEqual({error, not_found}, utils:find_by_id(999, Items)). safe_nth_test() -> List = [a, b, c, d, e], ?assertEqual({ok, c}, utils:safe_nth(3, List)), ?assertEqual({error, out_of_bounds}, utils:safe_nth(10, List)), ?assertEqual({error, out_of_bounds}, utils:safe_nth(0, List)). %%%=================================================================== %%% Math utility tests %%%=================================================================== calculate_discount_test() -> ?assertEqual(90.0, utils:calculate_discount(100.0, 10.0)), ?assertEqual(75.0, utils:calculate_discount(100.0, 25.0)), ?assertEqual(100.0, utils:calculate_discount(100.0, 0.0)). round_to_decimal_test() -> ?assertEqual(10.99, utils:round_to_decimal(10.9876, 2)), ?assertEqual(15.0, utils:round_to_decimal(15.0001, 2)), ?assertEqual(0.33, utils:round_to_decimal(1/3, 2)). percentage_test() -> ?assertEqual(50.0, utils:percentage(50, 100)), ?assertEqual(25.0, utils:percentage(1, 4)), ?assertEqual(0.0, utils:percentage(10, 0)). %%%=================================================================== %%% Date/Time utility tests %%%=================================================================== timestamp_test() -> Timestamp = utils:timestamp(), ?assert(is_integer(Timestamp)), ?assert(Timestamp > 0). format_datetime_test() -> % Test with a known timestamp Formatted = utils:format_datetime(1234567890), ?assert(is_list(Formatted)), ?assert(length(Formatted) > 0). days_between_test() -> Day1 = 1000000, Day2 = 1000000 + (3 * 24 * 3600), % 3 days later ?assertEqual(3, utils:days_between(Day1, Day2)), ?assertEqual(3, utils:days_between(Day2, Day1)). ================================================ FILE: test/resources/repos/fortran/test_repo/main.f90 ================================================ program test_program use math_utils implicit none real :: result ! Test addition result = add_numbers(5.0, 3.0) call print_result(result) ! Test multiplication result = multiply_numbers(4.0, 2.0) call print_result(result) print *, "All tests completed" end program test_program ================================================ FILE: test/resources/repos/fortran/test_repo/modules/geometry.f90 ================================================ module geometry_types implicit none ! Simple type definition type Point2D real :: x, y end type Point2D ! Type with double colon syntax type :: Circle real :: radius type(Point2D) :: center end type Circle ! Type with extends (inheritance) type, extends(Point2D) :: Point3D real :: z end type Point3D ! Named interface interface distance module procedure distance_2d, distance_3d end interface distance contains function distance_2d(p1, p2) result(dist) type(Point2D), intent(in) :: p1, p2 real :: dist dist = sqrt((p2%x - p1%x)**2 + (p2%y - p1%y)**2) end function distance_2d function distance_3d(p1, p2) result(dist) type(Point3D), intent(in) :: p1, p2 real :: dist dist = sqrt((p2%x - p1%x)**2 + (p2%y - p1%y)**2 + (p2%z - p1%z)**2) end function distance_3d function circle_area(c) result(area) type(Circle), intent(in) :: c real :: area real, parameter :: pi = 3.14159265359 area = pi * c%radius**2 end function circle_area end module geometry_types ================================================ FILE: test/resources/repos/fortran/test_repo/modules/math_utils.f90 ================================================ module math_utils implicit none contains function add_numbers(a, b) result(sum) real, intent(in) :: a, b real :: sum sum = a + b end function add_numbers function multiply_numbers(x, y) result(product) real, intent(in) :: x, y real :: product product = x * y end function multiply_numbers subroutine print_result(value) real, intent(in) :: value print *, "Result is:", value end subroutine print_result end module math_utils ================================================ FILE: test/resources/repos/fsharp/test_repo/.gitignore ================================================ bin/ obj/ *.user .vscode/ .ionide/ ================================================ FILE: test/resources/repos/fsharp/test_repo/Calculator.fs ================================================ module Calculator /// Simple calculator functions let add a b = a + b let subtract a b = a - b let multiply a b = a * b let divide a b = if b = 0 then failwith "Cannot divide by zero" else (float a) / (float b) /// More complex operations let square x = x * x let factorial n = if n <= 0 then 1 else let rec factorialHelper acc n = if n <= 1 then acc else factorialHelper (acc * n) (n - 1) factorialHelper 1 n /// Calculator type with instance methods type CalculatorClass() = member this.Add(a, b) = add a b member this.Subtract(a, b) = subtract a b member this.Multiply(a, b) = multiply a b member this.Divide(a, b) = divide a b ================================================ FILE: test/resources/repos/fsharp/test_repo/Models/Person.fs ================================================ namespace Models /// Person record type type Person = { Name: string Age: int Email: string option } module PersonModule = /// Create a new person let createPerson name age email = { Name = name; Age = age; Email = email } /// Check if person is an adult let isAdult person = person.Age >= 18 /// Get display name let getDisplayName person = match person.Email with | Some email -> $"{person.Name} ({email})" | None -> person.Name /// Update person age let updateAge newAge person = { person with Age = newAge } /// Address type type Address = { Street: string City: string ZipCode: string Country: string } /// Employee type that extends Person concept type Employee = { Person: Person EmployeeId: int Department: string Salary: decimal Address: Address option } ================================================ FILE: test/resources/repos/fsharp/test_repo/Program.fs ================================================ module Program open Calculator open Models [] let main argv = printfn "Hello, F# World!" // Test calculator functions let result1 = add 5 3 printfn "5 + 3 = %d" result1 let result2 = subtract 10 4 printfn "10 - 4 = %d" result2 let result3 = multiply 6 7 printfn "6 * 7 = %d" result3 let result4 = divide 15 3 printfn "15 / 3 = %.2f" result4 // Test calculator class let calc = CalculatorClass() let classResult = calc.Add(20, 5) printfn "Calculator class: 20 + 5 = %d" classResult // Test person module let person = PersonModule.createPerson "Alice Smith" 25 (Some "alice@example.com") printfn "Person: %s" (PersonModule.getDisplayName person) printfn "Is adult: %b" (PersonModule.isAdult person) // Test factorial let fact5 = factorial 5 printfn "5! = %d" fact5 0 // return success ================================================ FILE: test/resources/repos/fsharp/test_repo/README.md ================================================ # F# Test Project This is a test F# project for testing Serena's F# language support. ## Project Structure - `Program.fs` - Main program entry point - `Calculator.fs` - Calculator functions and types - `Models/Person.fs` - Person and Employee data models ## Build ```bash dotnet build ``` ## Run ```bash dotnet run ``` ================================================ FILE: test/resources/repos/fsharp/test_repo/TestProject.fsproj ================================================ Exe net8.0 ================================================ FILE: test/resources/repos/go/test_repo/buildtags/foo.go ================================================ //go:build foo // +build foo package buildtags type XFoo struct { Value int } ================================================ FILE: test/resources/repos/go/test_repo/buildtags/notfoo.go ================================================ //go:build !foo // +build !foo package buildtags type XNotFoo struct { Value int } ================================================ FILE: test/resources/repos/go/test_repo/go.mod ================================================ module test_repo go 1.21 ================================================ FILE: test/resources/repos/go/test_repo/main.go ================================================ package main import "fmt" func main() { fmt.Println("Hello, Go!") Helper() } func Helper() { fmt.Println("Helper function called") } type DemoStruct struct { Field int } func UsingHelper() { Helper() } ================================================ FILE: test/resources/repos/groovy/test_repo/.gitignore ================================================ .gradle/ ================================================ FILE: test/resources/repos/groovy/test_repo/build.gradle ================================================ plugins { id 'groovy' } repositories { mavenCentral() } ================================================ FILE: test/resources/repos/groovy/test_repo/src/main/groovy/com/example/Main.groovy ================================================ package com.example class Main { static void main(String[] args) { Utils.printHello() Model model = new Model("Cascade") println(model.name) } } ================================================ FILE: test/resources/repos/groovy/test_repo/src/main/groovy/com/example/Model.groovy ================================================ package com.example class Model { String name Model(String name) { this.name = name } } ================================================ FILE: test/resources/repos/groovy/test_repo/src/main/groovy/com/example/ModelUser.groovy ================================================ package com.example class ModelUser { static void main(String[] args) { Model model = new Model("Cascade") println(model.name) } } ================================================ FILE: test/resources/repos/groovy/test_repo/src/main/groovy/com/example/Utils.groovy ================================================ package com.example class Utils { static void printHello() { println("Hello from Utils!") } } ================================================ FILE: test/resources/repos/haskell/test_repo/app/Main.hs ================================================ module Main (main) where import Calculator import Helper main :: IO () main = do let calc = Calculator "TestCalc" 1 putStrLn $ "Using " ++ calcName calc ++ " version " ++ show (calcVersion calc) -- Test add function (cross-file reference) let result1 = add 5 3 putStrLn $ "5 + 3 = " ++ show result1 -- Test subtract (uses validateNumber from Helper) let result2 = Calculator.subtract 10 4 putStrLn $ "10 - 4 = " ++ show result2 -- Test calculate function case calculate calc "multiply" 6 7 of Just result -> putStrLn $ "6 * 7 = " ++ show result Nothing -> putStrLn "Calculation failed" -- Test helper functions directly putStrLn $ "Is 5 positive? " ++ show (isPositive 5) putStrLn $ "Absolute of -10: " ++ show (absolute (-10)) ================================================ FILE: test/resources/repos/haskell/test_repo/package.yaml ================================================ name: haskell-test-repo version: 0.1.0.0 github: "test/haskell-test-repo" license: BSD3 author: "Test Author" maintainer: "test@example.com" dependencies: - base >= 4.7 && < 5 library: source-dirs: src exposed-modules: - Calculator - Helper executables: haskell-test-repo-exe: main: Main.hs source-dirs: app dependencies: - haskell-test-repo default-extensions: - OverloadedStrings ================================================ FILE: test/resources/repos/haskell/test_repo/src/Calculator.hs ================================================ module Calculator ( Calculator(..) , add , subtract , multiply , divide , calculate ) where import Prelude hiding (subtract) import Helper (validateNumber) -- | A simple calculator data type data Calculator = Calculator { calcName :: String , calcVersion :: Int } deriving (Show, Eq) -- | Add two numbers add :: Int -> Int -> Int add x y = validateNumber x + validateNumber y -- | Subtract two numbers subtract :: Int -> Int -> Int subtract x y = validateNumber x - validateNumber y -- | Multiply two numbers multiply :: Int -> Int -> Int multiply x y = x * y -- | Divide two numbers (returns Maybe to handle division by zero) divide :: Int -> Int -> Maybe Int divide _ 0 = Nothing divide x y = Just (x `div` y) -- | Perform a calculation based on operator calculate :: Calculator -> String -> Int -> Int -> Maybe Int calculate calc op x y = case op of "add" -> Just (add x y) "subtract" -> Just (subtract x y) "multiply" -> Just (multiply x y) "divide" -> divide x y _ -> Nothing ================================================ FILE: test/resources/repos/haskell/test_repo/src/Helper.hs ================================================ module Helper ( validateNumber , isPositive , isNegative , absolute ) where -- | Validate that a number is not zero (for demonstration) validateNumber :: Int -> Int validateNumber x = if x == 0 then error "Zero not allowed" else x -- | Check if a number is positive isPositive :: Int -> Bool isPositive x = x > 0 -- | Check if a number is negative isNegative :: Int -> Bool isNegative x = x < 0 -- | Get absolute value absolute :: Int -> Int absolute x = if isNegative x then negate x else x ================================================ FILE: test/resources/repos/haskell/test_repo/stack.yaml ================================================ resolver: ghc-9.8.4 system-ghc: true install-ghc: false packages: - . ================================================ FILE: test/resources/repos/hlsl/test_repo/common.hlsl ================================================ #ifndef COMMON_HLSL #define COMMON_HLSL struct VertexInput { float3 position : POSITION; float3 normal : NORMAL; float2 uv : TEXCOORD0; }; struct VertexOutput { float4 clipPos : SV_POSITION; float3 worldNormal : TEXCOORD0; float2 uv : TEXCOORD1; }; float3 SafeNormalize(float3 v) { float len = length(v); return len > 0.0001 ? v / len : float3(0, 0, 0); } float Remap(float value, float fromMin, float fromMax, float toMin, float toMax) { return toMin + (value - fromMin) * (toMax - toMin) / (fromMax - fromMin); } #endif // COMMON_HLSL ================================================ FILE: test/resources/repos/hlsl/test_repo/compute_test.hlsl ================================================ #include "common.hlsl" RWTexture2D OutputTexture : register(u0); Texture2D InputTexture : register(t0); cbuffer ComputeParams : register(b0) { uint2 TextureSize; float BlurRadius; float _Pad; }; [numthreads(8, 8, 1)] void CSMain(uint3 id : SV_DispatchThreadID) { if (id.x >= TextureSize.x || id.y >= TextureSize.y) return; float4 color = InputTexture[id.xy]; float3 remapped = float3( Remap(color.r, 0.0, 1.0, 0.2, 0.8), Remap(color.g, 0.0, 1.0, 0.2, 0.8), Remap(color.b, 0.0, 1.0, 0.2, 0.8) ); OutputTexture[id.xy] = float4(remapped, color.a); } ================================================ FILE: test/resources/repos/hlsl/test_repo/lighting.hlsl ================================================ #ifndef LIGHTING_HLSL #define LIGHTING_HLSL #include "common.hlsl" cbuffer LightingConstants : register(b0) { float4x4 ViewProjection; float3 LightDirection; float LightIntensity; float3 AmbientColor; float _Padding; }; float3 CalculateDiffuse(float3 normal, float3 lightDir, float3 albedo) { float ndotl = max(dot(normal, -lightDir), 0.0); return albedo * ndotl; } float3 CalculateSpecular(float3 normal, float3 lightDir, float3 viewDir, float shininess) { float3 halfVec = SafeNormalize(-lightDir + viewDir); float ndoth = max(dot(normal, halfVec), 0.0); return pow(ndoth, shininess); } VertexOutput TransformVertex(VertexInput input) { VertexOutput output; output.clipPos = mul(ViewProjection, float4(input.position, 1.0)); output.worldNormal = input.normal; output.uv = input.uv; return output; } #endif // LIGHTING_HLSL ================================================ FILE: test/resources/repos/hlsl/test_repo/terrain/terrain_sdf.hlsl ================================================ #ifndef TERRAIN_SDF_HLSL #define TERRAIN_SDF_HLSL #include "../common.hlsl" struct SDFBrickData { float3 center; float halfExtent; int resolution; float maxDistance; }; float3 WorldOffset; float SampleSDF(float3 worldPos, SDFBrickData brick) { float3 localPos = worldPos - brick.center; float dist = length(localPos) - brick.halfExtent; return dist; } float3 CalculateGradient(float3 worldPos, SDFBrickData brick) { float eps = 0.01; float3 gradient; gradient.x = SampleSDF(worldPos + float3(eps, 0, 0), brick) - SampleSDF(worldPos - float3(eps, 0, 0), brick); gradient.y = SampleSDF(worldPos + float3(0, eps, 0), brick) - SampleSDF(worldPos - float3(0, eps, 0), brick); gradient.z = SampleSDF(worldPos + float3(0, 0, eps), brick) - SampleSDF(worldPos - float3(0, 0, eps), brick); return SafeNormalize(gradient); } #endif // TERRAIN_SDF_HLSL ================================================ FILE: test/resources/repos/java/test_repo/pom.xml ================================================ 4.0.0 org.example test_repo 1.0-SNAPSHOT jar Java Test Repo 21 21 3.13.0 org.apache.maven.plugins maven-compiler-plugin ${maven.compiler.plugin.version} 21 21 ================================================ FILE: test/resources/repos/java/test_repo/src/main/java/test_repo/Main.java ================================================ package test_repo; public class Main { public static void main(String[] args) { Utils.printHello(); Model model = new Model("Cascade"); System.out.println(model.getName()); acceptModel(model); } public static void acceptModel(Model m) { // Do nothing, just for LSP reference } } ================================================ FILE: test/resources/repos/java/test_repo/src/main/java/test_repo/Model.java ================================================ package test_repo; /** * A simple model class that holds a name and provides methods to retrieve it. */ public class Model { private String name; public Model(String name) { this.name = name; } public String getName() { return name; } public String getName(int maxChars) { if (name.length() <= maxChars) { return name; } else { return name.substring(0, maxChars) + "..."; } } } ================================================ FILE: test/resources/repos/java/test_repo/src/main/java/test_repo/ModelUser.java ================================================ package test_repo; public class ModelUser { public static void main(String[] args) { Model model = new Model("Cascade"); System.out.println(model.getName()); } } ================================================ FILE: test/resources/repos/java/test_repo/src/main/java/test_repo/Utils.java ================================================ package test_repo; public class Utils { public static void printHello() { System.out.println("Hello from Utils!"); } } ================================================ FILE: test/resources/repos/julia/test_repo/lib/helper.jl ================================================ module Helper function say_hello() println("Hello from the helper module!") end end ================================================ FILE: test/resources/repos/julia/test_repo/main.jl ================================================ include("lib/helper.jl") function calculate_sum(a, b) return a + b end function main() result = calculate_sum(5, 3) # A within-file reference println(result) Helper.say_hello() # A cross-file reference end main() ================================================ FILE: test/resources/repos/kotlin/test_repo/.gitignore ================================================ .gradle/ ================================================ FILE: test/resources/repos/kotlin/test_repo/build.gradle.kts ================================================ plugins { kotlin("jvm") version "1.9.21" application } group = "test.serena" version = "1.0-SNAPSHOT" repositories { mavenCentral() } ================================================ FILE: test/resources/repos/kotlin/test_repo/src/main/kotlin/test_repo/Main.kt ================================================ package test_repo object Main { @JvmStatic fun main(args: Array) { Utils.printHello() val model = Model("Cascade") println(model.name) acceptModel(model) } fun acceptModel(m: Model?) { // Do nothing, just for LSP reference } } ================================================ FILE: test/resources/repos/kotlin/test_repo/src/main/kotlin/test_repo/Model.kt ================================================ package test_repo data class Model(val name: String) ================================================ FILE: test/resources/repos/kotlin/test_repo/src/main/kotlin/test_repo/ModelUser.kt ================================================ package test_repo object ModelUser { @JvmStatic fun main(args: Array) { val model = Model("Cascade") println(model.name) } } ================================================ FILE: test/resources/repos/kotlin/test_repo/src/main/kotlin/test_repo/Utils.kt ================================================ package test_repo object Utils { fun printHello() { println("Hello from Utils!") } } ================================================ FILE: test/resources/repos/lean4/test_repo/Helper.lean ================================================ structure Calculator where name : String version : Nat deriving Repr def add (x y : Nat) : Nat := x + y def subtract (x y : Nat) : Int := Int.ofNat x - Int.ofNat y def isPositive (x : Int) : Bool := x > 0 def absolute (x : Int) : Int := if isPositive x then x else -x ================================================ FILE: test/resources/repos/lean4/test_repo/Main.lean ================================================ import Helper def multiply (x y : Nat) : Nat := x * y def calculate (c : Calculator) (op : String) (x y : Nat) : Option Int := match op with | "add" => some (Int.ofNat (add x y)) | "subtract" => some (subtract x y) | "multiply" => some (Int.ofNat (multiply x y)) | _ => none def main : IO Unit := do let c : Calculator := { name := "TestCalc", version := 1 } IO.println s!"Using {c.name} version {c.version}" let result1 := add 5 3 IO.println s!"5 + 3 = {result1}" let result2 := subtract 10 4 IO.println s!"10 - 4 = {result2}" match calculate c "multiply" 6 7 with | some result => IO.println s!"6 * 7 = {result}" | none => IO.println "Calculation failed" IO.println s!"Is 5 positive? {isPositive 5}" IO.println s!"Absolute of -10: {absolute (-10)}" ================================================ FILE: test/resources/repos/lean4/test_repo/lake-manifest.json ================================================ {"version": "1.1.0", "packagesDir": ".lake/packages", "packages": [], "name": "test_repo", "lakeDir": ".lake"} ================================================ FILE: test/resources/repos/lean4/test_repo/lakefile.lean ================================================ import Lake open Lake DSL package «test_repo» where leanOptions := #[⟨`autoImplicit, false⟩] @[default_target] lean_lib «Main» where roots := #[`Main, `Helper] ================================================ FILE: test/resources/repos/lean4/test_repo/lean-toolchain ================================================ leanprover/lean4:stable ================================================ FILE: test/resources/repos/lua/test_repo/.gitignore ================================================ # Lua specific *.luac .luarocks/ lua_modules/ luarocks/ # Build artifacts build/ dist/ # IDE .vscode/ .idea/ ================================================ FILE: test/resources/repos/lua/test_repo/main.lua ================================================ #!/usr/bin/env lua -- main.lua: Entry point for the test application local calculator = require("src.calculator") local utils = require("src.utils") local function print_banner() print("=" .. string.rep("=", 40)) print(" Lua Test Repository") print("=" .. string.rep("=", 40)) end local function test_calculator() print("\nTesting Calculator Module:") print("5 + 3 =", calculator.add(5, 3)) print("10 - 4 =", calculator.subtract(10, 4)) print("6 * 7 =", calculator.multiply(6, 7)) print("15 / 3 =", calculator.divide(15, 3)) print("2^8 =", calculator.power(2, 8)) print("5! =", calculator.factorial(5)) local numbers = {5, 2, 8, 3, 9, 1, 7} print("Mean of", table.concat(numbers, ", "), "=", calculator.mean(numbers)) print("Median of", table.concat(numbers, ", "), "=", calculator.median(numbers)) end local function test_utils() print("\nTesting Utils Module:") -- String utilities print("Trimmed ' hello ' =", "'" .. utils.trim(" hello ") .. "'") local parts = utils.split("apple,banana,orange", ",") print("Split 'apple,banana,orange' by ',' =", table.concat(parts, " | ")) print("'hello' starts with 'he' =", utils.starts_with("hello", "he")) print("'world' ends with 'ld' =", utils.ends_with("world", "ld")) -- Table utilities local t1 = {a = 1, b = 2} local t2 = {b = 3, c = 4} local merged = utils.table_merge(t1, t2) print("Merged tables: a=" .. (merged.a or "nil") .. ", b=" .. (merged.b or "nil") .. ", c=" .. (merged.c or "nil")) -- Logger local logger = utils.Logger:new("TestApp") logger:info("Application started") logger:debug("Debug information") logger:warn("This is a warning") end local function interactive_calculator() print("\nInteractive Calculator (type 'quit' to exit):") while true do io.write("Enter operation (e.g., '5 + 3'): ") local input = io.read() if input == "quit" then break end -- Simple parser for basic operations local a, op, b = input:match("(%d+)%s*([%+%-%*/])%s*(%d+)") if a and op and b then a = tonumber(a) b = tonumber(b) local result if op == "+" then result = calculator.add(a, b) elseif op == "-" then result = calculator.subtract(a, b) elseif op == "*" then result = calculator.multiply(a, b) elseif op == "/" then local success, res = pcall(calculator.divide, a, b) if success then result = res else print("Error: " .. res) goto continue end end print("Result: " .. result) else print("Invalid input. Please use format: number operator number") end ::continue:: end end -- Main execution local function main(args) print_banner() if #args == 0 then test_calculator() test_utils() elseif args[1] == "interactive" then interactive_calculator() elseif args[1] == "test" then test_calculator() test_utils() print("\nAll tests completed!") else print("Usage: lua main.lua [interactive|test]") end end -- Run main function main(arg or {}) ================================================ FILE: test/resources/repos/lua/test_repo/src/calculator.lua ================================================ -- calculator.lua: A simple calculator module for testing LSP features local calculator = {} -- Basic arithmetic operations function calculator.add(a, b) return a + b end function calculator.subtract(a, b) return a - b end function calculator.multiply(a, b) return a * b end function calculator.divide(a, b) if b == 0 then error("Division by zero") end return a / b end -- Advanced operations function calculator.power(base, exponent) return base ^ exponent end function calculator.factorial(n) if n < 0 then error("Factorial is not defined for negative numbers") elseif n == 0 or n == 1 then return 1 else local result = 1 for i = 2, n do result = result * i end return result end end -- Statistics functions function calculator.mean(numbers) if #numbers == 0 then return nil end local sum = 0 for _, num in ipairs(numbers) do sum = sum + num end return sum / #numbers end function calculator.median(numbers) if #numbers == 0 then return nil end local sorted = {} for i, v in ipairs(numbers) do sorted[i] = v end table.sort(sorted) local mid = math.floor(#sorted / 2) if #sorted % 2 == 0 then return (sorted[mid] + sorted[mid + 1]) / 2 else return sorted[mid + 1] end end return calculator ================================================ FILE: test/resources/repos/lua/test_repo/src/utils.lua ================================================ -- utils.lua: Utility functions for the test repository local utils = {} -- String utilities function utils.trim(s) return s:match("^%s*(.-)%s*$") end function utils.split(str, delimiter) local result = {} local pattern = string.format("([^%s]+)", delimiter) for match in string.gmatch(str, pattern) do table.insert(result, match) end return result end function utils.starts_with(str, prefix) return str:sub(1, #prefix) == prefix end function utils.ends_with(str, suffix) return str:sub(-#suffix) == suffix end -- Table utilities function utils.deep_copy(orig) local orig_type = type(orig) local copy if orig_type == 'table' then copy = {} for orig_key, orig_value in next, orig, nil do copy[utils.deep_copy(orig_key)] = utils.deep_copy(orig_value) end setmetatable(copy, utils.deep_copy(getmetatable(orig))) else copy = orig end return copy end function utils.table_contains(tbl, value) for _, v in ipairs(tbl) do if v == value then return true end end return false end function utils.table_merge(t1, t2) local result = {} for k, v in pairs(t1) do result[k] = v end for k, v in pairs(t2) do result[k] = v end return result end -- File utilities function utils.read_file(path) local file = io.open(path, "r") if not file then return nil, "Could not open file: " .. path end local content = file:read("*all") file:close() return content end function utils.write_file(path, content) local file = io.open(path, "w") if not file then return false, "Could not open file for writing: " .. path end file:write(content) file:close() return true end -- Class-like structure utils.Logger = {} utils.Logger.__index = utils.Logger function utils.Logger:new(name) local self = setmetatable({}, utils.Logger) self.name = name or "default" self.level = "info" return self end function utils.Logger:set_level(level) self.level = level end function utils.Logger:log(message, level) level = level or self.level print(string.format("[%s] %s: %s", self.name, level:upper(), message)) end function utils.Logger:debug(message) self:log(message, "debug") end function utils.Logger:info(message) self:log(message, "info") end function utils.Logger:warn(message) self:log(message, "warn") end function utils.Logger:error(message) self:log(message, "error") end return utils ================================================ FILE: test/resources/repos/lua/test_repo/tests/test_calculator.lua ================================================ -- test_calculator.lua: Unit tests for calculator module local calculator = require("src.calculator") local function assert_equals(actual, expected, message) if actual ~= expected then error(string.format("%s: expected %s, got %s", message or "Assertion failed", tostring(expected), tostring(actual))) end end local function assert_error(func, message) local success = pcall(func) if success then error(string.format("%s: expected error but none was thrown", message or "Assertion failed")) end end local function test_basic_operations() print("Testing basic operations...") assert_equals(calculator.add(2, 3), 5, "Addition test") assert_equals(calculator.add(-5, 5), 0, "Addition with negative") assert_equals(calculator.add(0, 0), 0, "Addition with zeros") assert_equals(calculator.subtract(10, 3), 7, "Subtraction test") assert_equals(calculator.subtract(5, 10), -5, "Subtraction negative result") assert_equals(calculator.multiply(4, 5), 20, "Multiplication test") assert_equals(calculator.multiply(-3, 4), -12, "Multiplication with negative") assert_equals(calculator.multiply(0, 100), 0, "Multiplication with zero") assert_equals(calculator.divide(10, 2), 5, "Division test") assert_equals(calculator.divide(7, 2), 3.5, "Division with decimal result") assert_error(function() calculator.divide(5, 0) end, "Division by zero") print("✓ Basic operations tests passed") end local function test_advanced_operations() print("Testing advanced operations...") assert_equals(calculator.power(2, 3), 8, "Power test") assert_equals(calculator.power(5, 0), 1, "Power of zero") assert_equals(calculator.power(10, -1), 0.1, "Negative exponent") assert_equals(calculator.factorial(0), 1, "Factorial of 0") assert_equals(calculator.factorial(1), 1, "Factorial of 1") assert_equals(calculator.factorial(5), 120, "Factorial of 5") assert_equals(calculator.factorial(10), 3628800, "Factorial of 10") assert_error(function() calculator.factorial(-1) end, "Factorial of negative") print("✓ Advanced operations tests passed") end local function test_statistics() print("Testing statistics functions...") -- Mean tests assert_equals(calculator.mean({1, 2, 3, 4, 5}), 3, "Mean of sequential numbers") assert_equals(calculator.mean({10}), 10, "Mean of single number") assert_equals(calculator.mean({-5, 5}), 0, "Mean with negatives") assert_equals(calculator.mean({}), nil, "Mean of empty array") -- Median tests assert_equals(calculator.median({1, 2, 3, 4, 5}), 3, "Median of odd count") assert_equals(calculator.median({1, 2, 3, 4}), 2.5, "Median of even count") assert_equals(calculator.median({5, 1, 3, 2, 4}), 3, "Median of unsorted") assert_equals(calculator.median({7}), 7, "Median of single number") assert_equals(calculator.median({}), nil, "Median of empty array") print("✓ Statistics tests passed") end -- Run all tests local function run_all_tests() print("Running calculator tests...\n") test_basic_operations() test_advanced_operations() test_statistics() print("\n✅ All calculator tests passed!") end -- Execute tests if run directly if arg and arg[0] and arg[0]:match("test_calculator%.lua$") then run_all_tests() end return { run_all_tests = run_all_tests, test_basic_operations = test_basic_operations, test_advanced_operations = test_advanced_operations, test_statistics = test_statistics } ================================================ FILE: test/resources/repos/luau/test_repo/.luaurc ================================================ { "languageMode": "strict", "lint": { "*": true }, "aliases": { "Packages": "Packages/" } } ================================================ FILE: test/resources/repos/luau/test_repo/src/init.luau ================================================ local module = require("./module") export type Config = { name: string, value: number, enabled: boolean, } local function createConfig(name: string, value: number): Config return { name = name, value = value, enabled = true, } end local function main() local config = createConfig("test", 42) local result = module.process(config) print(result) end return { createConfig = createConfig, main = main, } ================================================ FILE: test/resources/repos/luau/test_repo/src/module.luau ================================================ local function process(data: { name: string, value: number }): string return `Processing {data.name} with value {data.value}` end local function helper(x: number, y: number): number return x + y end return { process = process, helper = helper, } ================================================ FILE: test/resources/repos/markdown/test_repo/CONTRIBUTING.md ================================================ # Contributing Guidelines Thank you for considering contributing to this project! ## Table of Contents - [Code of Conduct](#code-of-conduct) - [Getting Started](#getting-started) - [Development Setup](#development-setup) - [Submitting Changes](#submitting-changes) ## Code of Conduct Please be respectful and considerate in all interactions. ## Getting Started To contribute: 1. Fork the repository 2. Create a feature branch 3. Make your changes 4. Submit a pull request ## Development Setup ### Prerequisites - Git - Node.js (v16+) - npm or yarn ### Installation Steps ```bash git clone https://github.com/example/repo.git cd repo npm install ``` ## Submitting Changes ### Pull Request Process 1. Update documentation 2. Add tests for new features 3. Ensure all tests pass 4. Update the [README](README.md) ### Commit Messages Use clear and descriptive commit messages: - feat: Add new feature - fix: Bug fix - docs: Documentation changes - test: Add or update tests ## Testing Run the test suite before submitting: ```bash npm test ``` For more information, see: - [User Guide](guide.md) - [API Reference](api.md) ## Questions? Contact the maintainers or open an issue. ================================================ FILE: test/resources/repos/markdown/test_repo/README.md ================================================ # Test Repository This is a test repository for markdown language server testing. ## Overview This repository contains sample markdown files for testing LSP features. ## Features - Document symbol detection - Link navigation - Reference finding - Code completion ### Installation To use this test repository: 1. Clone the repository 2. Install dependencies 3. Run tests ### Usage See [guide.md](guide.md) for detailed usage instructions. ## Code Examples Here's a simple example: ```python def hello_world(): print("Hello, World!") ``` ### JavaScript Example ```javascript function greet(name) { console.log(`Hello, ${name}!`); } ``` ## References - [Official Documentation](https://example.com/docs) - [API Reference](api.md) - [Contributing Guide](CONTRIBUTING.md) ## License MIT License ================================================ FILE: test/resources/repos/markdown/test_repo/api.md ================================================ # API Reference Complete API documentation for the test repository. ## Classes ### Client The main client class for interacting with the API. #### Methods ##### `connect()` Establishes a connection to the server. **Parameters:** - `host`: Server hostname - `port`: Server port number **Returns:** Connection object ##### `disconnect()` Closes the connection to the server. **Returns:** None ### Server Server-side implementation. #### Configuration ```json { "host": "localhost", "port": 8080, "timeout": 30 } ``` ## Functions ### `initialize(config)` Initializes the system with the provided configuration. **Parameters:** - `config`: Configuration dictionary **Example:** ```python config = { "host": "localhost", "port": 8080 } initialize(config) ``` ### `shutdown()` Gracefully shuts down the system. ## Error Handling Common errors and their solutions: - `ConnectionError`: Check network connectivity - `TimeoutError`: Increase timeout value - `ConfigError`: Validate configuration file ## See Also - [User Guide](guide.md) - [README](README.md) - [Contributing](CONTRIBUTING.md) ================================================ FILE: test/resources/repos/markdown/test_repo/guide.md ================================================ # User Guide This guide provides detailed instructions for using the test repository. ## Getting Started Welcome to the user guide. This document covers: - Basic concepts - Advanced features - Troubleshooting ### Basic Concepts The fundamental concepts you need to understand: #### Headers and Structure Markdown documents use headers to create structure. Headers are created using `#` symbols. #### Links and References Internal links can reference other documents: - [Back to README](README.md) - [See API documentation](api.md) ### Advanced Features For advanced users, we provide: 1. Custom extensions 2. Plugin support 3. Theme customization ## Configuration Configuration options are stored in `config.yaml`: ```yaml server: port: 8080 host: localhost ``` ## Troubleshooting If you encounter issues: 1. Check the [README](README.md) first 2. Review [common issues](CONTRIBUTING.md) 3. Contact support ## Next Steps After reading this guide, check out: - [API Reference](api.md) - [Contributing Guidelines](CONTRIBUTING.md) ================================================ FILE: test/resources/repos/matlab/test_repo/Calculator.m ================================================ classdef Calculator < handle % Calculator A simple calculator class for testing MATLAB LSP % % This class provides basic arithmetic operations and demonstrates % MATLAB class structure for LSP testing purposes. properties LastResult double = 0 History cell = {} end properties (Access = private) OperationCount uint32 = 0 end methods function obj = Calculator() % Constructor for Calculator class obj.LastResult = 0; obj.History = {}; obj.OperationCount = 0; end function result = add(obj, a, b) % ADD Add two numbers % result = add(obj, a, b) returns the sum of a and b result = a + b; obj.updateState(result, 'add'); end function result = subtract(obj, a, b) % SUBTRACT Subtract b from a % result = subtract(obj, a, b) returns a - b result = a - b; obj.updateState(result, 'subtract'); end function result = multiply(obj, a, b) % MULTIPLY Multiply two numbers % result = multiply(obj, a, b) returns a * b result = a * b; obj.updateState(result, 'multiply'); end function result = divide(obj, a, b) % DIVIDE Divide a by b % result = divide(obj, a, b) returns a / b % Throws error if b is zero if b == 0 error('Calculator:DivisionByZero', 'Cannot divide by zero'); end result = a / b; obj.updateState(result, 'divide'); end function displayHistory(obj) % DISPLAYHISTORY Display the calculation history fprintf('Calculation History:\n'); for i = 1:length(obj.History) fprintf(' %d: %s = %.4f\n', i, obj.History{i}.operation, obj.History{i}.result); end end end methods (Access = private) function updateState(obj, result, operation) % Update internal state after an operation obj.LastResult = result; obj.OperationCount = obj.OperationCount + 1; obj.History{end+1} = struct('operation', operation, 'result', result); end end methods (Static) function result = power(base, exponent) % POWER Compute base raised to exponent % result = Calculator.power(base, exponent) returns base^exponent result = base ^ exponent; end end end ================================================ FILE: test/resources/repos/matlab/test_repo/main.m ================================================ % MAIN Main script demonstrating Calculator and mathUtils usage % % This script shows how to use the Calculator class and mathUtils % functions together for various mathematical operations. % Add lib folder to path addpath('lib'); %% Section 1: Basic Calculator Operations % Create a calculator instance and perform basic operations calc = Calculator(); % Perform some calculations sum_result = calc.add(10, 5); fprintf('10 + 5 = %d\n', sum_result); diff_result = calc.subtract(10, 3); fprintf('10 - 3 = %d\n', diff_result); prod_result = calc.multiply(4, 7); fprintf('4 * 7 = %d\n', prod_result); quot_result = calc.divide(20, 4); fprintf('20 / 4 = %d\n', quot_result); %% Section 2: Static Method Usage % Use the static power method power_result = Calculator.power(2, 10); fprintf('2^10 = %d\n', power_result); %% Section 3: Math Utilities % Test the mathUtils functions % Factorial fact5 = mathUtils('factorial', 5); fprintf('5! = %d\n', fact5); % Fibonacci fib10 = mathUtils('fibonacci', 10); fprintf('Fibonacci(10) = %d\n', fib10); % Prime check is17prime = mathUtils('isPrime', 17); fprintf('Is 17 prime? %s\n', mat2str(is17prime)); % Statistics data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; [dataMean, dataStd] = mathUtils('stats', data); fprintf('Mean: %.2f, Std: %.2f\n', dataMean, dataStd); %% Section 4: Display History % Show all calculations performed by the calculator calc.displayHistory(); %% Section 5: Error Handling % Demonstrate error handling with division by zero try calc.divide(10, 0); catch ME fprintf('Caught expected error: %s\n', ME.message); end fprintf('\nAll tests completed successfully!\n'); ================================================ FILE: test/resources/repos/nix/test_repo/.gitignore ================================================ # Nix specific result result-* .direnv/ # Build artifacts *.drv # IDE .vscode/ .idea/ ================================================ FILE: test/resources/repos/nix/test_repo/default.nix ================================================ # default.nix - Traditional Nix expression for backwards compatibility { pkgs ? import { } }: let # Import library functions lib = pkgs.lib; stdenv = pkgs.stdenv; # Import our custom utilities utils = import ./lib/utils.nix { inherit lib; }; # Custom function to create a greeting makeGreeting = name: "Hello, ${name}!"; # List manipulation functions (using imported utils) listUtils = { double = list: map (x: x * 2) list; sum = list: lib.foldl' (acc: x: acc + x) 0 list; average = list: if list == [ ] then 0 else (listUtils.sum list) / (builtins.length list); # Use function from imported utils unique = utils.lists.unique; }; # String utilities stringUtils = rec { capitalize = str: let first = lib.substring 0 1 str; rest = lib.substring 1 (-1) str; in (lib.toUpper first) + rest; repeat = n: str: lib.concatStrings (lib.genList (_: str) n); padLeft = width: char: str: let len = lib.stringLength str; padding = if len >= width then 0 else width - len; in (repeat padding char) + str; }; # Package builder helper buildSimplePackage = { name, version, script }: stdenv.mkDerivation { pname = name; inherit version; phases = [ "installPhase" ]; installPhase = '' mkdir -p $out/bin cat > $out/bin/${name} << EOF #!/usr/bin/env bash ${script} EOF chmod +x $out/bin/${name} ''; }; in rec { # Export utilities inherit listUtils stringUtils makeGreeting; # Export imported utilities directly inherit (utils) math strings; # Example packages hello = buildSimplePackage { name = "hello"; version = "1.0"; script = '' echo "${makeGreeting "World"}" ''; }; calculator = buildSimplePackage { name = "calculator"; version = "0.1"; script = '' if [ $# -ne 3 ]; then echo "Usage: calculator " exit 1 fi case $2 in +) echo $(($1 + $3)) ;; -) echo $(($1 - $3)) ;; x) echo $(($1 * $3)) ;; /) echo $(($1 / $3)) ;; *) echo "Unknown operator: $2" ;; esac ''; }; # Environment with multiple packages devEnv = pkgs.buildEnv { name = "dev-environment"; paths = with pkgs; [ git vim bash hello calculator ]; }; # Shell derivation shell = pkgs.mkShell { buildInputs = with pkgs; [ bash coreutils findutils gnugrep gnused ]; shellHook = '' echo "Entering Nix shell environment" echo "Available custom functions: makeGreeting, listUtils, stringUtils" ''; }; # Configuration example config = { system = { stateVersion = "23.11"; enable = true; }; services = { nginx = { enable = false; virtualHosts = { "example.com" = { root = "/var/www/example"; locations."/" = { index = "index.html"; }; }; }; }; }; users = { testUser = { name = "test"; group = "users"; home = "/home/test"; shell = "${pkgs.bash}/bin/bash"; }; }; }; # Recursive attribute set example tree = { root = { value = 1; left = { value = 2; left = { value = 4; }; right = { value = 5; }; }; right = { value = 3; left = { value = 6; }; right = { value = 7; }; }; }; # Tree traversal function traverse = node: if node ? left && node ? right then [ node.value ] ++ (tree.traverse node.left) ++ (tree.traverse node.right) else if node ? value then [ node.value ] else [ ]; }; } ================================================ FILE: test/resources/repos/nix/test_repo/flake.nix ================================================ { description = "Test Nix flake for language server testing"; inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; flake-utils.url = "github:numtide/flake-utils"; }; outputs = { self, nixpkgs, flake-utils }: flake-utils.lib.eachDefaultSystem (system: let pkgs = nixpkgs.legacyPackages.${system}; # Import our default.nix for shared logic defaultNix = import ./default.nix { inherit pkgs; }; # Custom derivation for testing hello-custom = pkgs.stdenv.mkDerivation { pname = "hello-custom"; version = "1.0.0"; src = ./.; buildInputs = with pkgs; [ bash coreutils ]; installPhase = '' mkdir -p $out/bin cp ${./scripts/hello.sh} $out/bin/hello-custom chmod +x $out/bin/hello-custom ''; meta = with pkgs.lib; { description = "A custom hello world script"; license = licenses.mit; platforms = platforms.all; }; }; # Development shell configuration devShell = pkgs.mkShell { buildInputs = with pkgs; [ # Development tools git gnumake gcc # Nix tools nix-prefetch-git nixpkgs-fmt nil # Languages python3 nodejs rustc cargo ]; shellHook = '' echo "Welcome to the Nix development shell!" echo "Available tools: git, make, gcc, python3, nodejs, rustc" ''; }; in { # Packages packages = { default = hello-custom; inherit hello-custom; # Another package for testing utils = pkgs.stdenv.mkDerivation { pname = "test-utils"; version = "0.1.0"; src = ./.; installPhase = '' mkdir -p $out/share echo "Utility functions" > $out/share/utils.txt ''; }; }; # Apps apps = { default = { type = "app"; program = "${hello-custom}/bin/hello-custom"; }; hello = { type = "app"; program = "${hello-custom}/bin/hello-custom"; }; }; # Development shells devShells = { default = devShell; # Minimal shell for testing minimal = pkgs.mkShell { buildInputs = with pkgs; [ bash coreutils ]; }; }; # Overlay overlays.default = final: prev: { inherit hello-custom; }; # NixOS module nixosModules.default = { config, lib, pkgs, ... }: with lib; { options.services.hello-custom = { enable = mkEnableOption "hello-custom service"; message = mkOption { type = types.str; default = "Hello from NixOS!"; description = "Message to display"; }; }; config = mkIf config.services.hello-custom.enable { systemd.services.hello-custom = { description = "Hello Custom Service"; wantedBy = [ "multi-user.target" ]; serviceConfig = { ExecStart = "${hello-custom}/bin/hello-custom"; Type = "oneshot"; }; }; }; }; } ); } ================================================ FILE: test/resources/repos/nix/test_repo/lib/utils.nix ================================================ # Utility functions library { lib }: rec { # Math utilities math = { # Calculate factorial factorial = n: if n == 0 then 1 else n * factorial (n - 1); # Calculate fibonacci number fibonacci = n: if n <= 1 then n else (fibonacci (n - 1)) + (fibonacci (n - 2)); # Check if number is prime isPrime = n: let checkDivisible = i: if i * i > n then true else if lib.mod n i == 0 then false else checkDivisible (i + 1); in if n <= 1 then false else if n <= 3 then true else checkDivisible 2; # Greatest common divisor gcd = a: b: if b == 0 then a else gcd b (lib.mod a b); }; # String manipulation strings = { # Reverse a string reverse = str: let len = lib.stringLength str; chars = lib.genList (i: lib.substring (len - i - 1) 1 str) len; in lib.concatStrings chars; # Check if string is palindrome isPalindrome = str: str == strings.reverse str; # Convert to camelCase toCamelCase = str: let words = lib.splitString "-" str; capitalize = w: if w == "" then "" else (lib.toUpper (lib.substring 0 1 w)) + (lib.substring 1 (-1) w); capitalizedWords = lib.tail (map capitalize words); in (lib.head words) + (lib.concatStrings capitalizedWords); # Convert to snake_case toSnakeCase = str: lib.replaceStrings ["-"] ["_"] (lib.toLower str); }; # List operations lists = { # Get unique elements unique = list: lib.foldl' (acc: x: if lib.elem x acc then acc else acc ++ [x] ) [] list; # Zip two lists zip = list1: list2: let len1 = lib.length list1; len2 = lib.length list2; minLen = if len1 < len2 then len1 else len2; in lib.genList (i: { fst = lib.elemAt list1 i; snd = lib.elemAt list2 i; }) minLen; # Flatten nested list flatten = list: lib.foldl' (acc: x: if builtins.isList x then acc ++ (flatten x) else acc ++ [x] ) [] list; # Partition list by predicate partition = pred: list: lib.foldl' (acc: x: if pred x then { yes = acc.yes ++ [x]; no = acc.no; } else { yes = acc.yes; no = acc.no ++ [x]; } ) { yes = []; no = []; } list; }; # Attribute set operations attrs = { # Deep merge two attribute sets deepMerge = attr1: attr2: lib.recursiveUpdate attr1 attr2; # Filter attributes by predicate filterAttrs = pred: attrs: lib.filterAttrs pred attrs; # Map over attribute values mapValues = f: attrs: lib.mapAttrs (name: value: f value) attrs; # Get nested attribute safely getAttrPath = path: default: attrs: lib.attrByPath path default attrs; }; # File system utilities files = { # Read JSON file readJSON = path: builtins.fromJSON (builtins.readFile path); # Read TOML file readTOML = path: builtins.fromTOML (builtins.readFile path); # Check if path exists pathExists = path: builtins.pathExists path; # Get file type getFileType = path: let type = builtins.readFileType path; in if type == "directory" then "dir" else if type == "regular" then "file" else if type == "symlink" then "link" else "unknown"; }; # Validation utilities validate = { # Check if value is email isEmail = str: builtins.match "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$" str != null; # Check if value is URL isURL = str: builtins.match "^https?://[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}" str != null; # Check if value is valid version isVersion = str: builtins.match "^[0-9]+\\.[0-9]+\\.[0-9]+$" str != null; }; } ================================================ FILE: test/resources/repos/nix/test_repo/modules/example.nix ================================================ # Example NixOS module { config, lib, pkgs, ... }: with lib; let cfg = config.services.example; # Helper function to generate config file generateConfig = settings: '' # Generated configuration ${lib.concatStringsSep "\n" (lib.mapAttrsToList (k: v: "${k} = ${toString v}") settings)} ''; in { # Module options options = { services.example = { enable = mkEnableOption "example service"; package = mkOption { type = types.package; default = pkgs.hello; description = "Package to use for the service"; }; port = mkOption { type = types.port; default = 8080; description = "Port to listen on"; }; host = mkOption { type = types.str; default = "localhost"; description = "Host to bind to"; }; workers = mkOption { type = types.int; default = 4; description = "Number of worker processes"; }; settings = mkOption { type = types.attrsOf types.anything; default = {}; description = "Additional settings"; }; users = mkOption { type = types.listOf types.str; default = []; description = "List of users with access"; }; database = { enable = mkOption { type = types.bool; default = false; description = "Enable database support"; }; type = mkOption { type = types.enum [ "postgresql" "mysql" "sqlite" ]; default = "sqlite"; description = "Database type"; }; host = mkOption { type = types.str; default = "localhost"; description = "Database host"; }; name = mkOption { type = types.str; default = "example"; description = "Database name"; }; }; }; }; # Module configuration config = mkIf cfg.enable { # System packages environment.systemPackages = [ cfg.package ]; # Systemd service systemd.services.example = { description = "Example Service"; after = [ "network.target" ] ++ (optional cfg.database.enable "postgresql.service"); wantedBy = [ "multi-user.target" ]; serviceConfig = { Type = "simple"; User = "example"; Group = "example"; ExecStart = "${cfg.package}/bin/example --port ${toString cfg.port} --host ${cfg.host}"; Restart = "on-failure"; RestartSec = 5; # Security hardening PrivateTmp = true; ProtectSystem = "strict"; ProtectHome = true; NoNewPrivileges = true; }; environment = { EXAMPLE_WORKERS = toString cfg.workers; EXAMPLE_CONFIG = generateConfig cfg.settings; } // optionalAttrs cfg.database.enable { DATABASE_TYPE = cfg.database.type; DATABASE_HOST = cfg.database.host; DATABASE_NAME = cfg.database.name; }; }; # User and group users.users.example = { isSystemUser = true; group = "example"; description = "Example service user"; }; users.groups.example = {}; # Firewall rules networking.firewall = mkIf (cfg.host == "0.0.0.0") { allowedTCPPorts = [ cfg.port ]; }; # Database setup services.postgresql = mkIf (cfg.database.enable && cfg.database.type == "postgresql") { enable = true; ensureDatabases = [ cfg.database.name ]; ensureUsers = [{ name = "example"; ensureDBOwnership = true; }]; }; }; } ================================================ FILE: test/resources/repos/nix/test_repo/scripts/hello.sh ================================================ #!/usr/bin/env bash # Simple hello script for testing echo "Hello from Nix!" ================================================ FILE: test/resources/repos/ocaml/test_repo/bin/dune ================================================ (executable (public_name test_repo) (name main) (libraries test_repo)) ================================================ FILE: test/resources/repos/ocaml/test_repo/bin/main.ml ================================================ open Test_repo let n = 20 let () = let res = fib n in Printf.printf "fib(%d) = %d\n" n res; let greeting = DemoModule.someFunction "Hello" in Printf.printf "%s\n" greeting ================================================ FILE: test/resources/repos/ocaml/test_repo/dune-project ================================================ (lang dune 3.18) (name test_repo) (generate_opam_files true) (source (github username/reponame)) (authors "Author Name ") (maintainers "Maintainer Name ") (license LICENSE) (documentation https://url/to/documentation) (package (name test_repo) (synopsis "A short synopsis") (description "A longer description") (depends ocaml) (tags ("add topics" "to describe" your project))) ; See the complete stanza docs at https://dune.readthedocs.io/en/stable/reference/dune-project/index.html ================================================ FILE: test/resources/repos/ocaml/test_repo/lib/dune ================================================ (library (public_name test_repo) (name test_repo)) ================================================ FILE: test/resources/repos/ocaml/test_repo/lib/test_repo.ml ================================================ module DemoModule = struct type value = string let someFunction s = s ^ " More String" end let rec fib n = if n < 2 then 1 else fib (n-1) + fib (n-2) let num_domains = 2 ================================================ FILE: test/resources/repos/ocaml/test_repo/lib/test_repo.mli ================================================ module DemoModule : sig type value = string val someFunction : string -> string end val fib : int -> int val num_domains : int ================================================ FILE: test/resources/repos/ocaml/test_repo/test/dune ================================================ (test (name test_test_repo) (libraries test_repo)) ================================================ FILE: test/resources/repos/ocaml/test_repo/test/test_test_repo.ml ================================================ open Test_repo let test_fib () = assert (fib 0 = 1); assert (fib 1 = 1); assert (fib 2 = 2); assert (fib 5 = 8); Printf.printf "fib tests passed\n" let test_demo_module () = let result = DemoModule.someFunction "Test" in assert (result = "Test More String"); Printf.printf "DemoModule tests passed\n" let () = test_fib (); test_demo_module (); Printf.printf "All tests passed!\n" ================================================ FILE: test/resources/repos/ocaml/test_repo/test_repo.opam ================================================ # This file is generated by dune, edit dune-project instead opam-version: "2.0" synopsis: "A short synopsis" description: "A longer description" maintainer: ["Maintainer Name "] authors: ["Author Name "] license: "LICENSE" tags: ["add topics" "to describe" "your" "project"] homepage: "https://github.com/username/reponame" doc: "https://url/to/documentation" bug-reports: "https://github.com/username/reponame/issues" depends: [ "dune" {>= "3.18"} "ocaml" "odoc" {with-doc} ] build: [ ["dune" "subst"] {dev} [ "dune" "build" "-p" name "-j" jobs "@install" "@runtest" {with-test} "@doc" {with-doc} ] ] dev-repo: "git+https://github.com/username/reponame.git" x-maintenance-intent: ["(latest)"] ================================================ FILE: test/resources/repos/pascal/test_repo/.gitignore ================================================ backup/ *.o *.ppu *.exe *.lps *.compiled __history/ __recovery/ ================================================ FILE: test/resources/repos/pascal/test_repo/main.pas ================================================ unit Main; {$mode objfpc}{$H+} interface uses Classes, SysUtils, Helper; type { TUser - A simple user class } TUser = class private FName: string; FAge: Integer; public constructor Create(const AName: string; AAge: Integer); destructor Destroy; override; function GetInfo: string; procedure UpdateAge(NewAge: Integer); property Name: string read FName write FName; property Age: Integer read FAge write FAge; end; { TUserManager - Manages multiple users } TUserManager = class private FUsers: TList; public constructor Create; destructor Destroy; override; procedure AddUser(User: TUser); function GetUserCount: Integer; function FindUserByName(const AName: string): TUser; end; { Helper functions } /// Calculates the sum of two integers. /// @param A First integer value /// @param B Second integer value /// @returns The sum of A and B function CalculateSum(A, B: Integer): Integer; procedure PrintMessage(const Msg: string); implementation { TUser implementation } constructor TUser.Create(const AName: string; AAge: Integer); begin inherited Create; FName := AName; FAge := AAge; end; destructor TUser.Destroy; begin inherited Destroy; end; function TUser.GetInfo: string; begin Result := Format('Name: %s, Age: %d', [FName, FAge]); end; procedure TUser.UpdateAge(NewAge: Integer); begin FAge := NewAge; end; { TUserManager implementation } constructor TUserManager.Create; begin inherited Create; FUsers := TList.Create; end; destructor TUserManager.Destroy; var i: Integer; begin for i := 0 to FUsers.Count - 1 do TUser(FUsers[i]).Free; FUsers.Free; inherited Destroy; end; procedure TUserManager.AddUser(User: TUser); begin FUsers.Add(User); end; function TUserManager.GetUserCount: Integer; begin Result := FUsers.Count; end; function TUserManager.FindUserByName(const AName: string): TUser; var i: Integer; begin Result := nil; for i := 0 to FUsers.Count - 1 do begin if TUser(FUsers[i]).Name = AName then begin Result := TUser(FUsers[i]); Exit; end; end; end; { Helper functions } function CalculateSum(A, B: Integer): Integer; begin Result := A + B; end; procedure PrintMessage(const Msg: string); begin WriteLn(Msg); end; end. ================================================ FILE: test/resources/repos/perl/test_repo/helper.pl ================================================ #!/usr/bin/env perl use strict; use warnings; sub helper_function { print "Helper function was called.\n"; } 1; ================================================ FILE: test/resources/repos/perl/test_repo/main.pl ================================================ #!/usr/bin/env perl use strict; use warnings; use lib '.'; require helper; sub greet { my ($name) = @_; return "Hello, $name!"; } my $user_name = "Perl User"; my $greeting = greet($user_name); print "$greeting\n"; helper_function(); sub use_helper_function { helper_function(); } ================================================ FILE: test/resources/repos/php/test_repo/helper.php ================================================ ================================================ FILE: test/resources/repos/php/test_repo/index.php ================================================ ================================================ FILE: test/resources/repos/php/test_repo/sample.php ================================================ name = $name; $this->age = $age; } public function getName(): string { return $this->name; } public function getAge(): int { return $this->age; } abstract public function describe(): string; } /** * A concrete animal that can greet visitors. */ class Dog extends Animal implements GreeterInterface { private string $breed; public function __construct(string $name, int $age, string $breed) { parent::__construct($name, $age); $this->breed = $breed; } public function greet(string $visitorName): string { return "Woof! I'm {$this->name}. Hello, {$visitorName}!"; } public function getBreed(): string { return $this->breed; } public function describe(): string { return "Dog: {$this->name} ({$this->breed}), age {$this->age}"; } public function fetch(string $item): string { return "{$this->name} fetches the {$item}!"; } } /** * Another concrete animal. */ class Cat extends Animal { private bool $indoor; public function __construct(string $name, int $age, bool $indoor = true) { parent::__construct($name, $age); $this->indoor = $indoor; } public function isIndoor(): bool { return $this->indoor; } public function describe(): string { $type = $this->indoor ? 'indoor' : 'outdoor'; return "Cat: {$this->name} ({$type}), age {$this->age}"; } } const MAX_ANIMALS = 100; const DEFAULT_BREED = 'Mixed'; /** * Factory function to create an animal by type name. */ function createAnimal(string $type, string $name, int $age): Animal { return match ($type) { 'dog' => new Dog($name, $age, DEFAULT_BREED), 'cat' => new Cat($name, $age), default => throw new \InvalidArgumentException("Unknown animal type: {$type}"), }; } /** * Returns a summary string for a list of animals. * * @param Animal[] $animals */ function summarizeAnimals(array $animals): string { return implode(', ', array_map(fn(Animal $a) => $a->describe(), $animals)); } ================================================ FILE: test/resources/repos/php/test_repo/simple_var.php ================================================ ================================================ FILE: test/resources/repos/powershell/test_repo/PowerShellEditorServices.json ================================================ {"status":"started","languageServiceTransport":"Stdio","powerShellVersion":"7.5.3"} ================================================ FILE: test/resources/repos/powershell/test_repo/main.ps1 ================================================ # Main script demonstrating various PowerShell features # Import utility functions . "$PSScriptRoot\utils.ps1" # Global variables $Script:ScriptName = "Main Script" $Script:Counter = 0 <# .SYNOPSIS Greets a user with various greeting styles. .PARAMETER Username The name of the user to greet. .PARAMETER GreetingType The type of greeting (formal, casual, or default). #> function Greet-User { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Username, [Parameter(Mandatory = $false)] [ValidateSet("formal", "casual", "default")] [string]$GreetingType = "default" ) switch ($GreetingType) { "formal" { Write-Output "Good day, $Username!" } "casual" { Write-Output "Hey $Username!" } default { Write-Output "Hello, $Username!" } } } <# .SYNOPSIS Processes an array of items with the specified operation. .PARAMETER Items The array of items to process. .PARAMETER Operation The operation to perform (count, uppercase). #> function Process-Items { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string[]]$Items, [Parameter(Mandatory = $true)] [ValidateSet("count", "uppercase")] [string]$Operation ) foreach ($item in $Items) { switch ($Operation) { "count" { $Script:Counter++ Write-Output "Processing item $($Script:Counter): $item" } "uppercase" { Write-Output $item.ToUpper() } } } } <# .SYNOPSIS Main entry point for the script. #> function Main { [CmdletBinding()] param( [Parameter(Mandatory = $false)] [string]$User = "World", [Parameter(Mandatory = $false)] [string]$Greeting = "default" ) Write-Output "Starting $Script:ScriptName" # Use the Greet-User function Greet-User -Username $User -GreetingType $Greeting # Process some items $items = @("item1", "item2", "item3") Write-Output "Processing items..." Process-Items -Items $items -Operation "count" # Use utility functions from utils.ps1 $upperName = Convert-ToUpperCase -InputString $User Write-Output "Uppercase name: $upperName" $trimmed = Remove-Whitespace -InputString " Hello World " Write-Output "Trimmed: '$trimmed'" Write-Output "Script completed successfully" } # Run main function Main @args ================================================ FILE: test/resources/repos/powershell/test_repo/utils.ps1 ================================================ # Utility functions for PowerShell operations <# .SYNOPSIS Converts a string to uppercase. .PARAMETER InputString The string to convert. .OUTPUTS System.String - The uppercase string. #> function Convert-ToUpperCase { [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [string]$InputString ) return $InputString.ToUpper() } <# .SYNOPSIS Converts a string to lowercase. .PARAMETER InputString The string to convert. .OUTPUTS System.String - The lowercase string. #> function Convert-ToLowerCase { [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [string]$InputString ) return $InputString.ToLower() } <# .SYNOPSIS Removes leading and trailing whitespace from a string. .PARAMETER InputString The string to trim. .OUTPUTS System.String - The trimmed string. #> function Remove-Whitespace { [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [string]$InputString ) return $InputString.Trim() } <# .SYNOPSIS Creates a backup of a file. .PARAMETER FilePath The path to the file to backup. .PARAMETER BackupDirectory The directory where the backup will be created. .OUTPUTS System.String - The path to the backup file. #> function Backup-File { [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory = $true)] [string]$FilePath, [Parameter(Mandatory = $false)] [string]$BackupDirectory = "." ) if (-not (Test-Path $FilePath)) { throw "File not found: $FilePath" } $fileName = Split-Path $FilePath -Leaf $timestamp = Get-Date -Format "yyyyMMdd_HHmmss" $backupName = "$fileName.$timestamp.bak" $backupPath = Join-Path $BackupDirectory $backupName Copy-Item -Path $FilePath -Destination $backupPath return $backupPath } <# .SYNOPSIS Checks if an array contains a specific element. .PARAMETER Array The array to search. .PARAMETER Element The element to find. .OUTPUTS System.Boolean - True if the element is found, false otherwise. #> function Test-ArrayContains { [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory = $true)] [array]$Array, [Parameter(Mandatory = $true)] $Element ) return $Array -contains $Element } <# .SYNOPSIS Writes a log message with timestamp. .PARAMETER Message The message to log. .PARAMETER Level The log level (Info, Warning, Error). #> function Write-LogMessage { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Message, [Parameter(Mandatory = $false)] [ValidateSet("Info", "Warning", "Error")] [string]$Level = "Info" ) $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" $logEntry = "[$timestamp] [$Level] $Message" switch ($Level) { "Info" { Write-Host $logEntry -ForegroundColor White } "Warning" { Write-Host $logEntry -ForegroundColor Yellow } "Error" { Write-Host $logEntry -ForegroundColor Red } } } <# .SYNOPSIS Validates if a string is a valid email address. .PARAMETER Email The email address to validate. .OUTPUTS System.Boolean - True if the email is valid, false otherwise. #> function Test-ValidEmail { [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory = $true)] [string]$Email ) $emailRegex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" return $Email -match $emailRegex } <# .SYNOPSIS Checks if a string is a valid number. .PARAMETER Value The string to check. .OUTPUTS System.Boolean - True if the string is a valid number, false otherwise. #> function Test-IsNumber { [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory = $true)] [string]$Value ) $number = 0 return [double]::TryParse($Value, [ref]$number) } ================================================ FILE: test/resources/repos/python/test_repo/.gitignore ================================================ ignore_this_dir*/ ================================================ FILE: test/resources/repos/python/test_repo/custom_test/__init__.py ================================================ """ Custom test package for testing code parsing capabilities. """ ================================================ FILE: test/resources/repos/python/test_repo/custom_test/advanced_features.py ================================================ """ Advanced Python features for testing code parsing capabilities. This module contains various advanced Python code patterns to ensure that the code parser can correctly handle them. """ from __future__ import annotations import asyncio import os from abc import ABC, abstractmethod from collections.abc import Callable, Iterable from contextlib import contextmanager from dataclasses import dataclass, field from enum import Enum, Flag, IntEnum, auto from functools import wraps from typing import ( Annotated, Any, ClassVar, Final, Generic, Literal, NewType, Protocol, TypedDict, TypeVar, ) # Type variables for generics T = TypeVar("T") K = TypeVar("K") V = TypeVar("V") # Custom types using NewType UserId = NewType("UserId", str) ItemId = NewType("ItemId", int) # Type aliases PathLike = str | os.PathLike JsonDict = dict[str, Any] # TypedDict class UserDict(TypedDict): """TypedDict representing user data.""" id: str name: str email: str age: int roles: list[str] # Enums class Status(Enum): """Status enum for process states.""" PENDING = "pending" RUNNING = "running" COMPLETED = "completed" FAILED = "failed" class Priority(IntEnum): """Priority levels for tasks.""" LOW = 0 MEDIUM = 5 HIGH = 10 CRITICAL = auto() class Permissions(Flag): """Permission flags for access control.""" NONE = 0 READ = 1 WRITE = 2 EXECUTE = 4 ALL = READ | WRITE | EXECUTE # Abstract class with various method types class BaseProcessor(ABC): """Abstract base class for processors with various method patterns.""" # Class variable with type annotation DEFAULT_TIMEOUT: ClassVar[int] = 30 MAX_RETRIES: Final[int] = 3 def __init__(self, name: str, config: dict[str, Any] | None = None): self.name = name self.config = config or {} self._status = Status.PENDING @property def status(self) -> Status: """Status property getter.""" return self._status @status.setter def status(self, value: Status) -> None: """Status property setter.""" if not isinstance(value, Status): raise TypeError(f"Expected Status enum, got {type(value)}") self._status = value @abstractmethod def process(self, data: Any) -> Any: """Process the input data.""" @classmethod def create_from_config(cls, config: dict[str, Any]) -> BaseProcessor: """Factory classmethod.""" name = config.get("name", "default") return cls(name=name, config=config) @staticmethod def validate_config(config: dict[str, Any]) -> bool: """Static method for config validation.""" return "name" in config def __str__(self) -> str: return f"{self.__class__.__name__}(name={self.name})" # Concrete implementation of abstract class class DataProcessor(BaseProcessor): """Concrete implementation of BaseProcessor.""" def __init__(self, name: str, config: dict[str, Any] | None = None, priority: Priority = Priority.MEDIUM): super().__init__(name, config) self.priority = priority self.processed_count = 0 def process(self, data: Any) -> Any: """Process the data.""" # Nested function definition def transform(item: Any) -> Any: # Nested function within a nested function def apply_rules(x: Any) -> Any: return x return apply_rules(item) # Lambda function normalize = lambda x: x / max(x) if hasattr(x, "__iter__") and len(x) > 0 else x # noqa: F841 result = transform(data) self.processed_count += 1 return result # Method with complex type hints def batch_process(self, items: list[str | dict[str, Any] | tuple[Any, ...]]) -> dict[str, list[Any]]: """Process multiple items in a batch.""" results: dict[str, list[Any]] = {"success": [], "error": []} for item in items: try: result = self.process(item) results["success"].append(result) except Exception as e: results["error"].append((item, str(e))) return results # Generator method def process_stream(self, data_stream: Iterable[T]) -> Iterable[T]: """Process a stream of data, yielding results as they're processed.""" for item in data_stream: yield self.process(item) # Async method async def async_process(self, data: Any) -> Any: """Process data asynchronously.""" await asyncio.sleep(0.1) return self.process(data) # Method with function parameters def apply_transform(self, data: Any, transform_func: Callable[[Any], Any]) -> Any: """Apply a custom transform function to the data.""" return transform_func(data) # Dataclass @dataclass class Task: """Task dataclass for tracking work items.""" id: str name: str status: Status = Status.PENDING priority: Priority = Priority.MEDIUM metadata: dict[str, Any] = field(default_factory=dict) dependencies: list[str] = field(default_factory=list) created_at: float | None = None def __post_init__(self): if self.created_at is None: import time self.created_at = time.time() def has_dependencies(self) -> bool: """Check if task has dependencies.""" return len(self.dependencies) > 0 # Generic class class Repository(Generic[T]): """Generic repository for managing collections of items.""" def __init__(self): self.items: dict[str, T] = {} def add(self, id: str, item: T) -> None: """Add an item to the repository.""" self.items[id] = item def get(self, id: str) -> T | None: """Get an item by id.""" return self.items.get(id) def remove(self, id: str) -> bool: """Remove an item by id.""" if id in self.items: del self.items[id] return True return False def list_all(self) -> list[T]: """List all items.""" return list(self.items.values()) # Type with Protocol (structural subtyping) class Serializable(Protocol): """Protocol for objects that can be serialized to dict.""" def to_dict(self) -> dict[str, Any]: ... # # Decorator function def log_execution(func: Callable) -> Callable: """Decorator to log function execution.""" @wraps(func) def wrapper(*args, **kwargs): print(f"Executing {func.__name__}") result = func(*args, **kwargs) print(f"Finished {func.__name__}") return result return wrapper # Context manager @contextmanager def transaction_context(name: str = "default"): """Context manager for transaction-like operations.""" print(f"Starting transaction: {name}") try: yield name print(f"Committing transaction: {name}") except Exception as e: print(f"Rolling back transaction: {name}, error: {e}") raise # Function with complex parameter annotations def advanced_search( query: str, filters: dict[str, Any] | None = None, sort_by: str | None = None, sort_order: Literal["asc", "desc"] = "asc", page: int = 1, page_size: int = 10, include_metadata: bool = False, ) -> tuple[list[dict[str, Any]], int]: """ Advanced search function with many parameters. Returns search results and total count. """ results = [] total = 0 # Simulating search functionality return results, total # Class with nested classes class OuterClass: """Outer class with nested classes and methods.""" class NestedClass: """Nested class inside OuterClass.""" def __init__(self, value: Any): self.value = value def get_value(self) -> Any: """Get the stored value.""" return self.value class DeeplyNestedClass: """Deeply nested class for testing parser depth capabilities.""" def deep_method(self) -> str: """Method in deeply nested class.""" return "deep" def __init__(self, name: str): self.name = name self.nested = self.NestedClass(name) def get_nested(self) -> NestedClass: """Get the nested class instance.""" return self.nested # Method with nested functions def process_with_nested(self, data: Any) -> Any: """Method demonstrating deeply nested function definitions.""" def level1(x: Any) -> Any: """First level nested function.""" def level2(y: Any) -> Any: """Second level nested function.""" def level3(z: Any) -> Any: """Third level nested function.""" return z return level3(y) return level2(x) return level1(data) # Metaclass example class Meta(type): """Metaclass example for testing advanced class handling.""" def __new__(mcs, name, bases, attrs): print(f"Creating class: {name}") return super().__new__(mcs, name, bases, attrs) def __init__(cls, name, bases, attrs): print(f"Initializing class: {name}") super().__init__(name, bases, attrs) class WithMeta(metaclass=Meta): """Class that uses a metaclass.""" def __init__(self, value: str): self.value = value # Factory function that creates and returns instances def create_processor(processor_type: str, name: str, config: dict[str, Any] | None = None) -> BaseProcessor: """Factory function that creates and returns processor instances.""" if processor_type == "data": return DataProcessor(name, config) else: raise ValueError(f"Unknown processor type: {processor_type}") # Nested decorator example def with_retry(max_retries: int = 3): """Decorator factory that creates a retry decorator.""" def decorator(func): @wraps(func) def wrapper(*args, **kwargs): for attempt in range(max_retries): try: return func(*args, **kwargs) except Exception as e: if attempt == max_retries - 1: raise print(f"Retrying {func.__name__} after error: {e}") return None return wrapper return decorator @with_retry(max_retries=5) def unreliable_operation(data: Any) -> Any: """Function that might fail and uses the retry decorator.""" import random if random.random() < 0.5: raise RuntimeError("Random failure") return data # Complex type annotation with Annotated ValidatedString = Annotated[str, "A string that has been validated"] PositiveInt = Annotated[int, lambda x: x > 0] def process_validated_data(data: ValidatedString, count: PositiveInt) -> list[str]: """Process data with Annotated type hints.""" return [data] * count # Example of forward references and string literals in type annotations class TreeNode: """Tree node with forward reference to itself in annotations.""" def __init__(self, value: Any): self.value = value self.children: list[TreeNode] = [] def add_child(self, child: TreeNode) -> None: """Add a child node.""" self.children.append(child) def traverse(self) -> list[Any]: """Traverse the tree and return all values.""" result = [self.value] for child in self.children: result.extend(child.traverse()) return result # Main entry point for demonstration def main() -> None: """Main function demonstrating the use of various features.""" # Create processor processor = DataProcessor("test-processor", {"debug": True}) # Create tasks task1 = Task(id="task1", name="First Task") task2 = Task(id="task2", name="Second Task", dependencies=["task1"]) # Create repository repo: Repository[Task] = Repository() repo.add(task1.id, task1) repo.add(task2.id, task2) # Process some data data = [1, 2, 3, 4, 5] result = processor.process(data) # noqa: F841 # Use context manager with transaction_context("main"): # Process more data for task in repo.list_all(): processor.process(task.name) # Use advanced search _results, _total = advanced_search(query="test", filters={"status": Status.PENDING}, sort_by="priority", page=1, include_metadata=True) # Create a tree root = TreeNode("root") child1 = TreeNode("child1") child2 = TreeNode("child2") root.add_child(child1) root.add_child(child2) child1.add_child(TreeNode("grandchild1")) print("Done!") if __name__ == "__main__": main() ================================================ FILE: test/resources/repos/python/test_repo/examples/__init__.py ================================================ """ Examples package for demonstrating test_repo module usage. """ ================================================ FILE: test/resources/repos/python/test_repo/examples/user_management.py ================================================ """ Example demonstrating user management with the test_repo module. This example showcases: - Creating and managing users - Using various object types and relationships - Type annotations and complex Python patterns """ import logging from dataclasses import dataclass from typing import Any from test_repo.models import User, create_user_object from test_repo.services import UserService # Set up logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @dataclass class UserStats: """Statistics about user activity.""" user_id: str login_count: int = 0 last_active_days: int = 0 engagement_score: float = 0.0 def is_active(self) -> bool: """Check if the user is considered active.""" return self.last_active_days < 30 class UserManager: """Example class demonstrating complex user management.""" def __init__(self, service: UserService): self.service = service self.active_users: dict[str, User] = {} self.user_stats: dict[str, UserStats] = {} def register_user(self, name: str, email: str, roles: list[str] | None = None) -> User: """Register a new user.""" logger.info(f"Registering new user: {name} ({email})") user = self.service.create_user(name=name, email=email, roles=roles) self.active_users[user.id] = user self.user_stats[user.id] = UserStats(user_id=user.id) return user def get_user(self, user_id: str) -> User | None: """Get a user by ID.""" if user_id in self.active_users: return self.active_users[user_id] # Try to fetch from service user = self.service.get_user(user_id) if user: self.active_users[user.id] = user return user def update_user_stats(self, user_id: str, login_count: int, days_since_active: int) -> None: """Update statistics for a user.""" if user_id not in self.user_stats: self.user_stats[user_id] = UserStats(user_id=user_id) stats = self.user_stats[user_id] stats.login_count = login_count stats.last_active_days = days_since_active # Calculate engagement score based on activity engagement = (100 - min(days_since_active, 100)) * 0.8 engagement += min(login_count, 20) * 0.2 stats.engagement_score = engagement def get_active_users(self) -> list[User]: """Get all active users.""" active_user_ids = [user_id for user_id, stats in self.user_stats.items() if stats.is_active()] return [self.active_users[user_id] for user_id in active_user_ids if user_id in self.active_users] def get_user_by_email(self, email: str) -> User | None: """Find a user by their email address.""" for user in self.active_users.values(): if user.email == email: return user return None # Example function demonstrating type annotations def process_user_data(users: list[User], include_inactive: bool = False, transform_func: callable | None = None) -> dict[str, Any]: """Process user data with optional transformations.""" result: dict[str, Any] = {"users": [], "total": 0, "admin_count": 0} for user in users: if transform_func: user_data = transform_func(user.to_dict()) else: user_data = user.to_dict() result["users"].append(user_data) result["total"] += 1 if "admin" in user.roles: result["admin_count"] += 1 return result def main(): """Main function demonstrating the usage of UserManager.""" # Initialize service and manager service = UserService() manager = UserManager(service) # Register some users admin = manager.register_user("Admin User", "admin@example.com", ["admin"]) user1 = manager.register_user("Regular User", "user@example.com", ["user"]) user2 = manager.register_user("Another User", "another@example.com", ["user"]) # Update some stats manager.update_user_stats(admin.id, 100, 5) manager.update_user_stats(user1.id, 50, 10) manager.update_user_stats(user2.id, 10, 45) # Inactive user # Get active users active_users = manager.get_active_users() logger.info(f"Active users: {len(active_users)}") # Process user data user_data = process_user_data(active_users, transform_func=lambda u: {**u, "full_name": u.get("name", "")}) logger.info(f"Processed {user_data['total']} users, {user_data['admin_count']} admins") # Example of calling create_user directly external_user = create_user_object(id="ext123", name="External User", email="external@example.org", roles=["external"]) logger.info(f"Created external user: {external_user.name}") if __name__ == "__main__": main() ================================================ FILE: test/resources/repos/python/test_repo/scripts/__init__.py ================================================ """ Scripts package containing entry point scripts for the application. """ ================================================ FILE: test/resources/repos/python/test_repo/scripts/run_app.py ================================================ #!/usr/bin/env python """ Main entry point script for the test_repo application. This script demonstrates how a typical application entry point would be structured, with command-line arguments, configuration loading, and service initialization. """ import argparse import json import logging import os import sys from typing import Any # Add parent directory to path to make imports work sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) from test_repo.models import Item, User from test_repo.services import ItemService, UserService # Configure logging logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") logger = logging.getLogger(__name__) def parse_args(): """Parse command line arguments.""" parser = argparse.ArgumentParser(description="Test Repo Application") parser.add_argument("--config", type=str, default="config.json", help="Path to configuration file") parser.add_argument("--mode", choices=["user", "item", "both"], default="both", help="Operation mode") parser.add_argument("--verbose", action="store_true", help="Enable verbose logging") return parser.parse_args() def load_config(config_path: str) -> dict[str, Any]: """Load configuration from a JSON file.""" if not os.path.exists(config_path): logger.warning(f"Configuration file not found: {config_path}") return {} try: with open(config_path, encoding="utf-8") as f: return json.load(f) except json.JSONDecodeError: logger.error(f"Invalid JSON in configuration file: {config_path}") return {} except Exception as e: logger.error(f"Error loading configuration: {e}") return {} def create_sample_users(service: UserService, count: int = 3) -> list[User]: """Create sample users for demonstration.""" users = [] # Create admin user admin = service.create_user(name="Admin User", email="admin@example.com", roles=["admin"]) users.append(admin) # Create regular users for i in range(count - 1): user = service.create_user(name=f"User {i + 1}", email=f"user{i + 1}@example.com", roles=["user"]) users.append(user) return users def create_sample_items(service: ItemService, count: int = 5) -> list[Item]: """Create sample items for demonstration.""" categories = ["Electronics", "Books", "Clothing", "Food", "Other"] items = [] for i in range(count): category = categories[i % len(categories)] item = service.create_item(name=f"Item {i + 1}", price=10.0 * (i + 1), category=category) items.append(item) return items def run_user_operations(service: UserService, config: dict[str, Any]) -> None: """Run operations related to users.""" logger.info("Running user operations") # Get configuration user_count = config.get("user_count", 3) # Create users users = create_sample_users(service, user_count) logger.info(f"Created {len(users)} users") # Demonstrate some operations for user in users: logger.info(f"User: {user.name} (ID: {user.id})") # Access a method to demonstrate method calls if user.has_role("admin"): logger.info(f"{user.name} is an admin") # Lookup a user found_user = service.get_user(users[0].id) if found_user: logger.info(f"Found user: {found_user.name}") def run_item_operations(service: ItemService, config: dict[str, Any]) -> None: """Run operations related to items.""" logger.info("Running item operations") # Get configuration item_count = config.get("item_count", 5) # Create items items = create_sample_items(service, item_count) logger.info(f"Created {len(items)} items") # Demonstrate some operations total_price = 0.0 for item in items: price_display = item.get_display_price() logger.info(f"Item: {item.name}, Price: {price_display}") total_price += item.price logger.info(f"Total price of all items: ${total_price:.2f}") def main(): """Main entry point for the application.""" # Parse command line arguments args = parse_args() # Configure logging level if args.verbose: logging.getLogger().setLevel(logging.DEBUG) logger.info("Starting Test Repo Application") # Load configuration config = load_config(args.config) logger.debug(f"Loaded configuration: {config}") # Initialize services user_service = UserService() item_service = ItemService() # Run operations based on mode if args.mode in ("user", "both"): run_user_operations(user_service, config) if args.mode in ("item", "both"): run_item_operations(item_service, config) logger.info("Application completed successfully") item_reference = Item(id="1", name="Item 1", price=10.0, category="Electronics") if __name__ == "__main__": main() ================================================ FILE: test/resources/repos/python/test_repo/test_repo/__init__.py ================================================ ================================================ FILE: test/resources/repos/python/test_repo/test_repo/complex_types.py ================================================ from typing import TypedDict a: list[int] = [1] class CustomListInt(list[int]): def some_method(self): pass class CustomTypedDict(TypedDict): a: int b: str class Outer2: class InnerTypedDict(TypedDict): a: int b: str class ComplexExtension(Outer2.InnerTypedDict, total=False): c: bool ================================================ FILE: test/resources/repos/python/test_repo/test_repo/models.py ================================================ """ Models module that demonstrates various Python class patterns. """ from abc import ABC, abstractmethod from typing import Any, Generic, TypeVar T = TypeVar("T") class BaseModel(ABC): """ Abstract base class for all models. """ def __init__(self, id: str, name: str | None = None): self.id = id self.name = name or id @abstractmethod def to_dict(self) -> dict[str, Any]: """Convert model to dictionary representation""" @classmethod def from_dict(cls, data: dict[str, Any]) -> "BaseModel": """Create a model instance from dictionary data""" id = data.get("id", "") name = data.get("name") return cls(id=id, name=name) class User(BaseModel): """ User model representing a system user. """ def __init__(self, id: str, name: str | None = None, email: str = "", roles: list[str] | None = None): super().__init__(id, name) self.email = email self.roles = roles or [] def to_dict(self) -> dict[str, Any]: return {"id": self.id, "name": self.name, "email": self.email, "roles": self.roles} @classmethod def from_dict(cls, data: dict[str, Any]) -> "User": instance = super().from_dict(data) instance.email = data.get("email", "") instance.roles = data.get("roles", []) return instance def has_role(self, role: str) -> bool: """Check if user has a specific role""" return role in self.roles class Item(BaseModel): """ Item model representing a product or service. """ def __init__(self, id: str, name: str | None = None, price: float = 0.0, category: str = ""): super().__init__(id, name) self.price = price self.category = category def to_dict(self) -> dict[str, Any]: return {"id": self.id, "name": self.name, "price": self.price, "category": self.category} def get_display_price(self) -> str: """Format price for display""" return f"${self.price:.2f}" # Generic type example class Collection(Generic[T]): def __init__(self, items: list[T] | None = None): self.items = items or [] def add(self, item: T) -> None: self.items.append(item) def get_all(self) -> list[T]: return self.items # Factory function def create_user_object(id: str, name: str, email: str, roles: list[str] | None = None) -> User: """Factory function to create a user""" return User(id=id, name=name, email=email, roles=roles) # Multiple inheritance examples class Loggable: """ Mixin class that provides logging functionality. Example of a common mixin pattern used with multiple inheritance. """ def __init__(self, **kwargs): super().__init__(**kwargs) self.log_entries: list[str] = [] def log(self, message: str) -> None: """Add a log entry""" self.log_entries.append(message) def get_logs(self) -> list[str]: """Get all log entries""" return self.log_entries class Serializable: """ Mixin class that provides JSON serialization capabilities. Another example of a mixin for multiple inheritance. """ def __init__(self, **kwargs): super().__init__(**kwargs) def to_json(self) -> dict[str, Any]: """Convert to JSON-serializable dictionary""" return self.to_dict() if hasattr(self, "to_dict") else {} @classmethod def from_json(cls, data: dict[str, Any]) -> Any: """Create instance from JSON data""" return cls.from_dict(data) if hasattr(cls, "from_dict") else cls(**data) class Auditable: """ Mixin for tracking creation and modification timestamps. """ def __init__(self, **kwargs): super().__init__(**kwargs) self.created_at: str = kwargs.get("created_at", "") self.updated_at: str = kwargs.get("updated_at", "") def update_timestamp(self, timestamp: str) -> None: """Update the last modified timestamp""" self.updated_at = timestamp # Diamond inheritance pattern class BaseService(ABC): """ Base class for service objects - demonstrates diamond inheritance pattern. """ def __init__(self, name: str = "base"): self.service_name = name @abstractmethod def get_service_info(self) -> dict[str, str]: """Get service information""" class DataService(BaseService): """ Data handling service. """ def __init__(self, **kwargs): name = kwargs.pop("name", "data") super().__init__(name=name) self.data_source = kwargs.get("data_source", "default") def get_service_info(self) -> dict[str, str]: return {"service_type": "data", "service_name": self.service_name, "data_source": self.data_source} class NetworkService(BaseService): """ Network connectivity service. """ def __init__(self, **kwargs): name = kwargs.pop("name", "network") super().__init__(name=name) self.endpoint = kwargs.get("endpoint", "localhost") def get_service_info(self) -> dict[str, str]: return {"service_type": "network", "service_name": self.service_name, "endpoint": self.endpoint} class DataSyncService(DataService, NetworkService): """ Service that syncs data over network - example of diamond inheritance. Inherits from both DataService and NetworkService, which both inherit from BaseService. """ def __init__(self, **kwargs): super().__init__(**kwargs) self.sync_interval = kwargs.get("sync_interval", 60) def get_service_info(self) -> dict[str, str]: info = super().get_service_info() info.update({"service_type": "data_sync", "sync_interval": str(self.sync_interval)}) return info # Multiple inheritance with mixins class LoggableUser(User, Loggable): """ User class with logging capabilities. Example of extending a concrete class with a mixin. """ def __init__(self, id: str, name: str | None = None, email: str = "", roles: list[str] | None = None): super().__init__(id=id, name=name, email=email, roles=roles) def add_role(self, role: str) -> None: """Add a role to the user and log the action""" if role not in self.roles: self.roles.append(role) self.log(f"Added role '{role}' to user {self.id}") class TrackedItem(Item, Serializable, Auditable): """ Item with serialization and auditing capabilities. Example of a class inheriting from a concrete class and multiple mixins. """ def __init__( self, id: str, name: str | None = None, price: float = 0.0, category: str = "", created_at: str = "", updated_at: str = "" ): super().__init__(id=id, name=name, price=price, category=category, created_at=created_at, updated_at=updated_at) self.stock_level = 0 def update_stock(self, quantity: int) -> None: """Update stock level and timestamp""" self.stock_level = quantity self.update_timestamp(f"stock_update_{quantity}") def to_dict(self) -> dict[str, Any]: result = super().to_dict() result.update({"stock_level": self.stock_level, "created_at": self.created_at, "updated_at": self.updated_at}) return result ================================================ FILE: test/resources/repos/python/test_repo/test_repo/name_collisions.py ================================================ # ruff: noqa var_will_be_overwritten = 1 var_will_be_overwritten = 2 def func_using_overwritten_var(): print(var_will_be_overwritten) class ClassWillBeOverwritten: def method1(self): pass class ClassWillBeOverwritten: def method2(self): pass def func_will_be_overwritten(): pass def func_will_be_overwritten(): pass def func_calling_overwritten_func(): func_will_be_overwritten() def func_calling_overwritten_class(): ClassWillBeOverwritten() ================================================ FILE: test/resources/repos/python/test_repo/test_repo/nested.py ================================================ class OuterClass: class NestedClass: def find_me(self): pass def nested_test(self): class WithinMethod: pass def func_within_func(): pass a = self.NestedClass() # noqa: F841 b = OuterClass().NestedClass().find_me() ================================================ FILE: test/resources/repos/python/test_repo/test_repo/nested_base.py ================================================ """ Module to test parsing of classes with nested module paths in base classes. """ from typing import Generic, TypeVar T = TypeVar("T") class BaseModule: """Base module class for nested module tests.""" class SubModule: """Sub-module class for nested paths.""" class NestedBase: """Nested base class.""" def base_method(self): """Base method.""" return "base" class NestedLevel2: """Nested level 2.""" def nested_level_2_method(self): """Nested level 2 method.""" return "nested_level_2" class GenericBase(Generic[T]): """Generic nested base class.""" def generic_method(self, value: T) -> T: """Generic method.""" return value # Classes extending base classes with single-level nesting class FirstLevel(SubModule): """Class extending a class from a nested module path.""" def first_level_method(self): """First level method.""" return "first" # Classes extending base classes with multi-level nesting class TwoLevel(SubModule.NestedBase): """Class extending a doubly-nested base class.""" def multi_level_method(self): """Multi-level method.""" return "multi" def base_method(self): """Override of base method.""" return "overridden" class ThreeLevel(SubModule.NestedBase.NestedLevel2): """Class extending a triply-nested base class.""" def three_level_method(self): """Three-level method.""" return "three" # Class extending a generic base class with nesting class GenericExtension(SubModule.GenericBase[str]): """Class extending a generic nested base class.""" def generic_extension_method(self, text: str) -> str: """Extension method.""" return f"Extended: {text}" ================================================ FILE: test/resources/repos/python/test_repo/test_repo/overloaded.py ================================================ """ Module demonstrating function and method overloading with typing.overload """ from typing import Any, overload # Example of function overloading @overload def process_data(data: str) -> dict[str, str]: ... @overload def process_data(data: int) -> dict[str, int]: ... @overload def process_data(data: list[str | int]) -> dict[str, list[str | int]]: ... def process_data(data: str | int | list[str | int]) -> dict[str, Any]: """ Process data based on its type. - If string: returns a dict with 'value': - If int: returns a dict with 'value': - If list: returns a dict with 'value': """ return {"value": data} # Class with overloaded methods class DataProcessor: """ A class demonstrating method overloading. """ @overload def transform(self, input_value: str) -> str: ... @overload def transform(self, input_value: int) -> int: ... @overload def transform(self, input_value: list[Any]) -> list[Any]: ... def transform(self, input_value: str | int | list[Any]) -> str | int | list[Any]: """ Transform input based on its type. - If string: returns the string in uppercase - If int: returns the int multiplied by 2 - If list: returns the list sorted """ if isinstance(input_value, str): return input_value.upper() elif isinstance(input_value, int): return input_value * 2 elif isinstance(input_value, list): try: return sorted(input_value) except TypeError: return input_value return input_value @overload def fetch(self, id: int) -> dict[str, Any]: ... @overload def fetch(self, id: str, cache: bool = False) -> dict[str, Any] | None: ... def fetch(self, id: int | str, cache: bool = False) -> dict[str, Any] | None: """ Fetch data for a given ID. Args: id: The ID to fetch, either numeric or string cache: Whether to use cache for string IDs Returns: Data dictionary or None if not found """ # Implementation would actually fetch data if isinstance(id, int): return {"id": id, "type": "numeric"} else: return {"id": id, "type": "string", "cached": cache} ================================================ FILE: test/resources/repos/python/test_repo/test_repo/services.py ================================================ """ Services module demonstrating function usage and dependencies. """ from typing import Any from .models import Item, User class UserService: """Service for user-related operations""" def __init__(self, user_db: dict[str, User] | None = None): self.users = user_db or {} def create_user(self, id: str, name: str, email: str) -> User: """Create a new user and store it""" if id in self.users: raise ValueError(f"User with ID {id} already exists") user = User(id=id, name=name, email=email) self.users[id] = user return user def get_user(self, id: str) -> User | None: """Get a user by ID""" return self.users.get(id) def list_users(self) -> list[User]: """Get a list of all users""" return list(self.users.values()) def delete_user(self, id: str) -> bool: """Delete a user by ID""" if id in self.users: del self.users[id] return True return False class ItemService: """Service for item-related operations""" def __init__(self, item_db: dict[str, Item] | None = None): self.items = item_db or {} def create_item(self, id: str, name: str, price: float, category: str) -> Item: """Create a new item and store it""" if id in self.items: raise ValueError(f"Item with ID {id} already exists") item = Item(id=id, name=name, price=price, category=category) self.items[id] = item return item def get_item(self, id: str) -> Item | None: """Get an item by ID""" return self.items.get(id) def list_items(self, category: str | None = None) -> list[Item]: """List all items, optionally filtered by category""" if category: return [item for item in self.items.values() if item.category == category] return list(self.items.values()) # Factory function for services def create_service_container() -> dict[str, Any]: """Create a container with all services""" container = {"user_service": UserService(), "item_service": ItemService()} return container user_var_str = "user_var" user_service = UserService() user_service.create_user("1", "Alice", "alice@example.com") ================================================ FILE: test/resources/repos/python/test_repo/test_repo/utils.py ================================================ """ Utility functions and classes demonstrating various Python features. """ import logging from collections.abc import Callable from typing import Any, TypeVar # Type variables for generic functions T = TypeVar("T") U = TypeVar("U") def setup_logging(level: str = "INFO") -> logging.Logger: """Set up and return a configured logger""" levels = { "DEBUG": logging.DEBUG, "INFO": logging.INFO, "WARNING": logging.WARNING, "ERROR": logging.ERROR, "CRITICAL": logging.CRITICAL, } logger = logging.getLogger("test_repo") logger.setLevel(levels.get(level.upper(), logging.INFO)) handler = logging.StreamHandler() formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") handler.setFormatter(formatter) logger.addHandler(handler) return logger # Decorator example def log_execution(func: Callable) -> Callable: """Decorator to log function execution""" def wrapper(*args, **kwargs): logger = logging.getLogger("test_repo") logger.info(f"Executing function: {func.__name__}") result = func(*args, **kwargs) logger.info(f"Completed function: {func.__name__}") return result return wrapper # Higher-order function def map_list(items: list[T], mapper: Callable[[T], U]) -> list[U]: """Map a function over a list of items""" return [mapper(item) for item in items] # Class with various Python features class ConfigManager: """Manages configuration with various access patterns""" _instance = None # Singleton pattern def __new__(cls, *args, **kwargs): if not cls._instance: cls._instance = super().__new__(cls) return cls._instance def __init__(self, initial_config: dict[str, Any] | None = None): if not hasattr(self, "initialized"): self.config = initial_config or {} self.initialized = True def __getitem__(self, key: str) -> Any: """Allow dictionary-like access""" return self.config.get(key) def __setitem__(self, key: str, value: Any) -> None: """Allow dictionary-like setting""" self.config[key] = value @property def debug_mode(self) -> bool: """Property example""" return self.config.get("debug", False) @debug_mode.setter def debug_mode(self, value: bool) -> None: self.config["debug"] = value # Context manager example class Timer: """Context manager for timing code execution""" def __init__(self, name: str = "Timer"): self.name = name self.start_time = None self.end_time = None def __enter__(self): import time self.start_time = time.time() return self def __exit__(self, exc_type, exc_val, exc_tb): import time self.end_time = time.time() print(f"{self.name} took {self.end_time - self.start_time:.6f} seconds") # Functions with default arguments def retry(func: Callable, max_attempts: int = 3, delay: float = 1.0) -> Any: """Retry a function with backoff""" import time for attempt in range(max_attempts): try: return func() except Exception as e: if attempt == max_attempts - 1: raise e time.sleep(delay * (2**attempt)) ================================================ FILE: test/resources/repos/python/test_repo/test_repo/variables.py ================================================ """ Test module for variable declarations and usage. This module tests various types of variable declarations and usages including: - Module-level variables - Class-level variables - Instance variables - Variable reassignments """ from dataclasses import dataclass, field # Module-level variables module_var = "Initial module value" reassignable_module_var = 10 reassignable_module_var = 20 # Reassigned # Module-level variable with type annotation typed_module_var: int = 42 # Regular class with class and instance variables class VariableContainer: """Class that contains various variables.""" # Class-level variables class_var = "Initial class value" reassignable_class_var = True reassignable_class_var = False # Reassigned #noqa: PIE794 # Class-level variable with type annotation typed_class_var: str = "typed value" def __init__(self): # Instance variables self.instance_var = "Initial instance value" self.reassignable_instance_var = 100 # Instance variable with type annotation self.typed_instance_var: list[str] = ["item1", "item2"] def modify_instance_var(self): # Reassign instance variable self.instance_var = "Modified instance value" self.reassignable_instance_var = 200 # Reassigned def use_module_var(self): # Use module-level variables result = module_var + " used in method" other_result = reassignable_module_var + 5 return result, other_result def use_class_var(self): # Use class-level variables result = VariableContainer.class_var + " used in method" other_result = VariableContainer.reassignable_class_var return result, other_result # Dataclass with variables @dataclass class VariableDataclass: """Dataclass that contains various fields.""" # Field variables with type annotations id: int name: str items: list[str] = field(default_factory=list) metadata: dict[str, str] = field(default_factory=dict) optional_value: float | None = None # This will be reassigned in various places status: str = "pending" # Function that uses the module variables def use_module_variables(): """Function that uses module-level variables.""" result = module_var + " used in function" other_result = reassignable_module_var * 2 return result, other_result # Create instances and use variables dataclass_instance = VariableDataclass(id=1, name="Test") dataclass_instance.status = "active" # Reassign dataclass field # Use variables at module level module_result = module_var + " used at module level" other_module_result = reassignable_module_var + 30 # Create a second dataclass instance with different status second_dataclass = VariableDataclass(id=2, name="Another Test") second_dataclass.status = "completed" # Another reassignment of status ================================================ FILE: test/resources/repos/r/test_repo/.Rbuildignore ================================================ ^.*\.Rproj$ ^\.Rproj\.user$ ^\.serena$ ================================================ FILE: test/resources/repos/r/test_repo/DESCRIPTION ================================================ Package: testpackage Type: Package Title: Test Package for R Language Server Version: 1.0.0 Author: Serena Test Maintainer: Serena Test Description: A minimal test package for testing R language server functionality. This package contains sample R functions for testing symbol extraction, completion, and other language server features. License: MIT + file LICENSE Encoding: UTF-8 LazyData: true RoxygenNote: 7.2.0 ================================================ FILE: test/resources/repos/r/test_repo/NAMESPACE ================================================ # Generated by roxygen2: do not edit by hand export(calculate_mean) export(create_data_frame) export(fit_linear_model) export(plot_data) export(process_data) ================================================ FILE: test/resources/repos/r/test_repo/R/models.R ================================================ #' Fit a linear model #' #' @param formula A formula for the model #' @param data A data frame containing the variables #' @return A fitted lm object #' @export fit_linear_model <- function(formula, data) { if (missing(formula) || missing(data)) { stop("Both formula and data are required") } model <- lm(formula, data = data) # Add some custom attributes attr(model, "created_by") <- "fit_linear_model" attr(model, "creation_time") <- Sys.time() return(model) } #' Plot data using ggplot2-style syntax #' #' @param data A data frame #' @param x_var Column name for x-axis #' @param y_var Column name for y-axis #' @return A plot object #' @export plot_data <- function(data, x_var, y_var) { if (!is.data.frame(data)) { stop("data must be a data frame") } if (!(x_var %in% names(data))) { stop(paste("Column", x_var, "not found in data")) } if (!(y_var %in% names(data))) { stop(paste("Column", y_var, "not found in data")) } # Create a simple base R plot plot(data[[x_var]], data[[y_var]], xlab = x_var, ylab = y_var, main = paste(y_var, "vs", x_var)) # Add a trend line abline(lm(data[[y_var]] ~ data[[x_var]]), col = "red") } ================================================ FILE: test/resources/repos/r/test_repo/R/utils.R ================================================ #' Calculate mean of numeric vector #' #' @param x A numeric vector #' @return The mean of the vector #' @export calculate_mean <- function(x) { if (!is.numeric(x)) { stop("Input must be numeric") } mean(x, na.rm = TRUE) } #' Process data by removing missing values #' #' @param data A data frame #' @return A cleaned data frame #' @export process_data <- function(data) { if (!is.data.frame(data)) { stop("Input must be a data frame") } # Remove rows with any missing values clean_data <- na.omit(data) # Add a processed flag clean_data$processed <- TRUE return(clean_data) } #' Create a sample data frame #' #' @param n Number of rows to create #' @return A data frame with sample data #' @export create_data_frame <- function(n = 100) { data.frame( id = 1:n, value = rnorm(n), category = sample(c("A", "B", "C"), n, replace = TRUE), stringsAsFactors = FALSE ) } ================================================ FILE: test/resources/repos/r/test_repo/examples/analysis.R ================================================ # Example R script demonstrating package usage # Load required libraries library(testpackage) # Create sample data sample_data <- create_data_frame(n = 50) # Process the data clean_data <- process_data(sample_data) # Calculate some statistics mean_value <- calculate_mean(clean_data$value) cat("Mean value:", mean_value, "\n") # Fit a simple model model <- fit_linear_model(value ~ id, data = clean_data) summary(model) # Create a plot plot_data(clean_data, "id", "value") # Additional analysis function (not exported) analyze_categories <- function(data) { table(data$category) } # Run the analysis category_summary <- analyze_categories(clean_data) print(category_summary) ================================================ FILE: test/resources/repos/rego/test_repo/policies/authz.rego ================================================ package policies import data.utils # Default deny default allow := false # Admin access rule allow if { input.user.role == "admin" utils.is_valid_user(input.user) } # Read access for authenticated users allow_read if { input.action == "read" input.user.authenticated } # User roles list admin_roles := ["admin", "superuser"] # Helper function to check if user is admin is_admin(user) if { admin_roles[_] == user.role } # Check if action is allowed for user check_permission(user, action) if { user.role == "admin" allowed_actions := ["read", "write", "delete"] allowed_actions[_] == action } ================================================ FILE: test/resources/repos/rego/test_repo/policies/validation.rego ================================================ package policies import data.policies import data.utils # Validate user input validate_user_input if { utils.is_valid_user(input.user) utils.is_valid_email(input.user.email) } # Check if user has valid credentials has_valid_credentials(user) if { user.username != "" user.password != "" utils.is_valid_email(user.email) } # Validate request validate_request if { input.user.authenticated policies.allow } ================================================ FILE: test/resources/repos/rego/test_repo/utils/helpers.rego ================================================ package utils # User validation is_valid_user(user) if { user.id != "" user.email != "" } # Email validation is_valid_email(email) if { contains(email, "@") contains(email, ".") } # Username validation is_valid_username(username) if { count(username) >= 3 count(username) <= 32 } # Check if string is empty is_empty(str) if { str == "" } # Check if array contains element array_contains(arr, elem) if { arr[_] == elem } ================================================ FILE: test/resources/repos/ruby/test_repo/.solargraph.yml ================================================ --- include: - "main.rb" - "lib.rb" ================================================ FILE: test/resources/repos/ruby/test_repo/examples/user_management.rb ================================================ require '../services.rb' require '../models.rb' class UserStats attr_reader :user_count, :active_users, :last_updated def initialize @user_count = 0 @active_users = 0 @last_updated = Time.now end def update_stats(total, active) @user_count = total @active_users = active @last_updated = Time.now end def activity_ratio return 0.0 if @user_count == 0 (@active_users.to_f / @user_count * 100).round(2) end def formatted_stats "Users: #{@user_count}, Active: #{@active_users} (#{activity_ratio}%)" end end class UserManager def initialize @service = Services::UserService.new @stats = UserStats.new end def create_user_with_tracking(id, name, email = nil) user = @service.create_user(id, name) user.email = email if email update_statistics notify_user_created(user) user end def get_user_details(id) user = @service.get_user(id) return nil unless user { user_info: user.full_info, created_at: Time.now, stats: @stats.formatted_stats } end def bulk_create_users(user_data_list) created_users = [] user_data_list.each do |data| user = create_user_with_tracking(data[:id], data[:name], data[:email]) created_users << user end created_users end private def update_statistics total_users = @service.users.length # For demo purposes, assume all users are active @stats.update_stats(total_users, total_users) end def notify_user_created(user) puts "User created: #{user.name} (ID: #{user.id})" end end def process_user_data(raw_data) processed = raw_data.map do |entry| { id: entry["id"] || entry[:id], name: entry["name"] || entry[:name], email: entry["email"] || entry[:email] } end processed.reject { |entry| entry[:name].nil? || entry[:name].empty? } end def main # Example usage manager = UserManager.new sample_data = [ { id: 1, name: "Alice Johnson", email: "alice@example.com" }, { id: 2, name: "Bob Smith", email: "bob@example.com" }, { id: 3, name: "Charlie Brown" } ] users = manager.bulk_create_users(sample_data) users.each do |user| details = manager.get_user_details(user.id) puts details[:user_info] end puts "\nFinal statistics:" stats = UserStats.new stats.update_stats(users.length, users.length) puts stats.formatted_stats end # Execute if this file is run directly main if __FILE__ == $0 ================================================ FILE: test/resources/repos/ruby/test_repo/lib.rb ================================================ class Calculator def add(a, b) a + b end def subtract(a, b) a - b end end ================================================ FILE: test/resources/repos/ruby/test_repo/main.rb ================================================ require './lib.rb' class DemoClass attr_accessor :value def initialize(value) @value = value end def print_value puts @value end end def helper_function(number = 42) demo = DemoClass.new(number) Calculator.new.add(demo.value, 10) demo.print_value end helper_function ================================================ FILE: test/resources/repos/ruby/test_repo/models.rb ================================================ class User attr_accessor :id, :name, :email def initialize(id, name, email = nil) @id = id @name = name @email = email end def full_info info = "User: #{@name} (ID: #{@id})" info += ", Email: #{@email}" if @email info end def to_hash { id: @id, name: @name, email: @email } end def self.from_hash(hash) new(hash[:id], hash[:name], hash[:email]) end class << self def default_user new(0, "Guest") end end end class Item attr_reader :id, :name, :price def initialize(id, name, price) @id = id @name = name @price = price end def discounted_price(discount_percent) @price * (1 - discount_percent / 100.0) end def description "#{@name}: $#{@price}" end end module ItemHelpers def format_price(price) "$#{sprintf('%.2f', price)}" end def calculate_tax(price, tax_rate = 0.08) price * tax_rate end end class Order include ItemHelpers def initialize @items = [] @total = 0 end def add_item(item, quantity = 1) @items << { item: item, quantity: quantity } calculate_total end def total_with_tax tax = calculate_tax(@total) @total + tax end private def calculate_total @total = @items.sum { |entry| entry[:item].price * entry[:quantity] } end end ================================================ FILE: test/resources/repos/ruby/test_repo/nested.rb ================================================ class OuterClass def initialize @value = "outer" end def outer_method inner_function = lambda do |x| x * 2 end result = inner_function.call(5) puts "Result: #{result}" end class NestedClass def initialize(name) @name = name end def find_me "Found in NestedClass: #{@name}" end def nested_method puts "Nested method called" end class DeeplyNested def deep_method "Deep inside" end end end module NestedModule def module_method "Module method" end class ModuleClass def module_class_method "Module class method" end end end end # Test usage of nested classes outer = OuterClass.new nested = OuterClass::NestedClass.new("test") result = nested.find_me ================================================ FILE: test/resources/repos/ruby/test_repo/services.rb ================================================ require './lib.rb' require './models.rb' module Services class UserService attr_reader :users def initialize @users = {} end def create_user(id, name) user = User.new(id, name) @users[id] = user user end def get_user(id) @users[id] end def delete_user(id) @users.delete(id) end private def validate_user_data(id, name) return false if id.nil? || name.nil? return false if name.empty? true end end class ItemService def initialize @items = [] end def add_item(item) @items << item end def find_item(id) @items.find { |item| item.id == id } end end end # Module-level function def create_service_container { user_service: Services::UserService.new, item_service: Services::ItemService.new } end # Variables for testing user_service_instance = Services::UserService.new item_service_instance = Services::ItemService.new ================================================ FILE: test/resources/repos/ruby/test_repo/variables.rb ================================================ require './models.rb' # Global variables for testing references $global_counter = 0 $global_config = { debug: true, timeout: 30 } class DataContainer attr_accessor :status, :data, :metadata def initialize @status = "pending" @data = {} @metadata = { created_at: Time.now, version: "1.0" } end def update_status(new_status) old_status = @status @status = new_status log_status_change(old_status, new_status) end def process_data(input_data) @data = input_data @status = "processing" # Process the data result = @data.transform_values { |v| v.to_s.upcase } @status = "completed" result end def get_metadata_info info = "Status: #{@status}, Version: #{@metadata[:version]}" info += ", Created: #{@metadata[:created_at]}" info end private def log_status_change(old_status, new_status) puts "Status changed from #{old_status} to #{new_status}" end end class StatusTracker def initialize @tracked_items = [] end def add_item(item) @tracked_items << item item.status = "tracked" if item.respond_to?(:status=) end def find_by_status(target_status) @tracked_items.select { |item| item.status == target_status } end def update_all_status(new_status) @tracked_items.each do |item| item.status = new_status if item.respond_to?(:status=) end end end # Module level variables and functions module ProcessingHelper PROCESSING_MODES = ["sync", "async", "batch"].freeze @@instance_count = 0 def self.create_processor(mode = "sync") @@instance_count += 1 { id: @@instance_count, mode: mode, created_at: Time.now } end def self.get_instance_count @@instance_count end end # Test instances for reference testing dataclass_instance = DataContainer.new dataclass_instance.status = "initialized" second_dataclass = DataContainer.new second_dataclass.update_status("ready") tracker = StatusTracker.new tracker.add_item(dataclass_instance) tracker.add_item(second_dataclass) # Function that uses the variables def demonstrate_variable_usage puts "Global counter: #{$global_counter}" container = DataContainer.new container.status = "demo" processor = ProcessingHelper.create_processor("async") puts "Created processor #{processor[:id]} in #{processor[:mode]} mode" container end # More complex variable interactions class VariableInteractionTest def initialize @internal_status = "created" @data_containers = [] end def add_container(container) @data_containers << container container.status = "added_to_collection" @internal_status = "modified" end def process_all_containers @data_containers.each do |container| container.status = "batch_processed" end @internal_status = "processing_complete" end def get_status_summary statuses = @data_containers.map(&:status) { internal: @internal_status, containers: statuses, count: @data_containers.length } end end # Create instances for testing interaction_test = VariableInteractionTest.new interaction_test.add_container(dataclass_instance) interaction_test.add_container(second_dataclass) ================================================ FILE: test/resources/repos/rust/test_repo/Cargo.toml ================================================ [package] name = "rsandbox" version = "0.1.0" edition = "2021" [dependencies] ================================================ FILE: test/resources/repos/rust/test_repo/src/lib.rs ================================================ // This function returns the sum of 2 + 2 pub fn add() -> i32 { let res = 2 + 2; res } pub fn multiply() -> i32 { 2 * 3 } ================================================ FILE: test/resources/repos/rust/test_repo/src/main.rs ================================================ use rsandbox::add; fn main() { println!("Hello, World!"); println!("Good morning!"); println!("add result: {}", add()); println!("inserted line"); } ================================================ FILE: test/resources/repos/rust/test_repo_2024/Cargo.toml ================================================ [package] name = "rsandbox_2024" version = "0.1.0" edition = "2024" [dependencies] ================================================ FILE: test/resources/repos/rust/test_repo_2024/src/lib.rs ================================================ pub fn multiply(a: i32, b: i32) -> i32 { a * b } pub struct Calculator { pub result: i32, } impl Calculator { pub fn new() -> Self { Calculator { result: 0 } } pub fn add(&mut self, value: i32) { self.result += value; } pub fn get_result(&self) -> i32 { self.result } } ================================================ FILE: test/resources/repos/rust/test_repo_2024/src/main.rs ================================================ fn main() { println!("Hello, Rust 2024 edition!"); let result = add(2, 3); println!("2 + 3 = {}", result); } pub fn add(a: i32, b: i32) -> i32 { a + b } #[cfg(test)] mod tests { use super::*; #[test] fn test_add() { assert_eq!(add(2, 3), 5); } } ================================================ FILE: test/resources/repos/scala/build.sbt ================================================ scalaVersion := "2.13.16" ================================================ FILE: test/resources/repos/scala/project/build.properties ================================================ sbt.version=1.10.1 ================================================ FILE: test/resources/repos/scala/project/metals.sbt ================================================ // format: off // DO NOT EDIT! This file is auto-generated. // This file enables sbt-bloop to create bloop config files. addSbtPlugin("ch.epfl.scala" % "sbt-bloop" % "2.0.13") // format: on ================================================ FILE: test/resources/repos/scala/project/plugins.sbt ================================================ addSbtPlugin("ch.epfl.scala" % "sbt-bloop" % "v2.0.14") ================================================ FILE: test/resources/repos/scala/src/main/scala/com/example/Main.scala ================================================ package com.example object Main { def main(args: Array[String]): Unit = { println("Hello, Scala!") // Use Utils from another file Utils.printHello() val result = Utils.multiply(3, 4) println(s"3 * 4 = $result") // Call local methods val sum = add(5, 3) println(s"5 + 3 = $sum") } def add(a: Int, b: Int): Int = { a + b } // https://github.com/oraios/serena/issues/688 def someMethod(config: Config): Unit = { val str = config.field1 println(str) } case class Config(field1:String) } ================================================ FILE: test/resources/repos/scala/src/main/scala/com/example/Utils.scala ================================================ package com.example object Utils { def printHello(): Unit = { println("Hello from Utils!") } def multiply(x: Int, y: Int): Int = { x * y } } ================================================ FILE: test/resources/repos/solidity/test_repo/.gitignore ================================================ out/ cache/ artifacts/ node_modules/ ================================================ FILE: test/resources/repos/solidity/test_repo/contracts/Token.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import "./interfaces/IERC20.sol"; import "./lib/SafeMath.sol"; /// @title Token /// @notice A simple ERC-20 token implementation used as a test fixture. contract Token is IERC20 { using SafeMath for uint256; // ------------------------------------------------------------------------- // State variables // ------------------------------------------------------------------------- string public name; string public symbol; uint8 public decimals; uint256 private _totalSupply; mapping(address => uint256) private _balances; mapping(address => mapping(address => uint256)) private _allowances; // ------------------------------------------------------------------------- // Errors // ------------------------------------------------------------------------- /// @notice Thrown when transferring to the zero address. error ZeroAddress(); /// @notice Thrown when the caller's balance is insufficient. error InsufficientBalance(address account, uint256 required, uint256 available); /// @notice Thrown when the allowance is insufficient. error InsufficientAllowance(address spender, uint256 required, uint256 available); // ------------------------------------------------------------------------- // Constructor // ------------------------------------------------------------------------- /// @param _name Human-readable token name. /// @param _symbol Token ticker symbol. /// @param supply Initial supply minted to `msg.sender` (in whole tokens). constructor(string memory _name, string memory _symbol, uint256 supply) { name = _name; symbol = _symbol; decimals = 18; _mint(msg.sender, supply * 10 ** decimals); } // ------------------------------------------------------------------------- // IERC20 view functions // ------------------------------------------------------------------------- /// @inheritdoc IERC20 function totalSupply() external view override returns (uint256) { return _totalSupply; } /// @inheritdoc IERC20 function balanceOf(address account) external view override returns (uint256) { return _balances[account]; } /// @inheritdoc IERC20 function allowance(address owner, address spender) external view override returns (uint256) { return _allowances[owner][spender]; } // ------------------------------------------------------------------------- // IERC20 mutating functions // ------------------------------------------------------------------------- /// @inheritdoc IERC20 function transfer(address to, uint256 amount) external override returns (bool) { _transfer(msg.sender, to, amount); return true; } /// @inheritdoc IERC20 function approve(address spender, uint256 amount) external override returns (bool) { _approve(msg.sender, spender, amount); return true; } /// @inheritdoc IERC20 function transferFrom(address from, address to, uint256 amount) external override returns (bool) { uint256 currentAllowance = _allowances[from][msg.sender]; if (currentAllowance < amount) { revert InsufficientAllowance(msg.sender, amount, currentAllowance); } _approve(from, msg.sender, currentAllowance.sub(amount)); _transfer(from, to, amount); return true; } // ------------------------------------------------------------------------- // Internal helpers // ------------------------------------------------------------------------- function _transfer(address from, address to, uint256 amount) internal { if (to == address(0)) revert ZeroAddress(); uint256 fromBalance = _balances[from]; if (fromBalance < amount) { revert InsufficientBalance(from, amount, fromBalance); } _balances[from] = fromBalance.sub(amount); _balances[to] = _balances[to].add(amount); emit Transfer(from, to, amount); } function _approve(address owner, address spender, uint256 amount) internal { if (owner == address(0) || spender == address(0)) revert ZeroAddress(); _allowances[owner][spender] = amount; emit Approval(owner, spender, amount); } function _mint(address account, uint256 amount) internal { if (account == address(0)) revert ZeroAddress(); _totalSupply = _totalSupply.add(amount); _balances[account] = _balances[account].add(amount); emit Transfer(address(0), account, amount); } } ================================================ FILE: test/resources/repos/solidity/test_repo/contracts/interfaces/IERC20.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; /// @title IERC20 /// @notice Minimal ERC-20 interface used by the test token. interface IERC20 { /// @notice Emitted when tokens are transferred between accounts. event Transfer(address indexed from, address indexed to, uint256 value); /// @notice Emitted when an allowance is set via `approve`. event Approval(address indexed owner, address indexed spender, uint256 value); /// @notice Returns the total token supply. function totalSupply() external view returns (uint256); /// @notice Returns the token balance of `account`. function balanceOf(address account) external view returns (uint256); /// @notice Transfers `amount` tokens to `to`. function transfer(address to, uint256 amount) external returns (bool); /// @notice Returns the remaining allowance that `spender` has over `owner`'s tokens. function allowance(address owner, address spender) external view returns (uint256); /// @notice Sets `amount` as the allowance of `spender` over the caller's tokens. function approve(address spender, uint256 amount) external returns (bool); /// @notice Moves `amount` tokens from `from` to `to` using the allowance mechanism. function transferFrom(address from, address to, uint256 amount) external returns (bool); } ================================================ FILE: test/resources/repos/solidity/test_repo/contracts/lib/SafeMath.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; /// @title SafeMath /// @notice Arithmetic helpers with overflow checks (illustrative — 0.8+ reverts natively). library SafeMath { /// @notice Returns the sum of `a` and `b`, reverting on overflow. function add(uint256 a, uint256 b) internal pure returns (uint256) { return a + b; } /// @notice Returns `a` minus `b`, reverting on underflow. function sub(uint256 a, uint256 b) internal pure returns (uint256) { require(b <= a, "SafeMath: subtraction underflow"); return a - b; } /// @notice Returns the product of `a` and `b`, reverting on overflow. function mul(uint256 a, uint256 b) internal pure returns (uint256) { if (a == 0) return 0; return a * b; } /// @notice Returns the integer division of `a` by `b`, reverting on division by zero. function div(uint256 a, uint256 b) internal pure returns (uint256) { require(b > 0, "SafeMath: division by zero"); return a / b; } } ================================================ FILE: test/resources/repos/solidity/test_repo/foundry.toml ================================================ [profile.default] src = "contracts" out = "out" libs = [] solc = "0.8.20" ================================================ FILE: test/resources/repos/swift/test_repo/Package.swift ================================================ // swift-tools-version: 5.9 import PackageDescription let package = Package( name: "test_repo", products: [ .library( name: "test_repo", targets: ["test_repo"]), ], targets: [ .target( name: "test_repo", dependencies: []), ] ) ================================================ FILE: test/resources/repos/swift/test_repo/src/main.swift ================================================ import Foundation // Main entry point func main() { let calculator = Calculator() let result = calculator.add(5, 3) print("Result: \(result)") let user = User(name: "Alice", age: 30) user.greet() let area = Utils.calculateArea(radius: 5.0) print("Circle area: \(area)") } class Calculator { func add(_ a: Int, _ b: Int) -> Int { return a + b } func multiply(_ a: Int, _ b: Int) -> Int { return a * b } } struct User { let name: String let age: Int func greet() { print("Hello, my name is \(name) and I am \(age) years old.") } func isAdult() -> Bool { return age >= 18 } } enum Status { case active case inactive case pending } protocol Drawable { func draw() } class Circle: Drawable { let radius: Double init(radius: Double) { self.radius = radius } func draw() { print("Drawing a circle with radius \(radius)") } } // Call main main() ================================================ FILE: test/resources/repos/swift/test_repo/src/utils.swift ================================================ import Foundation public struct Utils { public static func formatDate(_ date: Date) -> String { let formatter = DateFormatter() formatter.dateStyle = .medium return formatter.string(from: date) } public static func calculateArea(radius: Double) -> Double { return Double.pi * radius * radius } } public extension String { func isValidEmail() -> Bool { let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}" return NSPredicate(format: "SELF MATCHES %@", emailRegex).evaluate(with: self) } } ================================================ FILE: test/resources/repos/systemverilog/test_repo/alu.sv ================================================ // ALU module for testing SystemVerilog LSP module alu #( parameter DATA_WIDTH = 32 ) ( input logic [DATA_WIDTH-1:0] a, input logic [DATA_WIDTH-1:0] b, input logic [2:0] op, output logic [DATA_WIDTH-1:0] result, output logic zero ); typedef enum logic [2:0] { ALU_ADD = 3'b000, ALU_SUB = 3'b001, ALU_AND = 3'b010, ALU_OR = 3'b011, ALU_XOR = 3'b100, ALU_SLL = 3'b101, ALU_SRL = 3'b110, ALU_SRA = 3'b111 } alu_op_t; always_comb begin case (op) ALU_ADD: result = a + b; ALU_SUB: result = a - b; ALU_AND: result = a & b; ALU_OR: result = a | b; ALU_XOR: result = a ^ b; ALU_SLL: result = a << b[4:0]; ALU_SRL: result = a >> b[4:0]; ALU_SRA: result = $signed(a) >>> b[4:0]; default: result = '0; endcase end assign zero = (result == '0); endmodule ================================================ FILE: test/resources/repos/systemverilog/test_repo/counter.sv ================================================ // Simple counter module for testing SystemVerilog LSP module counter #( parameter WIDTH = 8 ) ( input logic clk, input logic rst_n, input logic enable, output logic [WIDTH-1:0] count ); // Counter logic always_ff @(posedge clk or negedge rst_n) begin if (!rst_n) count <= '0; else if (enable) count <= count + 1'b1; end endmodule ================================================ FILE: test/resources/repos/systemverilog/test_repo/top.sv ================================================ // Top module that instantiates counter and alu for cross-file testing `include "types.svh" module top ( input logic clk, input logic rst_n, input logic enable, input word_t a, input word_t b, input logic [2:0] op, output byte_t count, output word_t alu_result, output logic alu_zero ); // Instantiate counter module counter #(.WIDTH(8)) u_counter ( .clk(clk), .rst_n(rst_n), .enable(enable), .count(count) ); // Instantiate ALU module alu #(.DATA_WIDTH(32)) u_alu ( .a(a), .b(b), .op(op), .result(alu_result), .zero(alu_zero) ); endmodule ================================================ FILE: test/resources/repos/systemverilog/test_repo/types.svh ================================================ // Common types header for testing SystemVerilog LSP `ifndef TYPES_SVH `define TYPES_SVH typedef logic [7:0] byte_t; typedef logic [15:0] halfword_t; typedef logic [31:0] word_t; typedef logic [63:0] doubleword_t; typedef struct packed { logic valid; logic [31:0] data; logic [3:0] tag; } tagged_data_t; `endif // TYPES_SVH ================================================ FILE: test/resources/repos/terraform/test_repo/data.tf ================================================ # Data sources for the Terraform configuration # Get the latest Ubuntu AMI data "aws_ami" "ubuntu" { most_recent = true owners = ["099720109477"] # Canonical filter { name = "name" values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"] } filter { name = "virtualization-type" values = ["hvm"] } } # Get available availability zones data "aws_availability_zones" "available" { state = "available" } # Get current AWS caller identity data "aws_caller_identity" "current" {} # Get current AWS region data "aws_region" "current" {} ================================================ FILE: test/resources/repos/terraform/test_repo/main.tf ================================================ # Main Terraform configuration terraform { required_version = ">= 1.0" required_providers { aws = { source = "hashicorp/aws" version = "~> 5.0" } } } provider "aws" { region = var.aws_region } # EC2 Instance resource "aws_instance" "web_server" { ami = data.aws_ami.ubuntu.id instance_type = var.instance_type vpc_security_group_ids = [aws_security_group.web_sg.id] subnet_id = aws_subnet.public.id user_data = <<-EOF #!/bin/bash apt-get update apt-get install -y nginx systemctl start nginx systemctl enable nginx EOF tags = { Name = "${var.project_name}-web-server" Environment = var.environment Project = var.project_name } } # S3 Bucket resource "aws_s3_bucket" "app_bucket" { bucket = "${var.project_name}-${var.environment}-bucket" tags = { Name = "${var.project_name}-bucket" Environment = var.environment Project = var.project_name } } resource "aws_s3_bucket_versioning" "app_bucket_versioning" { bucket = aws_s3_bucket.app_bucket.id versioning_configuration { status = "Enabled" } } # VPC resource "aws_vpc" "main" { cidr_block = "10.0.0.0/16" enable_dns_hostnames = true enable_dns_support = true tags = { Name = "${var.project_name}-vpc" Environment = var.environment Project = var.project_name } } # Internet Gateway resource "aws_internet_gateway" "main" { vpc_id = aws_vpc.main.id tags = { Name = "${var.project_name}-igw" Environment = var.environment Project = var.project_name } } # Public Subnet resource "aws_subnet" "public" { vpc_id = aws_vpc.main.id cidr_block = "10.0.1.0/24" availability_zone = data.aws_availability_zones.available.names[0] map_public_ip_on_launch = true tags = { Name = "${var.project_name}-public-subnet" Environment = var.environment Project = var.project_name } } # Security Group resource "aws_security_group" "web_sg" { name_prefix = "${var.project_name}-web-" vpc_id = aws_vpc.main.id ingress { from_port = 80 to_port = 80 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] } ingress { from_port = 443 to_port = 443 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] } egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } tags = { Name = "${var.project_name}-web-sg" Environment = var.environment Project = var.project_name } } ================================================ FILE: test/resources/repos/terraform/test_repo/outputs.tf ================================================ # Output values for the Terraform configuration output "instance_id" { description = "ID of the EC2 instance" value = aws_instance.web_server.id } output "instance_public_ip" { description = "Public IP address of the EC2 instance" value = aws_instance.web_server.public_ip } output "instance_public_dns" { description = "Public DNS name of the EC2 instance" value = aws_instance.web_server.public_dns } output "s3_bucket_name" { description = "Name of the S3 bucket" value = aws_s3_bucket.app_bucket.bucket } output "s3_bucket_arn" { description = "ARN of the S3 bucket" value = aws_s3_bucket.app_bucket.arn } output "vpc_id" { description = "ID of the VPC" value = aws_vpc.main.id } output "subnet_id" { description = "ID of the public subnet" value = aws_subnet.public.id } output "security_group_id" { description = "ID of the security group" value = aws_security_group.web_sg.id } output "application_url" { description = "URL to access the application" value = "http://${aws_instance.web_server.public_dns}" } ================================================ FILE: test/resources/repos/terraform/test_repo/variables.tf ================================================ # Input variables for the Terraform configuration variable "aws_region" { description = "AWS region for resources" type = string default = "us-west-2" } variable "instance_type" { description = "EC2 instance type" type = string default = "t3.micro" validation { condition = contains([ "t3.micro", "t3.small", "t3.medium", "t2.micro", "t2.small", "t2.medium" ], var.instance_type) error_message = "Instance type must be a valid t2 or t3 instance type." } } variable "environment" { description = "Environment name (dev, staging, prod)" type = string default = "dev" validation { condition = contains(["dev", "staging", "prod"], var.environment) error_message = "Environment must be dev, staging, or prod." } } variable "project_name" { description = "Name of the project" type = string default = "terraform-test" validation { condition = can(regex("^[a-z0-9-]+$", var.project_name)) error_message = "Project name must contain only lowercase letters, numbers, and hyphens." } } variable "enable_monitoring" { description = "Enable CloudWatch monitoring" type = bool default = false } variable "allowed_cidr_blocks" { description = "List of CIDR blocks allowed to access the application" type = list(string) default = ["0.0.0.0/0"] } variable "tags" { description = "Additional tags to apply to resources" type = map(string) default = {} } ================================================ FILE: test/resources/repos/toml/test_repo/Cargo.toml ================================================ [package] name = "test_project" version = "0.1.0" edition = "2021" description = "A test TOML file for Serena TOML language support" authors = ["Test Author "] license = "MIT" [dependencies] serde = { version = "1.0", features = ["derive"] } tokio = { version = "1.0", features = ["full"] } [dev-dependencies] proptest = "1.0" [features] default = ["feature1"] feature1 = [] feature2 = ["feature1"] [profile.release] lto = true opt-level = 3 [[bin]] name = "main" path = "src/main.rs" [workspace] members = ["crates/*"] ================================================ FILE: test/resources/repos/toml/test_repo/config.toml ================================================ # Configuration file with various TOML features for testing [server] host = "localhost" port = 8080 debug = false timeout = 30.5 # Inline table example endpoint = { url = "https://api.example.com", version = "v1" } [server.ssl] enabled = true cert_path = "/etc/ssl/cert.pem" key_path = "/etc/ssl/key.pem" [database] connection_string = """ postgresql://user:password@localhost:5432/mydb? sslmode=require& pool_size=10 """ [database.pool] min_connections = 5 max_connections = 20 idle_timeout = 300 [logging] level = "info" format = "json" outputs = ["stdout", "file"] [logging.file] path = "/var/log/app.log" max_size = 10485760 # 10MB max_backups = 5 compress = true # Array of inline tables [[endpoints]] name = "users" path = "/api/users" methods = ["GET", "POST", "PUT", "DELETE"] auth_required = true [[endpoints]] name = "health" path = "/health" methods = ["GET"] auth_required = false # Datetime values [metadata] created = 1979-05-27T07:32:00Z updated = 2024-01-15T10:30:00-05:00 # Special characters in strings [messages] welcome = "Hello, World!" multiline = ''' This is a multiline literal string. ''' with_escapes = "Line1\nLine2\tTabbed" ================================================ FILE: test/resources/repos/toml/test_repo/pyproject.toml ================================================ [project] name = "test-project" version = "0.1.0" description = "A test Python project for TOML support" readme = "README.md" requires-python = ">=3.11" license = {text = "MIT"} authors = [ {name = "Test Author", email = "test@example.com"} ] dependencies = [ "pydantic>=2.0", "httpx>=0.24", ] [project.optional-dependencies] dev = [ "pytest>=7.0", "ruff>=0.1", "mypy>=1.0", ] [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.ruff] line-length = 88 target-version = "py311" [tool.ruff.lint] select = ["E", "F", "I", "N", "W", "B", "S"] [tool.ruff.lint.isort] known-first-party = ["test_project"] [tool.mypy] python_version = "3.11" strict = true warn_unreachable = true [tool.pytest.ini_options] testpaths = ["tests"] python_files = ["test_*.py"] addopts = "-v --cov=src" ================================================ FILE: test/resources/repos/typescript/test_repo/index.ts ================================================ export class DemoClass { value: number; constructor(value: number) { this.value = value; } printValue() { console.log(this.value); } } export function helperFunction() { const demo = new DemoClass(42); demo.printValue(); } helperFunction(); ================================================ FILE: test/resources/repos/typescript/test_repo/tsconfig.json ================================================ { "compilerOptions": { "target": "ES2017", "module": "commonjs", "strict": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "skipLibCheck": true }, "include": ["**/*.ts"] } ================================================ FILE: test/resources/repos/typescript/test_repo/use_helper.ts ================================================ import {helperFunction} from "./index"; export function useHelper() { helperFunction(); } useHelper(); ================================================ FILE: test/resources/repos/typescript/test_repo/ws_manager.js ================================================ /** * Dummy WebSocket manager class for testing ambiguous regex matching. */ class WebSocketManager { constructor() { console.log("WebSocketManager initializing\nStatus OK"); this.ws = null; this.statusElement = document.getElementById("status"); } /** * Connects to the WebSocket server. */ connectToServer() { if (this.ws?.readyState === WebSocket.OPEN) { this.updateConnectionStatus("Already connected", true); return; } try { this.ws = new WebSocket("ws://localhost:4402"); this.updateConnectionStatus("Connecting...", false); this.ws.onopen = () => { console.log("Connected to server"); this.updateConnectionStatus("Connected", true); }; this.ws.onmessage = (event) => { console.log("Received message:", event.data); try { const data = JSON.parse(event.data); this.handleMessage(data); } catch (error) { console.error("Failed to parse message:", error); this.updateConnectionStatus("Parse error", false); } }; this.ws.onclose = (event) => { console.log("Connection closed"); const message = event.reason || undefined; this.updateConnectionStatus("Disconnected", false, message); this.ws = null; }; this.ws.onerror = (error) => { console.error("WebSocket error:", error); this.updateConnectionStatus("Connection error", false); }; } catch (error) { console.error("Failed to connect to server:", error); this.updateConnectionStatus("Connection failed", false); } } /** * Updates the connection status display. */ updateConnectionStatus(status, isConnected, message) { if (this.statusElement) { const text = message ? `${status}: ${message}` : status; this.statusElement.textContent = text; this.statusElement.style.color = isConnected ? "green" : "red"; } } /** * Handles incoming messages. */ handleMessage(data) { console.log("Handling:", data); } } ================================================ FILE: test/resources/repos/vue/test_repo/.gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* node_modules dist dist-ssr *.local # Editor directories and files .vscode/* !.vscode/extensions.json .idea .DS_Store *.suo *.ntvs* *.njsproj *.sln *.sw? ================================================ FILE: test/resources/repos/vue/test_repo/index.html ================================================ Vue Calculator
================================================ FILE: test/resources/repos/vue/test_repo/package.json ================================================ { "name": "vue-calculator-test-fixture", "version": "1.0.0", "type": "module", "description": "Vue 3 + Pinia + TypeScript test fixtures for Serena LSP testing", "dependencies": { "vue": "^3.4.0", "pinia": "^2.1.0" }, "devDependencies": { "@vue/language-server": "^2.0.0", "typescript": "~5.5.4" } } ================================================ FILE: test/resources/repos/vue/test_repo/src/App.vue ================================================ ================================================ FILE: test/resources/repos/vue/test_repo/src/components/CalculatorDisplay.vue ================================================ ================================================ FILE: test/resources/repos/vue/test_repo/src/components/CalculatorInput.vue ================================================ ================================================ FILE: test/resources/repos/vue/test_repo/src/composables/useFormatter.ts ================================================ import { ref, computed } from 'vue' import type { Ref, ComputedRef } from 'vue' import type { FormatOptions } from '@/types' /** * Composable for formatting numbers with various options. * Demonstrates: composable pattern, refs, computed, type imports */ export function useFormatter(initialPrecision: number = 2) { // State const precision = ref(initialPrecision) const useGrouping = ref(true) const locale = ref('en-US') // Computed properties const formatOptions = computed((): FormatOptions => ({ maxDecimals: precision.value, useGrouping: useGrouping.value })) // Methods const formatNumber = (value: number): string => { return value.toLocaleString(locale.value, { minimumFractionDigits: precision.value, maximumFractionDigits: precision.value, useGrouping: useGrouping.value }) } const formatCurrency = (value: number, currency: string = 'USD'): string => { return value.toLocaleString(locale.value, { style: 'currency', currency, minimumFractionDigits: precision.value, maximumFractionDigits: precision.value }) } const formatPercentage = (value: number): string => { return `${(value * 100).toFixed(precision.value)}%` } const setPrecision = (newPrecision: number): void => { if (newPrecision >= 0 && newPrecision <= 10) { precision.value = newPrecision } } const toggleGrouping = (): void => { useGrouping.value = !useGrouping.value } const setLocale = (newLocale: string): void => { locale.value = newLocale } // Return composable API return { // State (readonly) precision: computed(() => precision.value), useGrouping: computed(() => useGrouping.value), locale: computed(() => locale.value), formatOptions, // Methods formatNumber, formatCurrency, formatPercentage, setPrecision, toggleGrouping, setLocale } } /** * Composable for time formatting. * Demonstrates: simpler composable, pure functions */ export function useTimeFormatter() { const formatTime = (date: Date): string => { return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) } const formatDate = (date: Date): string => { return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) } const formatDateTime = (date: Date): string => { return `${formatDate(date)} ${formatTime(date)}` } const getRelativeTime = (date: Date): string => { const now = new Date() const diffMs = now.getTime() - date.getTime() const diffSecs = Math.floor(diffMs / 1000) const diffMins = Math.floor(diffSecs / 60) const diffHours = Math.floor(diffMins / 60) const diffDays = Math.floor(diffHours / 24) if (diffSecs < 60) return 'just now' if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago` if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago` return `${diffDays} day${diffDays > 1 ? 's' : ''} ago` } return { formatTime, formatDate, formatDateTime, getRelativeTime } } /** * Type definitions for return types */ export type UseFormatterReturn = ReturnType export type UseTimeFormatterReturn = ReturnType ================================================ FILE: test/resources/repos/vue/test_repo/src/composables/useTheme.ts ================================================ import { ref, computed, watch, inject, provide, type InjectionKey, type Ref } from 'vue' /** * Theme configuration type */ export interface ThemeConfig { isDark: boolean primaryColor: string fontSize: number } /** * Injection key for theme - demonstrates provide/inject pattern */ export const ThemeKey: InjectionKey> = Symbol('theme') /** * Composable for theme management with watchers. * Demonstrates: watch, provide/inject, localStorage interaction */ export function useThemeProvider() { // Initialize theme from localStorage or defaults const loadThemeFromStorage = (): ThemeConfig => { const stored = localStorage.getItem('app-theme') if (stored) { try { return JSON.parse(stored) } catch { // Fall through to defaults } } return { isDark: false, primaryColor: '#667eea', fontSize: 16 } } const theme = ref(loadThemeFromStorage()) // Computed properties const isDarkMode = computed(() => theme.value.isDark) const themeClass = computed(() => theme.value.isDark ? 'dark-theme' : 'light-theme') // Watch for theme changes and persist to localStorage watch( theme, (newTheme) => { localStorage.setItem('app-theme', JSON.stringify(newTheme)) document.documentElement.className = newTheme.isDark ? 'dark' : 'light' }, { deep: true } ) // Methods const toggleDarkMode = (): void => { theme.value.isDark = !theme.value.isDark } const setPrimaryColor = (color: string): void => { theme.value.primaryColor = color } const setFontSize = (size: number): void => { if (size >= 12 && size <= 24) { theme.value.fontSize = size } } const resetTheme = (): void => { theme.value = { isDark: false, primaryColor: '#667eea', fontSize: 16 } } // Provide theme to child components provide(ThemeKey, theme) return { theme, isDarkMode, themeClass, toggleDarkMode, setPrimaryColor, setFontSize, resetTheme } } /** * Composable for consuming theme in child components. * Demonstrates: inject pattern */ export function useTheme() { const theme = inject(ThemeKey) if (!theme) { throw new Error('useTheme must be used within a component that provides ThemeKey') } const isDark = computed(() => theme.value.isDark) const primaryColor = computed(() => theme.value.primaryColor) const fontSize = computed(() => theme.value.fontSize) return { theme, isDark, primaryColor, fontSize } } ================================================ FILE: test/resources/repos/vue/test_repo/src/main.ts ================================================ import { createApp } from 'vue' import { createPinia } from 'pinia' import App from './App.vue' const app = createApp(App) const pinia = createPinia() app.use(pinia) app.mount('#app') ================================================ FILE: test/resources/repos/vue/test_repo/src/stores/calculator.ts ================================================ import { defineStore } from 'pinia' import type { HistoryEntry, Operation, CalculatorState } from '@/types' export const useCalculatorStore = defineStore('calculator', { state: (): CalculatorState => ({ currentValue: 0, previousValue: null, operation: null, history: [], displayValue: '0' }), getters: { /** * Get the most recent history entries (last 10) */ recentHistory: (state): HistoryEntry[] => { return state.history.slice(-10).reverse() }, /** * Check if calculator has any history */ hasHistory: (state): boolean => { return state.history.length > 0 }, /** * Get the current display text */ display: (state): string => { return state.displayValue } }, actions: { /** * Set a number value */ setNumber(value: number) { this.currentValue = value this.displayValue = value.toString() }, /** * Append a digit to the current value */ appendDigit(digit: number) { if (this.displayValue === '0') { this.displayValue = digit.toString() } else { this.displayValue += digit.toString() } this.currentValue = parseFloat(this.displayValue) }, /** * Add two numbers */ add() { if (this.previousValue !== null && this.operation) { this.executeOperation() } this.previousValue = this.currentValue this.operation = 'add' this.displayValue = '0' }, /** * Subtract two numbers */ subtract() { if (this.previousValue !== null && this.operation) { this.executeOperation() } this.previousValue = this.currentValue this.operation = 'subtract' this.displayValue = '0' }, /** * Multiply two numbers */ multiply() { if (this.previousValue !== null && this.operation) { this.executeOperation() } this.previousValue = this.currentValue this.operation = 'multiply' this.displayValue = '0' }, /** * Divide two numbers */ divide() { if (this.previousValue !== null && this.operation) { this.executeOperation() } this.previousValue = this.currentValue this.operation = 'divide' this.displayValue = '0' }, /** * Execute the pending operation */ executeOperation() { if (this.previousValue === null || this.operation === null) { return } let result = 0 const prev = this.previousValue const current = this.currentValue let expression = '' switch (this.operation) { case 'add': result = prev + current expression = `${prev} + ${current}` break case 'subtract': result = prev - current expression = `${prev} - ${current}` break case 'multiply': result = prev * current expression = `${prev} × ${current}` break case 'divide': if (current === 0) { this.displayValue = 'Error' this.clear() return } result = prev / current expression = `${prev} ÷ ${current}` break } // Add to history this.history.push({ expression, result, timestamp: new Date() }) this.currentValue = result this.displayValue = result.toString() this.previousValue = null this.operation = null }, /** * Calculate the equals operation */ equals() { this.executeOperation() }, /** * Clear the calculator state */ clear() { this.currentValue = 0 this.previousValue = null this.operation = null this.displayValue = '0' }, /** * Clear all history */ clearHistory() { this.history = [] } } }) ================================================ FILE: test/resources/repos/vue/test_repo/src/types/index.ts ================================================ /** * Represents a single calculation in the history */ export interface HistoryEntry { expression: string result: number timestamp: Date } /** * Valid calculator operations */ export type Operation = 'add' | 'subtract' | 'multiply' | 'divide' | null /** * The complete state of the calculator */ export interface CalculatorState { currentValue: number previousValue: number | null operation: Operation history: HistoryEntry[] displayValue: string } /** * Format options for displaying numbers */ export interface FormatOptions { maxDecimals?: number useGrouping?: boolean } ================================================ FILE: test/resources/repos/vue/test_repo/tsconfig.json ================================================ { "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, "module": "ESNext", "lib": ["ES2020", "DOM", "DOM.Iterable"], "skipLibCheck": true, "moduleResolution": "bundler", "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "preserve", "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "baseUrl": ".", "paths": { "@/*": ["./src/*"] } }, "plugins": [ { "name": "@vue/typescript-plugin" } ], "include": ["src/**/*.ts", "src/**/*.vue"], "references": [{ "path": "./tsconfig.node.json" }] } ================================================ FILE: test/resources/repos/vue/test_repo/tsconfig.node.json ================================================ { "compilerOptions": { "composite": true, "skipLibCheck": true, "module": "ESNext", "moduleResolution": "bundler", "allowSyntheticDefaultImports": true, "strict": true }, "include": ["vite.config.ts"] } ================================================ FILE: test/resources/repos/vue/test_repo/vite.config.ts ================================================ import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import { fileURLToPath, URL } from 'node:url' export default defineConfig({ plugins: [vue()], resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } } }) ================================================ FILE: test/resources/repos/yaml/test_repo/config.yaml ================================================ # Application configuration app: name: test-application version: 1.0.0 port: 8080 debug: true database: host: localhost port: 5432 name: testdb username: admin password: secret123 logging: level: info format: json outputs: - console - file features: authentication: true caching: false monitoring: true ================================================ FILE: test/resources/repos/yaml/test_repo/data.yaml ================================================ # Sample data structure users: - id: 1 name: John Doe email: john@example.com roles: - admin - developer active: true - id: 2 name: Jane Smith email: jane@example.com roles: - developer active: true - id: 3 name: Bob Johnson email: bob@example.com roles: - viewer active: false projects: - name: project-alpha status: active team: - John Doe - Jane Smith tags: - backend - api - name: project-beta status: planning team: - Jane Smith tags: - frontend - ui ================================================ FILE: test/resources/repos/yaml/test_repo/services.yml ================================================ # Docker compose services definition version: '3.8' services: web: image: nginx:latest ports: - "80:80" - "443:443" volumes: - ./html:/usr/share/nginx/html environment: - NGINX_HOST=localhost - NGINX_PORT=80 networks: - frontend api: image: node:18 ports: - "3000:3000" depends_on: - database environment: - NODE_ENV=production - DB_HOST=database networks: - frontend - backend database: image: postgres:15 ports: - "5432:5432" environment: - POSTGRES_DB=mydb - POSTGRES_USER=admin - POSTGRES_PASSWORD=password volumes: - db-data:/var/lib/postgresql/data networks: - backend networks: frontend: driver: bridge backend: driver: bridge volumes: db-data: ================================================ FILE: test/resources/repos/zig/test_repo/.gitignore ================================================ zig-cache/ zig-out/ .zig-cache/ build/ dist/ ================================================ FILE: test/resources/repos/zig/test_repo/build.zig ================================================ const std = @import("std"); pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); const exe = b.addExecutable(.{ .name = "test_repo", .root_source_file = b.path("src/main.zig"), .target = target, .optimize = optimize, }); b.installArtifact(exe); const run_cmd = b.addRunArtifact(exe); run_cmd.step.dependOn(b.getInstallStep()); const run_step = b.step("run", "Run the app"); run_step.dependOn(&run_cmd.step); const lib_tests = b.addTest(.{ .root_source_file = b.path("src/calculator.zig"), .target = target, .optimize = optimize, }); const run_lib_tests = b.addRunArtifact(lib_tests); const test_step = b.step("test", "Run unit tests"); test_step.dependOn(&run_lib_tests.step); } ================================================ FILE: test/resources/repos/zig/test_repo/src/calculator.zig ================================================ const std = @import("std"); pub const CalculatorError = error{ DivisionByZero, Overflow, }; pub const Calculator = struct { const Self = @This(); pub fn init() Self { return .{}; } pub fn add(self: Self, a: i32, b: i32) i32 { _ = self; return a + b; } pub fn subtract(self: Self, a: i32, b: i32) i32 { _ = self; return a - b; } pub fn multiply(self: Self, a: i32, b: i32) i32 { _ = self; return a * b; } pub fn divide(self: Self, a: i32, b: i32) !f64 { _ = self; if (b == 0) { return CalculatorError.DivisionByZero; } return @as(f64, @floatFromInt(a)) / @as(f64, @floatFromInt(b)); } pub fn power(self: Self, base: i32, exponent: u32) i64 { _ = self; return std.math.pow(i64, base, exponent); } }; test "Calculator add" { const calc = Calculator.init(); try std.testing.expectEqual(@as(i32, 7), calc.add(3, 4)); try std.testing.expectEqual(@as(i32, 0), calc.add(-5, 5)); } test "Calculator subtract" { const calc = Calculator.init(); try std.testing.expectEqual(@as(i32, -1), calc.subtract(3, 4)); try std.testing.expectEqual(@as(i32, 10), calc.subtract(15, 5)); } test "Calculator multiply" { const calc = Calculator.init(); try std.testing.expectEqual(@as(i32, 12), calc.multiply(3, 4)); try std.testing.expectEqual(@as(i32, -25), calc.multiply(-5, 5)); } test "Calculator divide" { const calc = Calculator.init(); try std.testing.expectEqual(@as(f64, 2.0), try calc.divide(10, 5)); try std.testing.expectError(CalculatorError.DivisionByZero, calc.divide(10, 0)); } ================================================ FILE: test/resources/repos/zig/test_repo/src/main.zig ================================================ const std = @import("std"); const calculator = @import("calculator.zig"); const math_utils = @import("math_utils.zig"); pub fn main() !void { const stdout = std.io.getStdOut().writer(); const calc = calculator.Calculator.init(); const sum = calc.add(10, 5); const diff = calc.subtract(10, 5); const prod = calc.multiply(10, 5); const quot = calc.divide(10, 5) catch |err| { try stdout.print("Division error: {}\n", .{err}); return; }; try stdout.print("10 + 5 = {}\n", .{sum}); try stdout.print("10 - 5 = {}\n", .{diff}); try stdout.print("10 * 5 = {}\n", .{prod}); try stdout.print("10 / 5 = {}\n", .{quot}); const factorial_result = math_utils.factorial(5); try stdout.print("5! = {}\n", .{factorial_result}); const is_prime = math_utils.isPrime(17); try stdout.print("Is 17 prime? {}\n", .{is_prime}); } pub fn greeting(name: []const u8) []const u8 { return std.fmt.allocPrint(std.heap.page_allocator, "Hello, {s}!", .{name}) catch "Hello!"; } ================================================ FILE: test/resources/repos/zig/test_repo/src/math_utils.zig ================================================ const std = @import("std"); pub fn factorial(n: u32) u64 { if (n == 0 or n == 1) { return 1; } var result: u64 = 1; var i: u32 = 2; while (i <= n) : (i += 1) { result *= i; } return result; } pub fn isPrime(n: u32) bool { if (n <= 1) return false; if (n <= 3) return true; if (n % 2 == 0 or n % 3 == 0) return false; var i: u32 = 5; while (i * i <= n) : (i += 6) { if (n % i == 0 or n % (i + 2) == 0) { return false; } } return true; } pub fn gcd(a: u32, b: u32) u32 { var x = a; var y = b; while (y != 0) { const temp = y; y = x % y; x = temp; } return x; } pub fn lcm(a: u32, b: u32) u32 { return (a * b) / gcd(a, b); } test "factorial" { try std.testing.expectEqual(@as(u64, 1), factorial(0)); try std.testing.expectEqual(@as(u64, 1), factorial(1)); try std.testing.expectEqual(@as(u64, 120), factorial(5)); try std.testing.expectEqual(@as(u64, 3628800), factorial(10)); } test "isPrime" { try std.testing.expect(!isPrime(0)); try std.testing.expect(!isPrime(1)); try std.testing.expect(isPrime(2)); try std.testing.expect(isPrime(3)); try std.testing.expect(!isPrime(4)); try std.testing.expect(isPrime(17)); try std.testing.expect(!isPrime(100)); } test "gcd and lcm" { try std.testing.expectEqual(@as(u32, 6), gcd(12, 18)); try std.testing.expectEqual(@as(u32, 1), gcd(17, 19)); try std.testing.expectEqual(@as(u32, 36), lcm(12, 18)); } ================================================ FILE: test/resources/repos/zig/test_repo/zls.json ================================================ { "enable_build_on_save": true, "build_on_save_args": ["build"], "enable_autofix": false, "semantic_tokens": "full", "enable_inlay_hints": true, "inlay_hints_show_builtin": true, "inlay_hints_exclude_single_argument": true, "inlay_hints_show_parameter_name": true, "skip_std_references": false, "max_detail_length": 1048576 } ================================================ FILE: test/serena/__init__.py ================================================ ================================================ FILE: test/serena/__snapshots__/test_symbol_editing.ambr ================================================ # serializer version: 1 # name: test_delete_symbol[test_case0] ''' """ Test module for variable declarations and usage. This module tests various types of variable declarations and usages including: - Module-level variables - Class-level variables - Instance variables - Variable reassignments """ from dataclasses import dataclass, field # Module-level variables module_var = "Initial module value" reassignable_module_var = 10 reassignable_module_var = 20 # Reassigned # Module-level variable with type annotation typed_module_var: int = 42 # Regular class with class and instance variables # Dataclass with variables @dataclass class VariableDataclass: """Dataclass that contains various fields.""" # Field variables with type annotations id: int name: str items: list[str] = field(default_factory=list) metadata: dict[str, str] = field(default_factory=dict) optional_value: float | None = None # This will be reassigned in various places status: str = "pending" # Function that uses the module variables def use_module_variables(): """Function that uses module-level variables.""" result = module_var + " used in function" other_result = reassignable_module_var * 2 return result, other_result # Create instances and use variables dataclass_instance = VariableDataclass(id=1, name="Test") dataclass_instance.status = "active" # Reassign dataclass field # Use variables at module level module_result = module_var + " used at module level" other_module_result = reassignable_module_var + 30 # Create a second dataclass instance with different status second_dataclass = VariableDataclass(id=2, name="Another Test") second_dataclass.status = "completed" # Another reassignment of status ''' # --- # name: test_delete_symbol[test_case1] ''' export function helperFunction() { const demo = new DemoClass(42); demo.printValue(); } helperFunction(); ''' # --- # name: test_delete_symbol_vue[test_case0] ''' ''' # --- # name: test_insert_in_rel_to_symbol[test_case0-after] ''' """ Test module for variable declarations and usage. This module tests various types of variable declarations and usages including: - Module-level variables - Class-level variables - Instance variables - Variable reassignments """ from dataclasses import dataclass, field # Module-level variables module_var = "Initial module value" reassignable_module_var = 10 reassignable_module_var = 20 # Reassigned # Module-level variable with type annotation typed_module_var: int = 42 new_module_var = "Inserted after typed_module_var" # Regular class with class and instance variables class VariableContainer: """Class that contains various variables.""" # Class-level variables class_var = "Initial class value" reassignable_class_var = True reassignable_class_var = False # Reassigned #noqa: PIE794 # Class-level variable with type annotation typed_class_var: str = "typed value" def __init__(self): # Instance variables self.instance_var = "Initial instance value" self.reassignable_instance_var = 100 # Instance variable with type annotation self.typed_instance_var: list[str] = ["item1", "item2"] def modify_instance_var(self): # Reassign instance variable self.instance_var = "Modified instance value" self.reassignable_instance_var = 200 # Reassigned def use_module_var(self): # Use module-level variables result = module_var + " used in method" other_result = reassignable_module_var + 5 return result, other_result def use_class_var(self): # Use class-level variables result = VariableContainer.class_var + " used in method" other_result = VariableContainer.reassignable_class_var return result, other_result # Dataclass with variables @dataclass class VariableDataclass: """Dataclass that contains various fields.""" # Field variables with type annotations id: int name: str items: list[str] = field(default_factory=list) metadata: dict[str, str] = field(default_factory=dict) optional_value: float | None = None # This will be reassigned in various places status: str = "pending" # Function that uses the module variables def use_module_variables(): """Function that uses module-level variables.""" result = module_var + " used in function" other_result = reassignable_module_var * 2 return result, other_result # Create instances and use variables dataclass_instance = VariableDataclass(id=1, name="Test") dataclass_instance.status = "active" # Reassign dataclass field # Use variables at module level module_result = module_var + " used at module level" other_module_result = reassignable_module_var + 30 # Create a second dataclass instance with different status second_dataclass = VariableDataclass(id=2, name="Another Test") second_dataclass.status = "completed" # Another reassignment of status ''' # --- # name: test_insert_in_rel_to_symbol[test_case0-before] ''' """ Test module for variable declarations and usage. This module tests various types of variable declarations and usages including: - Module-level variables - Class-level variables - Instance variables - Variable reassignments """ from dataclasses import dataclass, field # Module-level variables module_var = "Initial module value" reassignable_module_var = 10 reassignable_module_var = 20 # Reassigned # Module-level variable with type annotation new_module_var = "Inserted after typed_module_var" typed_module_var: int = 42 # Regular class with class and instance variables class VariableContainer: """Class that contains various variables.""" # Class-level variables class_var = "Initial class value" reassignable_class_var = True reassignable_class_var = False # Reassigned #noqa: PIE794 # Class-level variable with type annotation typed_class_var: str = "typed value" def __init__(self): # Instance variables self.instance_var = "Initial instance value" self.reassignable_instance_var = 100 # Instance variable with type annotation self.typed_instance_var: list[str] = ["item1", "item2"] def modify_instance_var(self): # Reassign instance variable self.instance_var = "Modified instance value" self.reassignable_instance_var = 200 # Reassigned def use_module_var(self): # Use module-level variables result = module_var + " used in method" other_result = reassignable_module_var + 5 return result, other_result def use_class_var(self): # Use class-level variables result = VariableContainer.class_var + " used in method" other_result = VariableContainer.reassignable_class_var return result, other_result # Dataclass with variables @dataclass class VariableDataclass: """Dataclass that contains various fields.""" # Field variables with type annotations id: int name: str items: list[str] = field(default_factory=list) metadata: dict[str, str] = field(default_factory=dict) optional_value: float | None = None # This will be reassigned in various places status: str = "pending" # Function that uses the module variables def use_module_variables(): """Function that uses module-level variables.""" result = module_var + " used in function" other_result = reassignable_module_var * 2 return result, other_result # Create instances and use variables dataclass_instance = VariableDataclass(id=1, name="Test") dataclass_instance.status = "active" # Reassign dataclass field # Use variables at module level module_result = module_var + " used at module level" other_module_result = reassignable_module_var + 30 # Create a second dataclass instance with different status second_dataclass = VariableDataclass(id=2, name="Another Test") second_dataclass.status = "completed" # Another reassignment of status ''' # --- # name: test_insert_in_rel_to_symbol[test_case1-after] ''' """ Test module for variable declarations and usage. This module tests various types of variable declarations and usages including: - Module-level variables - Class-level variables - Instance variables - Variable reassignments """ from dataclasses import dataclass, field # Module-level variables module_var = "Initial module value" reassignable_module_var = 10 reassignable_module_var = 20 # Reassigned # Module-level variable with type annotation typed_module_var: int = 42 # Regular class with class and instance variables class VariableContainer: """Class that contains various variables.""" # Class-level variables class_var = "Initial class value" reassignable_class_var = True reassignable_class_var = False # Reassigned #noqa: PIE794 # Class-level variable with type annotation typed_class_var: str = "typed value" def __init__(self): # Instance variables self.instance_var = "Initial instance value" self.reassignable_instance_var = 100 # Instance variable with type annotation self.typed_instance_var: list[str] = ["item1", "item2"] def modify_instance_var(self): # Reassign instance variable self.instance_var = "Modified instance value" self.reassignable_instance_var = 200 # Reassigned def use_module_var(self): # Use module-level variables result = module_var + " used in method" other_result = reassignable_module_var + 5 return result, other_result def use_class_var(self): # Use class-level variables result = VariableContainer.class_var + " used in method" other_result = VariableContainer.reassignable_class_var return result, other_result # Dataclass with variables @dataclass class VariableDataclass: """Dataclass that contains various fields.""" # Field variables with type annotations id: int name: str items: list[str] = field(default_factory=list) metadata: dict[str, str] = field(default_factory=dict) optional_value: float | None = None # This will be reassigned in various places status: str = "pending" # Function that uses the module variables def use_module_variables(): """Function that uses module-level variables.""" result = module_var + " used in function" other_result = reassignable_module_var * 2 return result, other_result def new_inserted_function(): print("This is a new function inserted before another.") # Create instances and use variables dataclass_instance = VariableDataclass(id=1, name="Test") dataclass_instance.status = "active" # Reassign dataclass field # Use variables at module level module_result = module_var + " used at module level" other_module_result = reassignable_module_var + 30 # Create a second dataclass instance with different status second_dataclass = VariableDataclass(id=2, name="Another Test") second_dataclass.status = "completed" # Another reassignment of status ''' # --- # name: test_insert_in_rel_to_symbol[test_case1-before] ''' """ Test module for variable declarations and usage. This module tests various types of variable declarations and usages including: - Module-level variables - Class-level variables - Instance variables - Variable reassignments """ from dataclasses import dataclass, field # Module-level variables module_var = "Initial module value" reassignable_module_var = 10 reassignable_module_var = 20 # Reassigned # Module-level variable with type annotation typed_module_var: int = 42 # Regular class with class and instance variables class VariableContainer: """Class that contains various variables.""" # Class-level variables class_var = "Initial class value" reassignable_class_var = True reassignable_class_var = False # Reassigned #noqa: PIE794 # Class-level variable with type annotation typed_class_var: str = "typed value" def __init__(self): # Instance variables self.instance_var = "Initial instance value" self.reassignable_instance_var = 100 # Instance variable with type annotation self.typed_instance_var: list[str] = ["item1", "item2"] def modify_instance_var(self): # Reassign instance variable self.instance_var = "Modified instance value" self.reassignable_instance_var = 200 # Reassigned def use_module_var(self): # Use module-level variables result = module_var + " used in method" other_result = reassignable_module_var + 5 return result, other_result def use_class_var(self): # Use class-level variables result = VariableContainer.class_var + " used in method" other_result = VariableContainer.reassignable_class_var return result, other_result # Dataclass with variables @dataclass class VariableDataclass: """Dataclass that contains various fields.""" # Field variables with type annotations id: int name: str items: list[str] = field(default_factory=list) metadata: dict[str, str] = field(default_factory=dict) optional_value: float | None = None # This will be reassigned in various places status: str = "pending" # Function that uses the module variables def new_inserted_function(): print("This is a new function inserted before another.") def use_module_variables(): """Function that uses module-level variables.""" result = module_var + " used in function" other_result = reassignable_module_var * 2 return result, other_result # Create instances and use variables dataclass_instance = VariableDataclass(id=1, name="Test") dataclass_instance.status = "active" # Reassign dataclass field # Use variables at module level module_result = module_var + " used at module level" other_module_result = reassignable_module_var + 30 # Create a second dataclass instance with different status second_dataclass = VariableDataclass(id=2, name="Another Test") second_dataclass.status = "completed" # Another reassignment of status ''' # --- # name: test_insert_in_rel_to_symbol[test_case2-after] ''' export class DemoClass { value: number; constructor(value: number) { this.value = value; } printValue() { console.log(this.value); } } function newFunctionAfterClass(): void { console.log("This function is after DemoClass."); } export function helperFunction() { const demo = new DemoClass(42); demo.printValue(); } helperFunction(); ''' # --- # name: test_insert_in_rel_to_symbol[test_case2-before] ''' function newFunctionAfterClass(): void { console.log("This function is after DemoClass."); } export class DemoClass { value: number; constructor(value: number) { this.value = value; } printValue() { console.log(this.value); } } export function helperFunction() { const demo = new DemoClass(42); demo.printValue(); } helperFunction(); ''' # --- # name: test_insert_in_rel_to_symbol[test_case3-after] ''' export class DemoClass { value: number; constructor(value: number) { this.value = value; } printValue() { console.log(this.value); } } export function helperFunction() { const demo = new DemoClass(42); demo.printValue(); } function newInsertedFunction(): void { console.log("This is a new function inserted before another."); } helperFunction(); ''' # --- # name: test_insert_in_rel_to_symbol[test_case3-before] ''' export class DemoClass { value: number; constructor(value: number) { this.value = value; } printValue() { console.log(this.value); } } function newInsertedFunction(): void { console.log("This is a new function inserted before another."); } export function helperFunction() { const demo = new DemoClass(42); demo.printValue(); } helperFunction(); ''' # --- # name: test_insert_in_rel_to_symbol_vue[test_case0-after] ''' ''' # --- # name: test_insert_in_rel_to_symbol_vue[test_case0-before] ''' ''' # --- # name: test_insert_python_class_after ''' """ Test module for variable declarations and usage. This module tests various types of variable declarations and usages including: - Module-level variables - Class-level variables - Instance variables - Variable reassignments """ from dataclasses import dataclass, field # Module-level variables module_var = "Initial module value" reassignable_module_var = 10 reassignable_module_var = 20 # Reassigned # Module-level variable with type annotation typed_module_var: int = 42 # Regular class with class and instance variables class VariableContainer: """Class that contains various variables.""" # Class-level variables class_var = "Initial class value" reassignable_class_var = True reassignable_class_var = False # Reassigned #noqa: PIE794 # Class-level variable with type annotation typed_class_var: str = "typed value" def __init__(self): # Instance variables self.instance_var = "Initial instance value" self.reassignable_instance_var = 100 # Instance variable with type annotation self.typed_instance_var: list[str] = ["item1", "item2"] def modify_instance_var(self): # Reassign instance variable self.instance_var = "Modified instance value" self.reassignable_instance_var = 200 # Reassigned def use_module_var(self): # Use module-level variables result = module_var + " used in method" other_result = reassignable_module_var + 5 return result, other_result def use_class_var(self): # Use class-level variables result = VariableContainer.class_var + " used in method" other_result = VariableContainer.reassignable_class_var return result, other_result # Dataclass with variables @dataclass class VariableDataclass: """Dataclass that contains various fields.""" # Field variables with type annotations id: int name: str items: list[str] = field(default_factory=list) metadata: dict[str, str] = field(default_factory=dict) optional_value: float | None = None # This will be reassigned in various places status: str = "pending" class NewInsertedClass: pass # Function that uses the module variables def use_module_variables(): """Function that uses module-level variables.""" result = module_var + " used in function" other_result = reassignable_module_var * 2 return result, other_result # Create instances and use variables dataclass_instance = VariableDataclass(id=1, name="Test") dataclass_instance.status = "active" # Reassign dataclass field # Use variables at module level module_result = module_var + " used at module level" other_module_result = reassignable_module_var + 30 # Create a second dataclass instance with different status second_dataclass = VariableDataclass(id=2, name="Another Test") second_dataclass.status = "completed" # Another reassignment of status ''' # --- # name: test_insert_python_class_before ''' """ Test module for variable declarations and usage. This module tests various types of variable declarations and usages including: - Module-level variables - Class-level variables - Instance variables - Variable reassignments """ from dataclasses import dataclass, field # Module-level variables module_var = "Initial module value" reassignable_module_var = 10 reassignable_module_var = 20 # Reassigned # Module-level variable with type annotation typed_module_var: int = 42 # Regular class with class and instance variables class VariableContainer: """Class that contains various variables.""" # Class-level variables class_var = "Initial class value" reassignable_class_var = True reassignable_class_var = False # Reassigned #noqa: PIE794 # Class-level variable with type annotation typed_class_var: str = "typed value" def __init__(self): # Instance variables self.instance_var = "Initial instance value" self.reassignable_instance_var = 100 # Instance variable with type annotation self.typed_instance_var: list[str] = ["item1", "item2"] def modify_instance_var(self): # Reassign instance variable self.instance_var = "Modified instance value" self.reassignable_instance_var = 200 # Reassigned def use_module_var(self): # Use module-level variables result = module_var + " used in method" other_result = reassignable_module_var + 5 return result, other_result def use_class_var(self): # Use class-level variables result = VariableContainer.class_var + " used in method" other_result = VariableContainer.reassignable_class_var return result, other_result # Dataclass with variables class NewInsertedClass: pass @dataclass class VariableDataclass: """Dataclass that contains various fields.""" # Field variables with type annotations id: int name: str items: list[str] = field(default_factory=list) metadata: dict[str, str] = field(default_factory=dict) optional_value: float | None = None # This will be reassigned in various places status: str = "pending" # Function that uses the module variables def use_module_variables(): """Function that uses module-level variables.""" result = module_var + " used in function" other_result = reassignable_module_var * 2 return result, other_result # Create instances and use variables dataclass_instance = VariableDataclass(id=1, name="Test") dataclass_instance.status = "active" # Reassign dataclass field # Use variables at module level module_result = module_var + " used at module level" other_module_result = reassignable_module_var + 30 # Create a second dataclass instance with different status second_dataclass = VariableDataclass(id=2, name="Another Test") second_dataclass.status = "completed" # Another reassignment of status ''' # --- # name: test_nix_symbol_replacement_no_double_semicolon ''' # default.nix - Traditional Nix expression for backwards compatibility { pkgs ? import { } }: let # Import library functions lib = pkgs.lib; stdenv = pkgs.stdenv; # Import our custom utilities utils = import ./lib/utils.nix { inherit lib; }; # Custom function to create a greeting makeGreeting = name: "Hello, ${name}!"; # List manipulation functions (using imported utils) listUtils = { double = list: map (x: x * 2) list; sum = list: lib.foldl' (acc: x: acc + x) 0 list; average = list: if list == [ ] then 0 else (listUtils.sum list) / (builtins.length list); # Use function from imported utils unique = utils.lists.unique; }; # String utilities stringUtils = rec { capitalize = str: let first = lib.substring 0 1 str; rest = lib.substring 1 (-1) str; in (lib.toUpper first) + rest; repeat = n: str: lib.concatStrings (lib.genList (_: str) n); padLeft = width: char: str: let len = lib.stringLength str; padding = if len >= width then 0 else width - len; in (repeat padding char) + str; }; # Package builder helper buildSimplePackage = { name, version, script }: stdenv.mkDerivation { pname = name; inherit version; phases = [ "installPhase" ]; installPhase = '' mkdir -p $out/bin cat > $out/bin/${name} << EOF #!/usr/bin/env bash ${script} EOF chmod +x $out/bin/${name} ''; }; in rec { # Export utilities inherit listUtils stringUtils makeGreeting; # Export imported utilities directly inherit (utils) math strings; # Example packages hello = buildSimplePackage { name = "hello"; version = "1.0"; script = '' echo "${makeGreeting "World"}" ''; }; calculator = buildSimplePackage { name = "calculator"; version = "0.1"; script = '' if [ $# -ne 3 ]; then echo "Usage: calculator " exit 1 fi case $2 in +) echo $(($1 + $3)) ;; -) echo $(($1 - $3)) ;; x) echo $(($1 * $3)) ;; /) echo $(($1 / $3)) ;; *) echo "Unknown operator: $2" ;; esac ''; }; # Environment with multiple packages devEnv = pkgs.buildEnv { name = "dev-environment"; paths = with pkgs; [ git vim bash hello calculator ]; }; # Shell derivation shell = pkgs.mkShell { buildInputs = with pkgs; [ bash coreutils findutils gnugrep gnused ]; shellHook = '' echo "Entering Nix shell environment" echo "Available custom functions: makeGreeting, listUtils, stringUtils" ''; }; # Configuration example config = { system = { stateVersion = "23.11"; enable = true; }; services = { nginx = { enable = false; virtualHosts = { "example.com" = { root = "/var/www/example"; locations."/" = { index = "index.html"; }; }; }; }; }; users = { c = 3; }; }; # Recursive attribute set example tree = { root = { value = 1; left = { value = 2; left = { value = 4; }; right = { value = 5; }; }; right = { value = 3; left = { value = 6; }; right = { value = 7; }; }; }; # Tree traversal function traverse = node: if node ? left && node ? right then [ node.value ] ++ (tree.traverse node.left) ++ (tree.traverse node.right) else if node ? value then [ node.value ] else [ ]; }; } ''' # --- # name: test_rename_symbol ''' """ Test module for variable declarations and usage. This module tests various types of variable declarations and usages including: - Module-level variables - Class-level variables - Instance variables - Variable reassignments """ from dataclasses import dataclass, field # Module-level variables module_var = "Initial module value" reassignable_module_var = 10 reassignable_module_var = 20 # Reassigned # Module-level variable with type annotation renamed_typed_module_var: int = 42 # Regular class with class and instance variables class VariableContainer: """Class that contains various variables.""" # Class-level variables class_var = "Initial class value" reassignable_class_var = True reassignable_class_var = False # Reassigned #noqa: PIE794 # Class-level variable with type annotation typed_class_var: str = "typed value" def __init__(self): # Instance variables self.instance_var = "Initial instance value" self.reassignable_instance_var = 100 # Instance variable with type annotation self.typed_instance_var: list[str] = ["item1", "item2"] def modify_instance_var(self): # Reassign instance variable self.instance_var = "Modified instance value" self.reassignable_instance_var = 200 # Reassigned def use_module_var(self): # Use module-level variables result = module_var + " used in method" other_result = reassignable_module_var + 5 return result, other_result def use_class_var(self): # Use class-level variables result = VariableContainer.class_var + " used in method" other_result = VariableContainer.reassignable_class_var return result, other_result # Dataclass with variables @dataclass class VariableDataclass: """Dataclass that contains various fields.""" # Field variables with type annotations id: int name: str items: list[str] = field(default_factory=list) metadata: dict[str, str] = field(default_factory=dict) optional_value: float | None = None # This will be reassigned in various places status: str = "pending" # Function that uses the module variables def use_module_variables(): """Function that uses module-level variables.""" result = module_var + " used in function" other_result = reassignable_module_var * 2 return result, other_result # Create instances and use variables dataclass_instance = VariableDataclass(id=1, name="Test") dataclass_instance.status = "active" # Reassign dataclass field # Use variables at module level module_result = module_var + " used at module level" other_module_result = reassignable_module_var + 30 # Create a second dataclass instance with different status second_dataclass = VariableDataclass(id=2, name="Another Test") second_dataclass.status = "completed" # Another reassignment of status ''' # --- # name: test_replace_body[test_case0] ''' """ Test module for variable declarations and usage. This module tests various types of variable declarations and usages including: - Module-level variables - Class-level variables - Instance variables - Variable reassignments """ from dataclasses import dataclass, field # Module-level variables module_var = "Initial module value" reassignable_module_var = 10 reassignable_module_var = 20 # Reassigned # Module-level variable with type annotation typed_module_var: int = 42 # Regular class with class and instance variables class VariableContainer: """Class that contains various variables.""" # Class-level variables class_var = "Initial class value" reassignable_class_var = True reassignable_class_var = False # Reassigned #noqa: PIE794 # Class-level variable with type annotation typed_class_var: str = "typed value" def __init__(self): # Instance variables self.instance_var = "Initial instance value" self.reassignable_instance_var = 100 # Instance variable with type annotation self.typed_instance_var: list[str] = ["item1", "item2"] def modify_instance_var(self): # This body has been replaced self.instance_var = "Replaced!" self.reassignable_instance_var = 999 # Reassigned def use_module_var(self): # Use module-level variables result = module_var + " used in method" other_result = reassignable_module_var + 5 return result, other_result def use_class_var(self): # Use class-level variables result = VariableContainer.class_var + " used in method" other_result = VariableContainer.reassignable_class_var return result, other_result # Dataclass with variables @dataclass class VariableDataclass: """Dataclass that contains various fields.""" # Field variables with type annotations id: int name: str items: list[str] = field(default_factory=list) metadata: dict[str, str] = field(default_factory=dict) optional_value: float | None = None # This will be reassigned in various places status: str = "pending" # Function that uses the module variables def use_module_variables(): """Function that uses module-level variables.""" result = module_var + " used in function" other_result = reassignable_module_var * 2 return result, other_result # Create instances and use variables dataclass_instance = VariableDataclass(id=1, name="Test") dataclass_instance.status = "active" # Reassign dataclass field # Use variables at module level module_result = module_var + " used at module level" other_module_result = reassignable_module_var + 30 # Create a second dataclass instance with different status second_dataclass = VariableDataclass(id=2, name="Another Test") second_dataclass.status = "completed" # Another reassignment of status ''' # --- # name: test_replace_body[test_case1] ''' export class DemoClass { value: number; constructor(value: number) { this.value = value; } function printValue() { // This body has been replaced console.warn("New value: " + this.value); } } export function helperFunction() { const demo = new DemoClass(42); demo.printValue(); } helperFunction(); ''' # --- # name: test_replace_body_vue[test_case0] ''' ''' # --- # name: test_replace_body_vue_ts_file[test_case0] ''' import { defineStore } from 'pinia' import type { HistoryEntry, Operation, CalculatorState } from '@/types' export const useCalculatorStore = defineStore('calculator', { state: (): CalculatorState => ({ currentValue: 0, previousValue: null, operation: null, history: [], displayValue: '0' }), getters: { /** * Get the most recent history entries (last 10) */ recentHistory: (state): HistoryEntry[] => { return state.history.slice(-10).reverse() }, /** * Check if calculator has any history */ hasHistory: (state): boolean => { return state.history.length > 0 }, /** * Get the current display text */ display: (state): string => { return state.displayValue } }, actions: { /** * Set a number value */ setNumber(value: number) { this.currentValue = value this.displayValue = value.toString() }, /** * Append a digit to the current value */ appendDigit(digit: number) { if (this.displayValue === '0') { this.displayValue = digit.toString() } else { this.displayValue += digit.toString() } this.currentValue = parseFloat(this.displayValue) }, /** * Add two numbers */ add() { if (this.previousValue !== null && this.operation) { this.executeOperation() } this.previousValue = this.currentValue this.operation = 'add' this.displayValue = '0' }, /** * Subtract two numbers */ subtract() { if (this.previousValue !== null && this.operation) { this.executeOperation() } this.previousValue = this.currentValue this.operation = 'subtract' this.displayValue = '0' }, /** * Multiply two numbers */ multiply() { if (this.previousValue !== null && this.operation) { this.executeOperation() } this.previousValue = this.currentValue this.operation = 'multiply' this.displayValue = '0' }, /** * Divide two numbers */ divide() { if (this.previousValue !== null && this.operation) { this.executeOperation() } this.previousValue = this.currentValue this.operation = 'divide' this.displayValue = '0' }, /** * Execute the pending operation */ executeOperation() { if (this.previousValue === null || this.operation === null) { return } let result = 0 const prev = this.previousValue const current = this.currentValue let expression = '' switch (this.operation) { case 'add': result = prev + current expression = `${prev} + ${current}` break case 'subtract': result = prev - current expression = `${prev} - ${current}` break case 'multiply': result = prev * current expression = `${prev} × ${current}` break case 'divide': if (current === 0) { this.displayValue = 'Error' this.clear() return } result = prev / current expression = `${prev} ÷ ${current}` break } // Add to history this.history.push({ expression, result, timestamp: new Date() }) this.currentValue = result this.displayValue = result.toString() this.previousValue = null this.operation = null }, /** * Calculate the equals operation */ equals() { this.executeOperation() }, /** * Clear the calculator state */ function clear() { // Modified: Reset to initial state with a log console.log('Clearing calculator state'); displayValue.value = '0'; expression.value = ''; operationHistory.value = []; lastResult.value = undefined; }, /** * Clear all history */ clearHistory() { this.history = [] } } }) ''' # --- # name: test_replace_body_vue_with_disambiguation[test_case0] ''' ''' # --- ================================================ FILE: test/serena/config/__init__.py ================================================ # Empty init file for test package ================================================ FILE: test/serena/config/test_global_ignored_paths.py ================================================ import os import shutil import tempfile from pathlib import Path from serena.config.serena_config import ProjectConfig, RegisteredProject, SerenaConfig from serena.project import Project from solidlsp.ls_config import Language def _create_test_project( project_root: Path, project_ignored_paths: list[str] | None = None, global_ignored_paths: list[str] | None = None, ) -> Project: """Helper to create a Project with the given ignored paths configuration.""" config = ProjectConfig( project_name="test_project", languages=[Language.PYTHON], ignored_paths=project_ignored_paths or [], ignore_all_files_in_gitignore=False, ) serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=global_ignored_paths) return Project( project_root=str(project_root), project_config=config, serena_config=serena_config, ) class TestGlobalIgnoredPaths: """Tests for system-global ignored_paths feature.""" def setup_method(self) -> None: self.test_dir = tempfile.mkdtemp() self.project_path = Path(self.test_dir) # Create some test files and directories (self.project_path / "main.py").write_text("print('hello')") os.makedirs(self.project_path / "node_modules" / "pkg", exist_ok=True) (self.project_path / "node_modules" / "pkg" / "index.js").write_text("module.exports = {}") os.makedirs(self.project_path / "build", exist_ok=True) (self.project_path / "build" / "output.js").write_text("compiled") os.makedirs(self.project_path / "src", exist_ok=True) (self.project_path / "src" / "app.py").write_text("def app(): pass") (self.project_path / "debug.log").write_text("log data") def teardown_method(self) -> None: shutil.rmtree(self.test_dir) def test_global_ignored_paths_are_applied(self) -> None: """Global ignored_paths from SerenaConfig are respected by Project.is_ignored_path().""" project = _create_test_project( self.project_path, global_ignored_paths=["node_modules"], ) assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg" / "index.js")) assert not project.is_ignored_path(str(self.project_path / "src" / "app.py")) def test_additive_merge_of_global_and_project_patterns(self) -> None: """Global + project patterns are merged additively (both applied).""" project = _create_test_project( self.project_path, project_ignored_paths=["build"], global_ignored_paths=["node_modules"], ) # Global pattern should be applied assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg" / "index.js")) # Project pattern should also be applied assert project.is_ignored_path(str(self.project_path / "build" / "output.js")) # Non-ignored files should not be affected assert not project.is_ignored_path(str(self.project_path / "src" / "app.py")) def test_empty_global_ignored_paths_has_no_effect(self) -> None: """Empty global ignored_paths (default) has no effect on existing behavior.""" project = _create_test_project( self.project_path, project_ignored_paths=["build"], global_ignored_paths=[], ) # Project pattern still works assert project.is_ignored_path(str(self.project_path / "build" / "output.js")) # Non-ignored files still accessible assert not project.is_ignored_path(str(self.project_path / "node_modules" / "pkg" / "index.js")) def test_duplicate_patterns_across_global_and_project(self) -> None: """Duplicate patterns across global and project do not cause errors.""" project = _create_test_project( self.project_path, project_ignored_paths=["node_modules", "build"], global_ignored_paths=["node_modules", "build"], ) assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg" / "index.js")) assert project.is_ignored_path(str(self.project_path / "build" / "output.js")) assert not project.is_ignored_path(str(self.project_path / "src" / "app.py")) def test_glob_patterns_in_global_ignored_paths(self) -> None: """Global ignored_paths support gitignore-style glob patterns.""" project = _create_test_project( self.project_path, global_ignored_paths=["*.log"], ) assert project.is_ignored_path(str(self.project_path / "debug.log")) assert not project.is_ignored_path(str(self.project_path / "main.py")) class TestRegisteredProjectGlobalIgnoredPaths: """RegisteredProject.get_project_instance() correctly passes global patterns to Project.""" def setup_method(self) -> None: self.test_dir = tempfile.mkdtemp() self.project_path = Path(self.test_dir).resolve() (self.project_path / "main.py").write_text("print('hello')") os.makedirs(self.project_path / "node_modules", exist_ok=True) (self.project_path / "node_modules" / "pkg.js").write_text("module") def teardown_method(self) -> None: shutil.rmtree(self.test_dir) def test_get_project_instance_passes_global_ignored_paths(self) -> None: """RegisteredProject.get_project_instance() passes global_ignored_paths to Project.""" config = ProjectConfig( project_name="test_project", languages=[Language.PYTHON], ignored_paths=[], ignore_all_files_in_gitignore=False, ) serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=["node_modules"]) registered = RegisteredProject( project_root=str(self.project_path), project_config=config, ) project = registered.get_project_instance(serena_config=serena_config) assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) def test_get_project_instance_without_global_ignored_paths(self) -> None: """RegisteredProject without global_ignored_paths defaults to empty.""" config = ProjectConfig( project_name="test_project", languages=[Language.PYTHON], ignored_paths=[], ignore_all_files_in_gitignore=False, ) registered = RegisteredProject( project_root=str(self.project_path), project_config=config, ) serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=[]) project = registered.get_project_instance(serena_config=serena_config) assert not project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) def test_from_project_root_passes_global_ignored_paths(self) -> None: """RegisteredProject.from_project_root() threads global_ignored_paths to Project.""" # Create a minimal project.yml so from_project_root can load config serena_dir = self.project_path / ".serena" serena_dir.mkdir(exist_ok=True) (serena_dir / "project.yml").write_text( 'project_name: "test_project"\nlanguages: ["python"]\nignored_paths: []\nignore_all_files_in_gitignore: false\n' ) serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=["node_modules"]) registered = RegisteredProject.from_project_root( str(self.project_path), serena_config=serena_config, ) project = registered.get_project_instance(serena_config=serena_config) assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) def test_from_project_instance_passes_global_ignored_paths(self) -> None: """RegisteredProject.from_project_instance() threads global_ignored_paths to Project.""" config = ProjectConfig( project_name="test_project", languages=[Language.PYTHON], ignored_paths=[], ignore_all_files_in_gitignore=False, ) serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=["node_modules"]) project = Project( project_root=str(self.project_path), project_config=config, serena_config=serena_config, ) registered = RegisteredProject.from_project_instance(project) # The registered project already has a project_instance, so get_project_instance() returns it directly retrieved = registered.get_project_instance(serena_config=serena_config) assert retrieved.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) class TestGlobalIgnoredPathsWithGitignore: """Global ignored_paths combined with ignore_all_files_in_gitignore produces correct three-way merge.""" def setup_method(self) -> None: self.test_dir = tempfile.mkdtemp() self.project_path = Path(self.test_dir).resolve() # Create test files (self.project_path / "main.py").write_text("print('hello')") os.makedirs(self.project_path / "node_modules", exist_ok=True) (self.project_path / "node_modules" / "pkg.js").write_text("module") os.makedirs(self.project_path / "dist", exist_ok=True) (self.project_path / "dist" / "bundle.js").write_text("bundled") os.makedirs(self.project_path / "build", exist_ok=True) (self.project_path / "build" / "output.js").write_text("compiled") # Create .gitignore that ignores dist/ (self.project_path / ".gitignore").write_text("dist/\n") def teardown_method(self) -> None: shutil.rmtree(self.test_dir) def test_three_way_merge_global_project_and_gitignore(self) -> None: """Global patterns, project patterns, and .gitignore patterns are all applied together.""" config = ProjectConfig( project_name="test_project", languages=[Language.PYTHON], ignored_paths=["build"], ignore_all_files_in_gitignore=True, ) serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=["node_modules"]) project = Project( project_root=str(self.project_path), project_config=config, serena_config=serena_config, ) # Global pattern: node_modules assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) # Project pattern: build assert project.is_ignored_path(str(self.project_path / "build" / "output.js")) # Gitignore pattern: dist/ assert project.is_ignored_path(str(self.project_path / "dist" / "bundle.js")) # Non-ignored file assert not project.is_ignored_path(str(self.project_path / "main.py")) class TestSerenaConfigIgnoredPaths: """Config loading with ignored_paths in serena_config.yml works correctly.""" def test_serena_config_default_ignored_paths(self) -> None: """SerenaConfig defaults to empty ignored_paths.""" config = SerenaConfig(gui_log_window=False, web_dashboard=False) assert config.ignored_paths == [] def test_serena_config_with_ignored_paths(self) -> None: """SerenaConfig can be created with explicit ignored_paths.""" config = SerenaConfig( gui_log_window=False, web_dashboard=False, ignored_paths=["node_modules", "*.log", "build"], ) assert config.ignored_paths == ["node_modules", "*.log", "build"] ================================================ FILE: test/serena/config/test_serena_config.py ================================================ import logging import os import shutil import tempfile from pathlib import Path import pytest from serena.agent import SerenaAgent from serena.config.serena_config import ( DEFAULT_PROJECT_SERENA_FOLDER_LOCATION, LanguageBackend, ProjectConfig, RegisteredProject, SerenaConfig, SerenaConfigError, ) from serena.constants import PROJECT_TEMPLATE_FILE, SERENA_MANAGED_DIR_NAME from serena.project import MemoriesManager, Project from solidlsp.ls_config import Language from test.conftest import create_default_serena_config class TestProjectConfigAutogenerate: """Test class for ProjectConfig autogeneration functionality.""" def setup_method(self): """Set up test environment before each test method.""" # Create a temporary directory for testing self.test_dir = tempfile.mkdtemp() self.serena_config = create_default_serena_config() self.project_path = Path(self.test_dir) def teardown_method(self): """Clean up test environment after each test method.""" # Remove the temporary directory shutil.rmtree(self.test_dir) def test_autogenerate_empty_directory(self): """Test that autogenerate succeeds with empty languages list for an empty directory.""" config = ProjectConfig.autogenerate(self.project_path, self.serena_config, save_to_disk=False) assert config.project_name == self.project_path.name assert config.languages == [] def test_autogenerate_empty_directory_logs_warning(self, caplog): """Test that autogenerate logs a warning when no language files are found.""" with caplog.at_level(logging.WARNING): ProjectConfig.autogenerate(self.project_path, self.serena_config, save_to_disk=False) assert any("No source files for supported language servers were found" in msg for msg in caplog.messages) def test_autogenerate_with_python_files(self): """Test successful autogeneration with Python source files.""" # Create a Python file python_file = self.project_path / "main.py" python_file.write_text("def hello():\n print('Hello, world!')\n") # Run autogenerate config = ProjectConfig.autogenerate(self.project_path, self.serena_config, save_to_disk=False) # Verify the configuration assert config.project_name == self.project_path.name assert config.languages == [Language.PYTHON] def test_autogenerate_with_js_files(self): """Test successful autogeneration with JavaScript source files.""" # Create files for multiple languages (self.project_path / "small.js").write_text("console.log('JS');") # Run autogenerate - should pick Python as dominant config = ProjectConfig.autogenerate(self.project_path, self.serena_config, save_to_disk=False) assert config.languages == [Language.TYPESCRIPT] def test_autogenerate_with_multiple_languages(self): """Test autogeneration picks dominant language when multiple are present.""" # Create files for multiple languages (self.project_path / "main.py").write_text("print('Python')") (self.project_path / "util.py").write_text("def util(): pass") (self.project_path / "small.js").write_text("console.log('JS');") # Run autogenerate - should pick Python as dominant config = ProjectConfig.autogenerate(self.project_path, self.serena_config, save_to_disk=False) assert config.languages == [Language.PYTHON] def test_autogenerate_saves_to_disk(self): """Test that autogenerate can save the configuration to disk.""" # Create a Go file go_file = self.project_path / "main.go" go_file.write_text("package main\n\nfunc main() {}\n") # Run autogenerate with save_to_disk=True config = ProjectConfig.autogenerate(self.project_path, self.serena_config, save_to_disk=True) # Verify the configuration file was created config_path = self.project_path / ".serena" / "project.yml" assert config_path.exists() # Verify the content assert config.languages == [Language.GO] def test_autogenerate_nonexistent_path(self): """Test that autogenerate raises FileNotFoundError for non-existent path.""" non_existent = self.project_path / "does_not_exist" with pytest.raises(FileNotFoundError) as exc_info: ProjectConfig.autogenerate(non_existent, self.serena_config, save_to_disk=False) assert "Project root not found" in str(exc_info.value) def test_autogenerate_with_gitignored_files_only(self): """Test autogenerate creates a project with empty languages when only gitignored files exist.""" # Create a .gitignore that ignores all Python files gitignore = self.project_path / ".gitignore" gitignore.write_text("*.py\n") # Create Python files that will be ignored (self.project_path / "ignored.py").write_text("print('ignored')") # Should succeed with empty languages (gitignored files are not counted) config = ProjectConfig.autogenerate(self.project_path, self.serena_config, save_to_disk=False) assert config.project_name == self.project_path.name assert config.languages == [] def test_autogenerate_custom_project_name(self): """Test autogenerate with custom project name.""" # Create a TypeScript file ts_file = self.project_path / "index.ts" ts_file.write_text("const greeting: string = 'Hello';\n") # Run autogenerate with custom name custom_name = "my-custom-project" config = ProjectConfig.autogenerate(self.project_path, self.serena_config, project_name=custom_name, save_to_disk=False) assert config.project_name == custom_name assert config.languages == [Language.TYPESCRIPT] class TestProjectConfig: def test_template_is_complete(self): _, is_complete = ProjectConfig._load_yaml_dict(PROJECT_TEMPLATE_FILE) assert is_complete, "Project template YAML is incomplete; all fields must be present (with descriptions)." class TestProjectConfigLanguageBackend: """Tests for the per-project language_backend field.""" def test_language_backend_defaults_to_none(self): config = ProjectConfig( project_name="test", languages=[Language.PYTHON], ) assert config.language_backend is None def test_language_backend_can_be_set(self): config = ProjectConfig( project_name="test", languages=[Language.PYTHON], language_backend=LanguageBackend.JETBRAINS, ) assert config.language_backend == LanguageBackend.JETBRAINS def test_language_backend_roundtrips_through_yaml(self): config = ProjectConfig( project_name="test", languages=[Language.PYTHON], language_backend=LanguageBackend.JETBRAINS, ) d = config._to_yaml_dict() assert d["language_backend"] == "JetBrains" def test_language_backend_none_roundtrips_through_yaml(self): config = ProjectConfig( project_name="test", languages=[Language.PYTHON], ) d = config._to_yaml_dict() assert d["language_backend"] is None def test_language_backend_parsed_from_dict(self): """Test that _from_dict parses language_backend correctly.""" template_path = PROJECT_TEMPLATE_FILE data, _ = ProjectConfig._load_yaml_dict(template_path) data["project_name"] = "test" data["languages"] = ["python"] data["language_backend"] = "JetBrains" config = ProjectConfig._from_dict(data, local_override_keys=[]) assert config.language_backend == LanguageBackend.JETBRAINS def test_language_backend_none_when_missing_from_dict(self): """Test that _from_dict handles missing language_backend gracefully.""" template_path = PROJECT_TEMPLATE_FILE data, _ = ProjectConfig._load_yaml_dict(template_path) data["project_name"] = "test" data["languages"] = ["python"] data.pop("language_backend", None) config = ProjectConfig._from_dict(data, local_override_keys=[]) assert config.language_backend is None def _make_config_with_project( project_name: str, language_backend: LanguageBackend | None = None, global_backend: LanguageBackend = LanguageBackend.LSP, ) -> tuple[SerenaConfig, str]: """Create a SerenaConfig with a single registered project and return (config, project_name).""" config = SerenaConfig( gui_log_window=False, web_dashboard=False, log_level=logging.ERROR, language_backend=global_backend, ) project = Project( project_root=str(Path(__file__).parent.parent / "resources" / "repos" / "python" / "test_repo"), project_config=ProjectConfig( project_name=project_name, languages=[Language.PYTHON], language_backend=language_backend, ), serena_config=config, ) config.projects = [RegisteredProject.from_project_instance(project)] return config, project_name class TestEffectiveLanguageBackend: """Tests for per-project language_backend override logic in SerenaAgent.""" def test_default_backend_is_global(self): """When no project override, effective backend matches global config.""" config, name = _make_config_with_project("test_proj", language_backend=None, global_backend=LanguageBackend.LSP) agent = SerenaAgent(project=name, serena_config=config) try: assert agent.get_language_backend().is_lsp() finally: agent.shutdown(timeout=5) def test_project_overrides_global_backend(self): """When startup project has language_backend set, it overrides the global.""" config, name = _make_config_with_project( "test_jetbrains", language_backend=LanguageBackend.JETBRAINS, global_backend=LanguageBackend.LSP ) agent = SerenaAgent(project=name, serena_config=config) try: assert agent.get_language_backend().is_jetbrains() finally: agent.shutdown(timeout=5) def test_no_project_uses_global_backend(self): """When no startup project is provided, effective backend is the global one.""" config = SerenaConfig( gui_log_window=False, web_dashboard=False, log_level=logging.ERROR, language_backend=LanguageBackend.LSP, ) agent = SerenaAgent(project=None, serena_config=config) try: assert agent.get_language_backend() == LanguageBackend.LSP finally: agent.shutdown(timeout=5) def test_activate_project_rejects_backend_mismatch(self): """Post-init activation of a project with mismatched backend raises ValueError.""" # Start with LSP backend config, name = _make_config_with_project("lsp_proj", language_backend=None, global_backend=LanguageBackend.LSP) # Add a second project that requires JetBrains jb_project = Project( project_root=str(Path(__file__).parent.parent / "resources" / "repos" / "python" / "test_repo"), project_config=ProjectConfig( project_name="jb_proj", languages=[Language.PYTHON], language_backend=LanguageBackend.JETBRAINS, ), serena_config=config, ) config.projects.append(RegisteredProject.from_project_instance(jb_project)) agent = SerenaAgent(project=name, serena_config=config) try: with pytest.raises(ValueError, match="Cannot activate project"): agent.activate_project_from_path_or_name("jb_proj") finally: agent.shutdown(timeout=5) def test_activate_project_allows_matching_backend(self): """Post-init activation of a project with matching backend succeeds.""" config, name = _make_config_with_project("lsp_proj", language_backend=None, global_backend=LanguageBackend.LSP) # Add a second project that also uses LSP lsp_project2 = Project( project_root=str(Path(__file__).parent.parent / "resources" / "repos" / "python" / "test_repo"), project_config=ProjectConfig( project_name="lsp_proj2", languages=[Language.PYTHON], language_backend=LanguageBackend.LSP, ), serena_config=config, ) config.projects.append(RegisteredProject.from_project_instance(lsp_project2)) agent = SerenaAgent(project=name, serena_config=config) try: # Should not raise agent.activate_project_from_path_or_name("lsp_proj2") finally: agent.shutdown(timeout=5) def test_activate_project_allows_none_backend(self): """Post-init activation of a project with no backend override succeeds.""" config, name = _make_config_with_project("lsp_proj", language_backend=None, global_backend=LanguageBackend.LSP) # Add a second project with no backend override proj2 = Project( project_root=str(Path(__file__).parent.parent / "resources" / "repos" / "python" / "test_repo"), project_config=ProjectConfig( project_name="proj2", languages=[Language.PYTHON], language_backend=None, ), serena_config=config, ) config.projects.append(RegisteredProject.from_project_instance(proj2)) agent = SerenaAgent(project=name, serena_config=config) try: # Should not raise — None means "inherit session backend" agent.activate_project_from_path_or_name("proj2") finally: agent.shutdown(timeout=5) class TestGetConfiguredProjectSerenaFolder: """Tests for SerenaConfig.get_configured_project_serena_folder (pure template resolution).""" def test_default_location(self): config = SerenaConfig( gui_log_window=False, web_dashboard=False, ) result = config.get_configured_project_serena_folder("/home/user/myproject") assert result == os.path.abspath("/home/user/myproject/.serena") def test_custom_location_with_project_folder_name(self): config = SerenaConfig( gui_log_window=False, web_dashboard=False, project_serena_folder_location="/projects-metadata/$projectFolderName/.serena", ) result = config.get_configured_project_serena_folder("/home/user/myproject") assert result == os.path.abspath("/projects-metadata/myproject/.serena") def test_custom_location_with_project_dir(self): config = SerenaConfig( gui_log_window=False, web_dashboard=False, project_serena_folder_location="$projectDir/.custom-serena", ) result = config.get_configured_project_serena_folder("/home/user/myproject") assert result == os.path.abspath("/home/user/myproject/.custom-serena") def test_custom_location_with_both_placeholders(self): config = SerenaConfig( gui_log_window=False, web_dashboard=False, project_serena_folder_location="/data/$projectFolderName/$projectDir/.serena", ) result = config.get_configured_project_serena_folder("/home/user/proj") assert result == os.path.abspath("/data/proj/home/user/proj/.serena") def test_default_field_value(self): config = SerenaConfig( gui_log_window=False, web_dashboard=False, ) assert config.project_serena_folder_location == DEFAULT_PROJECT_SERENA_FOLDER_LOCATION def test_rejects_unknown_placeholder(self): config = SerenaConfig( gui_log_window=False, web_dashboard=False, project_serena_folder_location="$projectDir/$unknownVar/.serena", ) with pytest.raises(SerenaConfigError, match=r"Unknown placeholder '\$unknownVar'"): config.get_configured_project_serena_folder("/home/user/myproject") def test_rejects_typo_projectDirs(self): """$projectDirs should not be silently treated as $projectDir + 's'.""" config = SerenaConfig( gui_log_window=False, web_dashboard=False, project_serena_folder_location="$projectDirs/.serena", ) with pytest.raises(SerenaConfigError, match=r"Unknown placeholder '\$projectDirs'"): config.get_configured_project_serena_folder("/home/user/myproject") def test_rejects_typo_projectfoldername_lowercase(self): config = SerenaConfig( gui_log_window=False, web_dashboard=False, project_serena_folder_location="/data/$projectfoldername/.serena", ) with pytest.raises(SerenaConfigError, match=r"Unknown placeholder '\$projectfoldername'"): config.get_configured_project_serena_folder("/home/user/myproject") def test_no_placeholders_is_valid(self): config = SerenaConfig( gui_log_window=False, web_dashboard=False, project_serena_folder_location="/fixed/path/.serena", ) result = config.get_configured_project_serena_folder("/home/user/myproject") assert result == os.path.abspath("/fixed/path/.serena") def test_error_message_lists_supported_placeholders(self): config = SerenaConfig( gui_log_window=False, web_dashboard=False, project_serena_folder_location="$bogus/.serena", ) with pytest.raises(SerenaConfigError, match=r"\$projectDir.*\$projectFolderName|\$projectFolderName.*\$projectDir"): config.get_configured_project_serena_folder("/home/user/myproject") class TestProjectSerenaDataFolder: """Tests for SerenaConfig.get_project_serena_folder fallback logic (via Project).""" def setup_method(self): self.test_dir = tempfile.mkdtemp() self.project_path = Path(self.test_dir) / "myproject" self.project_path.mkdir() (self.project_path / "main.py").write_text("print('hello')\n") def teardown_method(self): shutil.rmtree(self.test_dir) def _make_project(self, serena_config: "SerenaConfig | None" = None) -> Project: project_config = ProjectConfig( project_name="myproject", languages=[Language.PYTHON], ) project = Project( project_root=str(self.project_path), project_config=project_config, serena_config=serena_config, ) project._ignore_spec_available.wait() return project def test_default_config_creates_in_project_dir(self): config = SerenaConfig(gui_log_window=False, web_dashboard=False) project = self._make_project(config) expected = os.path.abspath(str(self.project_path / SERENA_MANAGED_DIR_NAME)) assert project.path_to_serena_data_folder() == expected def test_custom_location_creates_outside_project(self): custom_base = Path(self.test_dir) / "metadata" custom_base.mkdir() config = SerenaConfig( gui_log_window=False, web_dashboard=False, project_serena_folder_location=str(custom_base) + "/$projectFolderName/.serena", ) project = self._make_project(config) expected = os.path.abspath(str(custom_base / "myproject" / ".serena")) assert project.path_to_serena_data_folder() == expected def test_fallback_to_existing_project_dir(self): """If config points to a non-existent path but .serena exists in the project root, use the existing one.""" existing_serena = self.project_path / SERENA_MANAGED_DIR_NAME existing_serena.mkdir() config = SerenaConfig( gui_log_window=False, web_dashboard=False, project_serena_folder_location="/nonexistent/path/$projectFolderName/.serena", ) project = self._make_project(config) assert project.path_to_serena_data_folder() == str(existing_serena) def test_configured_path_takes_precedence_when_exists(self): """If both config path and project root path exist, use the config path.""" existing_serena = self.project_path / SERENA_MANAGED_DIR_NAME existing_serena.mkdir() custom_base = Path(self.test_dir) / "metadata" custom_serena = custom_base / "myproject" / ".serena" custom_serena.mkdir(parents=True) config = SerenaConfig( gui_log_window=False, web_dashboard=False, project_serena_folder_location=str(custom_base) + "/$projectFolderName/.serena", ) project = self._make_project(config) assert project.path_to_serena_data_folder() == str(custom_serena) class TestMemoriesManagerCustomPath: """Tests for MemoriesManager with a custom serena data folder.""" def setup_method(self): self.test_dir = tempfile.mkdtemp() self.data_folder = Path(self.test_dir) / "custom_serena" def teardown_method(self): shutil.rmtree(self.test_dir) def test_memories_subdir_is_created(self): assert not self.data_folder.exists() MemoriesManager(str(self.data_folder)) assert (self.data_folder / "memories").exists() def test_save_and_load_memory(self): manager = MemoriesManager(str(self.data_folder)) manager.save_memory("test_topic", "test content", is_tool_context=False) content = manager.load_memory("test_topic") assert content == "test content" def test_list_memories(self): manager = MemoriesManager(str(self.data_folder)) manager.save_memory("topic_a", "content a", is_tool_context=False) manager.save_memory("topic_b", "content b", is_tool_context=False) memories = manager.list_project_memories() assert sorted(memories.get_full_list()) == ["topic_a", "topic_b"] ================================================ FILE: test/serena/test_cli_project_commands.py ================================================ """Tests for CLI project commands (create, index).""" import os import shutil import tempfile import time from pathlib import Path import pytest from click.testing import CliRunner from serena.cli import ProjectCommands, TopLevelCommands, find_project_root from serena.config.serena_config import ProjectConfig pytestmark = pytest.mark.filterwarnings("ignore::UserWarning") @pytest.fixture def temp_project_dir(): """Create a temporary directory for testing.""" tmpdir = tempfile.mkdtemp() try: yield tmpdir finally: # if windows, wait a bit to avoid PermissionError on cleanup if os.name == "nt": time.sleep(0.2) shutil.rmtree(tmpdir, ignore_errors=True) @pytest.fixture def temp_project_dir_with_python_file(): """Create a temporary directory with a Python file for testing.""" tmpdir = tempfile.mkdtemp() try: # Create a simple Python file so language detection works py_file = os.path.join(tmpdir, "test.py") with open(py_file, "w") as f: f.write("def hello():\n pass\n") yield tmpdir finally: # if windows, wait a bit to avoid PermissionError on cleanup if os.name == "nt": time.sleep(0.2) shutil.rmtree(tmpdir, ignore_errors=True) @pytest.fixture def cli_runner(): """Create a CliRunner for testing Click commands.""" return CliRunner() class TestProjectCreate: """Tests for 'project create' command.""" def test_create_basic_with_language(self, cli_runner, temp_project_dir): """Test basic project creation with explicit language.""" result = cli_runner.invoke(ProjectCommands.create, [temp_project_dir, "--language", "python"]) assert result.exit_code == 0, f"Command failed: {result.output}" assert "Generated project" in result.output assert "python" in result.output.lower() # Verify project.yml was created yml_path = os.path.join(temp_project_dir, ".serena", "project.yml") assert os.path.exists(yml_path), f"project.yml not found at {yml_path}" def test_create_auto_detect_language(self, cli_runner, temp_project_dir_with_python_file): """Test project creation with auto-detected language.""" result = cli_runner.invoke(ProjectCommands.create, [temp_project_dir_with_python_file]) assert result.exit_code == 0, f"Command failed: {result.output}" assert "Generated project" in result.output assert "python" in result.output.lower() # Verify project.yml was created yml_path = os.path.join(temp_project_dir_with_python_file, ".serena", "project.yml") assert os.path.exists(yml_path) def test_create_with_name(self, cli_runner, temp_project_dir): """Test project creation with custom name and explicit language.""" result = cli_runner.invoke(ProjectCommands.create, [temp_project_dir, "--name", "my-custom-project", "--language", "python"]) assert result.exit_code == 0, f"Command failed: {result.output}" assert "Generated project" in result.output # Verify project.yml was created yml_path = os.path.join(temp_project_dir, ".serena", "project.yml") assert os.path.exists(yml_path) def test_create_with_language(self, cli_runner, temp_project_dir): """Test project creation with specified language.""" result = cli_runner.invoke(ProjectCommands.create, [temp_project_dir, "--language", "python"]) assert result.exit_code == 0, f"Command failed: {result.output}" assert "Generated project" in result.output assert "python" in result.output.lower() def test_create_with_multiple_languages(self, cli_runner, temp_project_dir): """Test project creation with multiple languages.""" result = cli_runner.invoke( ProjectCommands.create, [temp_project_dir, "--language", "python", "--language", "typescript"], ) assert result.exit_code == 0, f"Command failed: {result.output}" assert "Generated project" in result.output def test_create_with_invalid_language(self, cli_runner, temp_project_dir): """Test project creation with invalid language raises error.""" result = cli_runner.invoke( ProjectCommands.create, [temp_project_dir, "--language", "invalid-lang"], ) assert result.exit_code != 0, "Should fail with invalid language" assert "Unknown language" in result.output or "invalid-lang" in result.output def test_create_already_exists(self, cli_runner, temp_project_dir): """Test that creating a project twice fails gracefully.""" # Create once with explicit language result1 = cli_runner.invoke(ProjectCommands.create, [temp_project_dir, "--language", "python"]) assert result1.exit_code == 0 # Try to create again - should fail gracefully result2 = cli_runner.invoke(ProjectCommands.create, [temp_project_dir, "--language", "python"]) assert result2.exit_code != 0, "Should fail when project.yml already exists" assert "already exists" in result2.output.lower() assert "Error:" in result2.output # Should be user-friendly error def test_create_with_index_flag(self, cli_runner, temp_project_dir_with_python_file): """Test project creation with --index flag performs indexing.""" result = cli_runner.invoke( ProjectCommands.create, [temp_project_dir_with_python_file, "--language", "python", "--index", "--log-level", "ERROR", "--timeout", "5"], ) assert result.exit_code == 0, f"Command failed: {result.output}" assert "Generated project" in result.output assert "Indexing project" in result.output # Verify project.yml was created yml_path = os.path.join(temp_project_dir_with_python_file, ".serena", "project.yml") assert os.path.exists(yml_path) # Verify cache directory was created (proof of indexing) cache_dir = os.path.join(temp_project_dir_with_python_file, ".serena", "cache") assert os.path.exists(cache_dir), "Cache directory should exist after indexing" def test_create_without_index_flag(self, cli_runner, temp_project_dir): """Test that project creation without --index does NOT perform indexing.""" result = cli_runner.invoke(ProjectCommands.create, [temp_project_dir, "--language", "python"]) assert result.exit_code == 0 assert "Generated project" in result.output assert "Indexing" not in result.output # Verify cache directory was NOT created cache_dir = os.path.join(temp_project_dir, ".serena", "cache") assert not os.path.exists(cache_dir), "Cache directory should not exist without --index" class TestProjectIndex: """Tests for 'project index' command.""" def test_index_auto_creates_project_with_files(self, cli_runner, temp_project_dir_with_python_file): """Test that index command auto-creates project.yml if it doesn't exist (with source files).""" result = cli_runner.invoke(ProjectCommands.index, [temp_project_dir_with_python_file, "--log-level", "ERROR", "--timeout", "5"]) # Should succeed and perform indexing assert result.exit_code == 0, f"Command failed: {result.output}" assert "Auto-creating" in result.output or "Indexing" in result.output # Verify project.yml was auto-created yml_path = os.path.join(temp_project_dir_with_python_file, ".serena", "project.yml") assert os.path.exists(yml_path), "project.yml should be auto-created" def test_index_with_explicit_language(self, cli_runner, temp_project_dir): """Test index with explicit --language for empty directory.""" result = cli_runner.invoke( ProjectCommands.index, [temp_project_dir, "--language", "python", "--log-level", "ERROR", "--timeout", "5"], ) # Should succeed even without source files if language is explicit assert result.exit_code == 0, f"Command failed: {result.output}" yml_path = os.path.join(temp_project_dir, ".serena", "project.yml") assert os.path.exists(yml_path) def test_index_with_language_auto_creates(self, cli_runner, temp_project_dir): """Test index with --language option for auto-creation.""" result = cli_runner.invoke( ProjectCommands.index, [temp_project_dir, "--language", "python", "--log-level", "ERROR"], ) assert result.exit_code == 0 or "Indexing" in result.output yml_path = os.path.join(temp_project_dir, ".serena", "project.yml") assert os.path.exists(yml_path) def test_index_is_equivalent_to_create_with_index(self, cli_runner, temp_project_dir_with_python_file): """Test that 'index' behaves like 'create --index' for new projects.""" # Use manual temp directory creation with Windows-safe cleanup # to avoid PermissionError on Windows CI when language servers hold file locks dir1 = tempfile.mkdtemp() dir2 = tempfile.mkdtemp() try: # Setup both directories with same file for d in [dir1, dir2]: with open(os.path.join(d, "test.py"), "w") as f: f.write("def hello():\n pass\n") # Run 'create --index' on dir1 result1 = cli_runner.invoke( ProjectCommands.create, [dir1, "--language", "python", "--index", "--log-level", "ERROR", "--timeout", "5"] ) # Run 'index' on dir2 result2 = cli_runner.invoke(ProjectCommands.index, [dir2, "--language", "python", "--log-level", "ERROR", "--timeout", "5"]) # Both should succeed assert result1.exit_code == 0, f"create --index failed: {result1.output}" assert result2.exit_code == 0, f"index failed: {result2.output}" # Both should create project.yml assert os.path.exists(os.path.join(dir1, ".serena", "project.yml")) assert os.path.exists(os.path.join(dir2, ".serena", "project.yml")) # Both should create cache (proof of indexing) assert os.path.exists(os.path.join(dir1, ".serena", "cache")) assert os.path.exists(os.path.join(dir2, ".serena", "cache")) finally: # Windows-safe cleanup: wait for file handles to be released if os.name == "nt": time.sleep(0.2) # Use ignore_errors to handle lingering file locks on Windows shutil.rmtree(dir1, ignore_errors=True) shutil.rmtree(dir2, ignore_errors=True) class TestProjectCreateHelper: """Tests for _create_project helper method.""" def test_create_project_helper_returns_config(self, temp_project_dir): """Test that _create_project returns a ProjectConfig with explicit language.""" config = ProjectCommands._create_project(temp_project_dir, "test-project", ("python",)).project_config assert isinstance(config, ProjectConfig) assert config.project_name == "test-project" def test_create_project_helper_with_auto_detect(self, temp_project_dir_with_python_file): """Test _create_project with auto-detected language.""" config = ProjectCommands._create_project(temp_project_dir_with_python_file, "my-project", ()).project_config assert isinstance(config, ProjectConfig) assert config.project_name == "my-project" assert len(config.languages) >= 1 def test_create_project_helper_with_languages(self, temp_project_dir): """Test _create_project with language specification.""" config = ProjectCommands._create_project(temp_project_dir, None, ("python", "typescript")).project_config assert isinstance(config, ProjectConfig) assert len(config.languages) >= 1 def test_create_project_helper_file_exists_error(self, temp_project_dir): """Test _create_project raises error if project.yml exists.""" # Create project first with explicit language ProjectCommands._create_project(temp_project_dir, None, ("python",)) # Try to create again - should raise FileExistsError with pytest.raises(FileExistsError): ProjectCommands._create_project(temp_project_dir, None, ("python",)) class TestFindProjectRoot: """Tests for find_project_root helper with virtual chroot boundary.""" def test_finds_serena_from_subdirectory(self, temp_project_dir): """Test that .serena/project.yml is found when searching from a subdirectory.""" serena_dir = os.path.join(temp_project_dir, ".serena") os.makedirs(serena_dir) Path(os.path.join(serena_dir, "project.yml")).touch() subdir = os.path.join(temp_project_dir, "src", "nested") os.makedirs(subdir) original_cwd = os.getcwd() try: os.chdir(subdir) result = find_project_root(root=temp_project_dir) assert result is not None assert os.path.samefile(result, temp_project_dir) finally: os.chdir(original_cwd) def test_serena_preferred_over_git(self, temp_project_dir): """Test that .serena/project.yml takes priority over .git at the same level.""" serena_dir = os.path.join(temp_project_dir, ".serena") os.makedirs(serena_dir) Path(os.path.join(serena_dir, "project.yml")).touch() os.makedirs(os.path.join(temp_project_dir, ".git")) original_cwd = os.getcwd() try: os.chdir(temp_project_dir) result = find_project_root(root=temp_project_dir) assert result is not None assert os.path.isdir(os.path.join(result, ".serena")) assert os.path.samefile(result, temp_project_dir) finally: os.chdir(original_cwd) def test_git_used_as_fallback(self, temp_project_dir): """Test that .git is found when no .serena exists.""" os.makedirs(os.path.join(temp_project_dir, ".git")) subdir = os.path.join(temp_project_dir, "src") os.makedirs(subdir) original_cwd = os.getcwd() try: os.chdir(subdir) result = find_project_root(root=temp_project_dir) assert result is not None assert os.path.samefile(result, temp_project_dir) finally: os.chdir(original_cwd) def test_falls_back_to_none_when_no_markers(self, temp_project_dir): """Test falls back to None when no markers exist within boundary.""" subdir = os.path.join(temp_project_dir, "src") os.makedirs(subdir) original_cwd = os.getcwd() try: os.chdir(subdir) result = find_project_root(root=temp_project_dir) assert result is None finally: os.chdir(original_cwd) class TestProjectFromCwdMutualExclusivity: """Tests for --project-from-cwd mutual exclusivity.""" def test_project_from_cwd_with_project_flag_fails(self, cli_runner): """Test that --project-from-cwd with --project raises error.""" result = cli_runner.invoke( TopLevelCommands.start_mcp_server, ["--project-from-cwd", "--project", "/some/path"], ) assert result.exit_code != 0 assert "cannot be used with" in result.output if __name__ == "__main__": # For manual testing, you can run this file directly: # uv run pytest test/serena/test_cli_project_commands.py -v pytest.main([__file__, "-v"]) ================================================ FILE: test/serena/test_edit_marker.py ================================================ from serena.tools import CreateTextFileTool, ReadFileTool, Tool class TestEditMarker: def test_tool_can_edit_method(self): """Test that Tool.can_edit() method works correctly""" # Non-editing tool should return False assert issubclass(ReadFileTool, Tool) assert not ReadFileTool.can_edit() # Editing tool should return True assert issubclass(CreateTextFileTool, Tool) assert CreateTextFileTool.can_edit() ================================================ FILE: test/serena/test_jetbrains_plugin_client.py ================================================ import pytest from serena.constants import REPO_ROOT from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient class TestSerenaJetBrainsPluginClient: @pytest.mark.parametrize( "serena_path, plugin_path", [ (REPO_ROOT, REPO_ROOT), ("/home/user/project", "/home/user/project"), ("/home/user/project", "//wsl.localhost/Ubuntu-24.04/home/user/project"), ("/home/user/project", "//wsl$/Ubuntu/home/user/project"), ("/home/user/project", "//wsl$/Ubuntu/home/user/project"), ("/mnt/c/Users/user/projects/my-app", "/workspaces/serena/C:/Users/user/projects/my-app"), ], ) def test_path_matching(self, serena_path, plugin_path) -> None: assert JetBrainsPluginClient._paths_match(serena_path, plugin_path) ================================================ FILE: test/serena/test_mcp.py ================================================ """Tests for the mcp.py module in serena.""" import pytest from mcp.server.fastmcp.tools.base import Tool as MCPTool from serena.agent import Tool, ToolRegistry from serena.config.context_mode import SerenaAgentContext from serena.mcp import SerenaMCPFactory make_tool = SerenaMCPFactory.make_mcp_tool # Create a mock agent for tool initialization class MockAgent: def __init__(self): self.project_config = None self.serena_config = None @staticmethod def get_context() -> SerenaAgentContext: return SerenaAgentContext.load_default() class BaseMockTool(Tool): """A mock Tool class for testing.""" def __init__(self): super().__init__(MockAgent()) # type: ignore class BasicTool(BaseMockTool): """A mock Tool class for testing.""" def apply(self, name: str, age: int = 0) -> str: """This is a test function. :param name: The person's name :param age: The person's age :return: A greeting message """ return f"Hello {name}, you are {age} years old!" def apply_ex( self, log_call: bool = True, catch_exceptions: bool = True, **kwargs, ) -> str: """Mock implementation of apply_ex.""" return self.apply(**kwargs) def test_make_tool_basic() -> None: """Test that make_tool correctly creates an MCP tool from a Tool object.""" mock_tool = BasicTool() mcp_tool = make_tool(mock_tool) # Test that the MCP tool has the correct properties assert isinstance(mcp_tool, MCPTool) assert mcp_tool.name == "basic" assert "This is a test function. Returns A greeting message." in mcp_tool.description # Test that the parameters were correctly processed parameters = mcp_tool.parameters assert "properties" in parameters assert "name" in parameters["properties"] assert "age" in parameters["properties"] assert parameters["properties"]["name"]["description"] == "The person's name." assert parameters["properties"]["age"]["description"] == "The person's age." def test_make_tool_execution() -> None: """Test that the execution function created by make_tool works correctly.""" mock_tool = BasicTool() mcp_tool = make_tool(mock_tool) # Execute the MCP tool function result = mcp_tool.fn(name="Alice", age=30) assert result == "Hello Alice, you are 30 years old!" def test_make_tool_no_params() -> None: """Test make_tool with a function that has no parameters.""" class NoParamsTool(BaseMockTool): def apply(self) -> str: """This is a test function with no parameters. :return: A simple result """ return "Simple result" def apply_ex(self, *args, **kwargs) -> str: return self.apply() tool = NoParamsTool() mcp_tool = make_tool(tool) assert mcp_tool.name == "no_params" assert "This is a test function with no parameters. Returns A simple result." in mcp_tool.description assert mcp_tool.parameters["properties"] == {} def test_make_tool_no_return_description() -> None: """Test make_tool with a function that has no return description.""" class NoReturnTool(BaseMockTool): def apply(self, param: str) -> str: """This is a test function. :param param: The parameter """ return f"Processed: {param}" def apply_ex(self, *args, **kwargs) -> str: return self.apply(**kwargs) tool = NoReturnTool() mcp_tool = make_tool(tool) assert mcp_tool.name == "no_return" assert mcp_tool.description == "This is a test function." assert mcp_tool.parameters["properties"]["param"]["description"] == "The parameter." def test_make_tool_parameter_not_in_docstring() -> None: """Test make_tool when a parameter in properties is not in the docstring.""" class MissingParamTool(BaseMockTool): def apply(self, name: str, missing_param: str = "") -> str: """This is a test function. :param name: The person's name """ return f"Hello {name}! Missing param: {missing_param}" def apply_ex(self, *args, **kwargs) -> str: return self.apply(**kwargs) tool = MissingParamTool() mcp_tool = make_tool(tool) assert "name" in mcp_tool.parameters["properties"] assert "missing_param" in mcp_tool.parameters["properties"] assert mcp_tool.parameters["properties"]["name"]["description"] == "The person's name." assert "description" not in mcp_tool.parameters["properties"]["missing_param"] def test_make_tool_multiline_docstring() -> None: """Test make_tool with a complex multi-line docstring.""" class ComplexDocTool(BaseMockTool): def apply(self, project_file_path: str, host: str, port: int) -> str: """Create an MCP server. This function creates and configures a Model Context Protocol server with the specified settings. :param project_file_path: The path to the project file, or None :param host: The host to bind to :param port: The port to bind to :return: A configured FastMCP server instance """ return f"Server config: {project_file_path}, {host}:{port}" def apply_ex(self, *args, **kwargs) -> str: return self.apply(**kwargs) tool = ComplexDocTool() mcp_tool = make_tool(tool) assert "Create an MCP server" in mcp_tool.description assert "Returns A configured FastMCP server instance" in mcp_tool.description assert mcp_tool.parameters["properties"]["project_file_path"]["description"] == "The path to the project file, or None." assert mcp_tool.parameters["properties"]["host"]["description"] == "The host to bind to." assert mcp_tool.parameters["properties"]["port"]["description"] == "The port to bind to." def test_make_tool_capitalization_and_periods() -> None: """Test that make_tool properly handles capitalization and periods in descriptions.""" class FormatTool(BaseMockTool): def apply(self, param1: str, param2: str, param3: str) -> str: """Test function. :param param1: lowercase description :param param2: description with period. :param param3: description with Capitalized word. """ return f"Formatted: {param1}, {param2}, {param3}" def apply_ex(self, *args, **kwargs) -> str: return self.apply(**kwargs) tool = FormatTool() mcp_tool = make_tool(tool) assert mcp_tool.parameters["properties"]["param1"]["description"] == "Lowercase description." assert mcp_tool.parameters["properties"]["param2"]["description"] == "Description with period." assert mcp_tool.parameters["properties"]["param3"]["description"] == "Description with Capitalized word." def test_make_tool_missing_apply() -> None: """Test make_tool with a tool that doesn't have an apply method.""" class BadTool(BaseMockTool): pass tool = BadTool() with pytest.raises(AttributeError): make_tool(tool) @pytest.mark.parametrize( "docstring, expected_description", [ ( """This is a test function. :param param: The parameter :return: A result """, "This is a test function. Returns A result.", ), ( """ :param param: The parameter :return: A result """, "Returns A result.", ), ( """ :param param: The parameter """, "", ), ("Description without params.", "Description without params."), ], ) def test_make_tool_descriptions(docstring, expected_description) -> None: """Test make_tool with various docstring formats.""" class TestTool(BaseMockTool): def apply(self, param: str) -> str: return f"Result: {param}" def apply_ex(self, *args, **kwargs) -> str: return self.apply(**kwargs) # Dynamically set the docstring TestTool.apply.__doc__ = docstring tool = TestTool() mcp_tool = make_tool(tool) assert mcp_tool.name == "test" assert mcp_tool.description == expected_description def is_test_mock_class(tool_class: type) -> bool: """Check if a class is a test mock class.""" # Check if the class is defined in a test module module_name = tool_class.__module__ return ( module_name.startswith(("test.", "tests.")) or "test_" in module_name or tool_class.__name__ in [ "BaseMockTool", "BasicTool", "BadTool", "NoParamsTool", "NoReturnTool", "MissingParamTool", "ComplexDocTool", "FormatTool", "NoDescriptionTool", ] ) @pytest.mark.parametrize("tool_class", ToolRegistry().get_all_tool_classes()) def test_make_tool_all_tools(tool_class) -> None: """Test that make_tool works for all tools in the codebase.""" # Create an instance of the tool tool_instance = tool_class(MockAgent()) # Try to create an MCP tool from it mcp_tool = make_tool(tool_instance) # Basic validation assert isinstance(mcp_tool, MCPTool) assert mcp_tool.name == tool_class.get_name_from_cls() # The description should be a string (either from docstring or default) assert isinstance(mcp_tool.description, str) ================================================ FILE: test/serena/test_serena_agent.py ================================================ import json import logging import os import re import time from collections.abc import Iterator from contextlib import contextmanager from typing import Literal import pytest from serena.agent import SerenaAgent from serena.config.serena_config import ProjectConfig, RegisteredProject, SerenaConfig from serena.project import Project from serena.tools import SUCCESS_RESULT, FindReferencingSymbolsTool, FindSymbolTool, ReplaceContentTool, ReplaceSymbolBodyTool from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind from test.conftest import get_repo_path, is_ci, language_tests_enabled from test.solidlsp import clojure as clj @pytest.fixture def serena_config(): config = SerenaConfig(gui_log_window=False, web_dashboard=False, log_level=logging.ERROR) # Create test projects for all supported languages test_projects = [] for language in [ Language.PYTHON, Language.GO, Language.JAVA, Language.KOTLIN, Language.RUST, Language.TYPESCRIPT, Language.PHP, Language.CSHARP, Language.CLOJURE, Language.FSHARP, Language.POWERSHELL, Language.CPP_CCLS, Language.LEAN4, ]: repo_path = get_repo_path(language) if repo_path.exists(): project_name = f"test_repo_{language}" project = Project( project_root=str(repo_path), project_config=ProjectConfig( project_name=project_name, languages=[language], ignored_paths=[], excluded_tools=[], read_only=False, ignore_all_files_in_gitignore=True, initial_prompt="", encoding="utf-8", ), serena_config=config, ) test_projects.append(RegisteredProject.from_project_instance(project)) config.projects = test_projects return config def read_project_file(project: Project, relative_path: str) -> str: """Utility function to read a file from the project.""" file_path = os.path.join(project.project_root, relative_path) with open(file_path, encoding=project.project_config.encoding) as f: return f.read() @contextmanager def project_file_modification_context(serena_agent: SerenaAgent, relative_path: str) -> Iterator[None]: """Context manager to modify a project file and revert the changes after use.""" project = serena_agent.get_active_project() file_path = os.path.join(project.project_root, relative_path) # Read the original content original_content = read_project_file(project, relative_path) try: yield finally: # Revert to the original content with open(file_path, "w", encoding=project.project_config.encoding) as f: f.write(original_content) @pytest.fixture def serena_agent(request: pytest.FixtureRequest, serena_config) -> Iterator[SerenaAgent]: language = Language(request.param) if not language_tests_enabled(language): pytest.skip(f"Tests for language {language} are not enabled.") project_name = f"test_repo_{language}" agent = SerenaAgent(project=project_name, serena_config=serena_config) # wait for agent to be ready agent.execute_task(lambda: None) yield agent # explicitly shut down to free resources agent.shutdown(timeout=5) class TestSerenaAgent: @pytest.mark.parametrize("project", [None, str(get_repo_path(Language.PYTHON)), "non_existent_path"]) def test_agent_instantiation(self, project: str | None): """ Tests agent instantiation for cases where * no project is specified at startup * a valid project path is specified at startup * an invalid project path is specified at startup All cases must not raise an exception. """ serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False) SerenaAgent(project=project, serena_config=serena_config) def _assert_find_symbol(self, serena_agent: SerenaAgent, symbol_name: str, expected_kind: str, expected_file: str) -> None: agent = serena_agent find_symbol_tool = agent.get_tool(FindSymbolTool) result = find_symbol_tool.apply(name_path_pattern=symbol_name, include_info=True) symbols = json.loads(result) assert any( symbol_name in s["name_path"] and expected_kind.lower() in s["kind"].lower() and expected_file in s["relative_path"] for s in symbols ), f"Expected to find {symbol_name} ({expected_kind}) in {expected_file}" # testing retrieval of symbol info if serena_agent.get_active_lsp_languages() == [Language.KOTLIN]: # kotlin LS doesn't seem to provide hover info right now, at least for the struct we test this on return for s in symbols: if s["kind"] in (SymbolKind.File.name, SymbolKind.Module.name): # we ignore file and module symbols for the info test continue symbol_info = s.get("info") assert symbol_info, f"Expected symbol info to be present for symbol: {s}" assert ( symbol_name in s["info"] ), f"[{serena_agent.get_active_lsp_languages()[0]}] Expected symbol info to contain symbol name {symbol_name}. Info: {s['info']}" # special additional test for Java, since Eclipse returns hover in a complex format and we want to make sure to get it right if s["kind"] == SymbolKind.Class.name and serena_agent.get_active_lsp_languages() == [Language.JAVA]: assert "A simple model class" in symbol_info, f"Java class docstring not found in symbol info: {s}" @pytest.mark.php @pytest.mark.parametrize("serena_agent", [Language.PHP], indirect=True) def test_find_symbol_within_php_file(self, serena_agent: SerenaAgent) -> None: """Verify find_symbol with a PHP file path routes to the PHP language server. This validates the fix in symbol.py (LanguageServerSymbolRetriever.find_symbols): when within_relative_path points to a PHP file, the retriever must use get_language_server() rather than iterating all language servers. Without this fix, non-PHP servers reject the PHP file and no symbols are returned. """ find_symbol_tool = serena_agent.get_tool(FindSymbolTool) sample_php = "sample.php" result = find_symbol_tool.apply(name_path_pattern="Dog/greet", relative_path=sample_php) symbols = json.loads(result) assert len(symbols) > 0, ( f"Expected to find Dog/greet in {sample_php} but got empty result. " "This may indicate that find_symbol is not routing to the PHP language server for PHP files." ) assert any( "greet" in s["name_path"] and sample_php in s["relative_path"] for s in symbols ), f"Dog/greet not found in {sample_php}. Symbols: {symbols}" @pytest.mark.parametrize( "serena_agent,symbol_name,expected_kind,expected_file", [ pytest.param(Language.PYTHON, "User", "Class", "models.py", marks=pytest.mark.python), pytest.param(Language.GO, "Helper", "Function", "main.go", marks=pytest.mark.go), pytest.param(Language.JAVA, "Model", "Class", "Model.java", marks=pytest.mark.java), pytest.param( Language.KOTLIN, "Model", "Struct", "Model.kt", marks=[pytest.mark.kotlin] + ([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []), ), pytest.param(Language.TYPESCRIPT, "DemoClass", "Class", "index.ts", marks=pytest.mark.typescript), pytest.param(Language.PHP, "helperFunction", "Function", "helper.php", marks=pytest.mark.php), pytest.param(Language.CLOJURE, "greet", "Function", clj.CORE_PATH, marks=pytest.mark.clojure), pytest.param(Language.CSHARP, "Calculator", "Class", "Program.cs", marks=pytest.mark.csharp), pytest.param(Language.POWERSHELL, "function Greet-User ()", "Function", "main.ps1", marks=pytest.mark.powershell), pytest.param(Language.CPP_CCLS, "add", "Function", "b.cpp", marks=pytest.mark.cpp), pytest.param(Language.LEAN4, "add", "Method", "Helper.lean", marks=pytest.mark.lean4), ], indirect=["serena_agent"], ) def test_find_symbol_stable(self, serena_agent: SerenaAgent, symbol_name: str, expected_kind: str, expected_file: str) -> None: self._assert_find_symbol(serena_agent, symbol_name, expected_kind, expected_file) @pytest.mark.parametrize( "serena_agent,symbol_name,expected_kind,expected_file", [ pytest.param(Language.FSHARP, "Calculator", "Module", "Calculator.fs", marks=pytest.mark.fsharp), ], indirect=["serena_agent"], ) @pytest.mark.xfail(reason="F# language server is unreliable") # See issue #1040 def test_find_symbol_fsharp(self, serena_agent: SerenaAgent, symbol_name: str, expected_kind: str, expected_file: str) -> None: self._assert_find_symbol(serena_agent, symbol_name, expected_kind, expected_file) @pytest.mark.parametrize( "serena_agent,symbol_name,expected_kind,expected_file", [ pytest.param(Language.RUST, "add", "Function", "lib.rs", marks=pytest.mark.rust), ], indirect=["serena_agent"], ) @pytest.mark.xfail(reason="Rust language server is unreliable") # See issue #1040 def test_find_symbol_rust(self, serena_agent: SerenaAgent, symbol_name: str, expected_kind: str, expected_file: str) -> None: self._assert_find_symbol(serena_agent, symbol_name, expected_kind, expected_file) def _assert_find_symbol_references(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None: agent = serena_agent # Find the symbol location first find_symbol_tool = agent.get_tool(FindSymbolTool) result = find_symbol_tool.apply(name_path_pattern=symbol_name, relative_path=def_file) time.sleep(1) symbols = json.loads(result) # Find the definition def_symbol = symbols[0] # Now find references find_refs_tool = agent.get_tool(FindReferencingSymbolsTool) result = find_refs_tool.apply(name_path=def_symbol["name_path"], relative_path=def_symbol["relative_path"]) def contains_ref_with_relative_path(refs, relative_path): """ Checks for reference to relative path, regardless of output format (grouped an ungrouped) """ if isinstance(refs, list): for ref in refs: if contains_ref_with_relative_path(ref, relative_path): return True elif isinstance(refs, dict): if relative_path in refs: return True for value in refs.values(): if contains_ref_with_relative_path(value, relative_path): return True return False refs = json.loads(result) assert contains_ref_with_relative_path(refs, ref_file), f"Expected to find reference to {symbol_name} in {ref_file}. refs={refs}" @pytest.mark.parametrize( "serena_agent,symbol_name,def_file,ref_file", [ pytest.param( Language.PYTHON, "User", os.path.join("test_repo", "models.py"), os.path.join("test_repo", "services.py"), marks=pytest.mark.python, ), pytest.param(Language.GO, "Helper", "main.go", "main.go", marks=pytest.mark.go), pytest.param( Language.JAVA, "Model", os.path.join("src", "main", "java", "test_repo", "Model.java"), os.path.join("src", "main", "java", "test_repo", "Main.java"), marks=pytest.mark.java, ), pytest.param( Language.KOTLIN, "Model", os.path.join("src", "main", "kotlin", "test_repo", "Model.kt"), os.path.join("src", "main", "kotlin", "test_repo", "Main.kt"), marks=[pytest.mark.kotlin] + ([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []), ), pytest.param(Language.RUST, "add", os.path.join("src", "lib.rs"), os.path.join("src", "main.rs"), marks=pytest.mark.rust), pytest.param(Language.PHP, "helperFunction", "helper.php", "index.php", marks=pytest.mark.php), pytest.param( Language.CLOJURE, "multiply", clj.CORE_PATH, clj.UTILS_PATH, marks=pytest.mark.clojure, ), pytest.param(Language.CSHARP, "Calculator", "Program.cs", "Program.cs", marks=pytest.mark.csharp), pytest.param(Language.POWERSHELL, "function Greet-User ()", "main.ps1", "main.ps1", marks=pytest.mark.powershell), pytest.param(Language.CPP_CCLS, "add", "b.cpp", "a.cpp", marks=pytest.mark.cpp), pytest.param(Language.LEAN4, "add", "Helper.lean", "Main.lean", marks=pytest.mark.lean4), ], indirect=["serena_agent"], ) def test_find_symbol_references_stable(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None: self._assert_find_symbol_references(serena_agent, symbol_name, def_file, ref_file) @pytest.mark.parametrize( "serena_agent,symbol_name,def_file,ref_file", [ pytest.param(Language.TYPESCRIPT, "helperFunction", "index.ts", "use_helper.ts", marks=pytest.mark.typescript), ], indirect=["serena_agent"], ) @pytest.mark.xfail(False, reason="TypeScript language server is unreliable") # NOTE: Testing; may be resolved by #1120; See issue #1040 def test_find_symbol_references_typescript(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None: self._assert_find_symbol_references(serena_agent, symbol_name, def_file, ref_file) @pytest.mark.parametrize( "serena_agent,symbol_name,def_file,ref_file", [ pytest.param(Language.FSHARP, "add", "Calculator.fs", "Program.fs", marks=pytest.mark.fsharp), ], indirect=["serena_agent"], ) @pytest.mark.xfail(reason="F# language server is unreliable") # See issue #1040 def test_find_symbol_references_fsharp(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None: self._assert_find_symbol_references(serena_agent, symbol_name, def_file, ref_file) @pytest.mark.parametrize( "serena_agent,name_path,substring_matching,expected_symbol_name,expected_kind,expected_file", [ pytest.param( Language.PYTHON, "OuterClass/NestedClass", False, "NestedClass", "Class", os.path.join("test_repo", "nested.py"), id="exact_qualname_class", marks=pytest.mark.python, ), pytest.param( Language.PYTHON, "OuterClass/NestedClass/find_me", False, "find_me", "Method", os.path.join("test_repo", "nested.py"), id="exact_qualname_method", marks=pytest.mark.python, ), pytest.param( Language.PYTHON, "OuterClass/NestedCl", # Substring for NestedClass True, "NestedClass", "Class", os.path.join("test_repo", "nested.py"), id="substring_qualname_class", marks=pytest.mark.python, ), pytest.param( Language.PYTHON, "OuterClass/NestedClass/find_m", # Substring for find_me True, "find_me", "Method", os.path.join("test_repo", "nested.py"), id="substring_qualname_method", marks=pytest.mark.python, ), pytest.param( Language.PYTHON, "/OuterClass", # Absolute path False, "OuterClass", "Class", os.path.join("test_repo", "nested.py"), id="absolute_qualname_class", marks=pytest.mark.python, ), pytest.param( Language.PYTHON, "/OuterClass/NestedClass/find_m", # Absolute path with substring True, "find_me", "Method", os.path.join("test_repo", "nested.py"), id="absolute_substring_qualname_method", marks=pytest.mark.python, ), ], indirect=["serena_agent"], ) def test_find_symbol_name_path( self, serena_agent, name_path: str, substring_matching: bool, expected_symbol_name: str, expected_kind: str, expected_file: str, ): agent = serena_agent find_symbol_tool = agent.get_tool(FindSymbolTool) result = find_symbol_tool.apply_ex( name_path_pattern=name_path, depth=0, relative_path=None, include_body=False, include_kinds=None, exclude_kinds=None, substring_matching=substring_matching, ) symbols = json.loads(result) assert any( expected_symbol_name == s["name_path"].split("/")[-1] and expected_kind.lower() in s["kind"].lower() and expected_file in s["relative_path"] for s in symbols ), f"Expected to find {name_path} ({expected_kind}) in {expected_file}. Symbols: {symbols}" @pytest.mark.parametrize( "serena_agent,name_path", [ pytest.param( Language.PYTHON, "/NestedClass", # Absolute path, NestedClass is not top-level id="absolute_path_non_top_level_no_match", marks=pytest.mark.python, ), pytest.param( Language.PYTHON, "/NoSuchParent/NestedClass", # Absolute path with non-existent parent id="absolute_path_non_existent_parent_no_match", marks=pytest.mark.python, ), ], indirect=["serena_agent"], ) def test_find_symbol_name_path_no_match( self, serena_agent, name_path: str, ): agent = serena_agent find_symbol_tool = agent.get_tool(FindSymbolTool) result = find_symbol_tool.apply_ex( name_path_pattern=name_path, depth=0, substring_matching=True, ) symbols = json.loads(result) assert not symbols, f"Expected to find no symbols for {name_path}. Symbols found: {symbols}" @pytest.mark.parametrize( "serena_agent,name_path,num_expected", [ pytest.param( Language.JAVA, "Model/getName", 2, id="overloaded_java_method", marks=pytest.mark.java, ), ], indirect=["serena_agent"], ) def test_find_symbol_overloaded_function(self, serena_agent: SerenaAgent, name_path: str, num_expected: int): """ Tests whether the FindSymbolTool can find all overloads of a function/method (provided that the overload id remains unspecified in the name path) """ agent = serena_agent find_symbol_tool = agent.get_tool(FindSymbolTool) result = find_symbol_tool.apply_ex( name_path_pattern=name_path, depth=0, substring_matching=False, ) symbols = json.loads(result) assert ( len(symbols) == num_expected ), f"Expected to find {num_expected} symbols for overloaded function {name_path}. Symbols found: {symbols}" @pytest.mark.parametrize( "serena_agent,name_path,relative_path", [ pytest.param( Language.JAVA, "Model/getName", os.path.join("src", "main", "java", "test_repo", "Model.java"), id="overloaded_java_method", marks=pytest.mark.java, ), ], indirect=["serena_agent"], ) def test_non_unique_symbol_reference_error(self, serena_agent: SerenaAgent, name_path: str, relative_path: str): """ Tests whether the tools operating on a well-defined symbol raises an error when the symbol reference is non-unique. We exemplarily test a retrieval tool (FindReferencingSymbolsTool) and an editing tool (ReplaceSymbolBodyTool). """ match_text = "multiple" find_refs_tool = serena_agent.get_tool(FindReferencingSymbolsTool) with pytest.raises(ValueError, match=match_text): find_refs_tool.apply(name_path=name_path, relative_path=relative_path) replace_symbol_body_tool = serena_agent.get_tool(ReplaceSymbolBodyTool) with pytest.raises(ValueError, match=match_text): replace_symbol_body_tool.apply(name_path=name_path, relative_path=relative_path, body="") @pytest.mark.parametrize( "serena_agent", [ pytest.param( Language.TYPESCRIPT, marks=pytest.mark.typescript, ), ], indirect=["serena_agent"], ) def test_replace_content_regex_with_wildcard_ok(self, serena_agent: SerenaAgent): """ Tests a regex-based content replacement that has a unique match """ relative_path = "ws_manager.js" with project_file_modification_context(serena_agent, relative_path): replace_content_tool = serena_agent.get_tool(ReplaceContentTool) result = replace_content_tool.apply( needle=r'catch \(error\) \{\s*console.error\("Failed to connect.*?\}', repl='catch(error) { console.log("Never mind"); }', relative_path=relative_path, mode="regex", ) assert result == SUCCESS_RESULT @pytest.mark.parametrize( "serena_agent", [ pytest.param( Language.TYPESCRIPT, marks=pytest.mark.typescript, ), ], indirect=["serena_agent"], ) @pytest.mark.parametrize("mode", ["literal", "regex"]) def test_replace_content_with_backslashes(self, serena_agent: SerenaAgent, mode: Literal["literal", "regex"]): """ Tests a content replacement where the needle and replacement strings contain backslashes. This is a regression test for escaping issues. """ relative_path = "ws_manager.js" needle = r'console.log("WebSocketManager initializing\nStatus OK");' repl = r'console.log("WebSocketManager initialized\nAll systems go!");' replace_content_tool = serena_agent.get_tool(ReplaceContentTool) with project_file_modification_context(serena_agent, relative_path): result = replace_content_tool.apply( needle=re.escape(needle) if mode == "regex" else needle, repl=repl, relative_path=relative_path, mode=mode, ) assert result == SUCCESS_RESULT new_content = read_project_file(serena_agent.get_active_project(), relative_path) assert repl in new_content @pytest.mark.parametrize( "serena_agent", [ pytest.param( Language.TYPESCRIPT, marks=pytest.mark.typescript, ), ], indirect=["serena_agent"], ) def test_replace_content_regex_with_wildcard_ambiguous(self, serena_agent: SerenaAgent): """ Tests that an ambiguous replacement where there is a larger match that internally contains a smaller match triggers an exception """ replace_content_tool = serena_agent.get_tool(ReplaceContentTool) with pytest.raises(ValueError, match="ambiguous"): replace_content_tool.apply( needle=r'catch \(error\) \{.*?this\.updateConnectionStatus\("Connection failed", false\);.*?\}', repl='catch(error) { console.log("Never mind"); }', relative_path="ws_manager.js", mode="regex", ) ================================================ FILE: test/serena/test_set_modes.py ================================================ """Tests for SerenaAgent.set_modes() to verify that mode switching works correctly.""" import logging from serena.agent import SerenaAgent from serena.config.serena_config import ModeSelectionDefinition, SerenaConfig class TestSetModes: """Test that set_modes correctly changes active modes.""" def _create_agent(self, modes: ModeSelectionDefinition | None = None) -> SerenaAgent: config = SerenaConfig(gui_log_window=False, web_dashboard=False, log_level=logging.ERROR) return SerenaAgent(serena_config=config, modes=modes) def test_set_modes_changes_active_modes(self) -> None: """Test that calling set_modes actually changes the active modes.""" agent = self._create_agent(modes=ModeSelectionDefinition(default_modes=["editing", "interactive"])) initial_mode_names = sorted(m.name for m in agent.get_active_modes()) assert "editing" in initial_mode_names assert "interactive" in initial_mode_names # Switch to planning mode agent.set_modes(["planning", "interactive"]) new_mode_names = sorted(m.name for m in agent.get_active_modes()) assert "planning" in new_mode_names assert "interactive" in new_mode_names assert "editing" not in new_mode_names def test_set_modes_overrides_config_defaults(self) -> None: """Test that set_modes takes precedence over config defaults.""" config = SerenaConfig(gui_log_window=False, web_dashboard=False, log_level=logging.ERROR) config.default_modes = ["editing", "interactive"] agent = SerenaAgent(serena_config=config) # Verify config defaults are active initial_mode_names = [m.name for m in agent.get_active_modes()] assert "editing" in initial_mode_names # Switch modes — should override config defaults agent.set_modes(["planning", "one-shot"]) new_mode_names = [m.name for m in agent.get_active_modes()] assert "planning" in new_mode_names assert "one-shot" in new_mode_names assert "editing" not in new_mode_names def test_set_modes_persists_after_repeated_calls(self) -> None: """Test that set_modes result persists (modes don't revert).""" agent = self._create_agent(modes=ModeSelectionDefinition(default_modes=["editing"])) agent.set_modes(["planning"]) mode_names_1 = [m.name for m in agent.get_active_modes()] assert "planning" in mode_names_1 # Call get_active_modes again — should still be planning mode_names_2 = [m.name for m in agent.get_active_modes()] assert mode_names_1 == mode_names_2 def test_set_modes_can_switch_back(self) -> None: """Test that modes can be switched back to original after switching away.""" agent = self._create_agent(modes=ModeSelectionDefinition(default_modes=["editing", "interactive"])) # Switch away agent.set_modes(["planning", "one-shot"]) assert "planning" in [m.name for m in agent.get_active_modes()] # Switch back agent.set_modes(["editing", "interactive"]) mode_names = [m.name for m in agent.get_active_modes()] assert "editing" in mode_names assert "interactive" in mode_names assert "planning" not in mode_names ================================================ FILE: test/serena/test_symbol.py ================================================ from unittest.mock import MagicMock import pytest from serena.jetbrains.jetbrains_types import SymbolDTO, SymbolDTOKey from serena.project import Project from serena.symbol import LanguageServerSymbol, LanguageServerSymbolRetriever, NamePathComponent, NamePathMatcher from solidlsp.ls_config import Language class TestSymbolNameMatching: def _create_assertion_error_message( self, name_path_pattern: str, name_path_components: list[NamePathComponent], is_substring_match: bool, expected_result: bool, actual_result: bool, ) -> str: """Helper to create a detailed error message for assertions.""" qnp_repr = "/".join(map(str, name_path_components)) return ( f"Pattern '{name_path_pattern}' (substring: {is_substring_match}) vs " f"Name path components {name_path_components} (as '{qnp_repr}'). " f"Expected: {expected_result}, Got: {actual_result}" ) @pytest.mark.parametrize( "name_path_pattern, symbol_name_path_parts, is_substring_match, expected", [ # Exact matches, anywhere in the name (is_substring_match=False) pytest.param("foo", ["foo"], False, True, id="'foo' matches 'foo' exactly (simple)"), pytest.param("foo/", ["foo"], False, True, id="'foo/' matches 'foo' exactly (simple)"), pytest.param("foo", ["bar", "foo"], False, True, id="'foo' matches ['bar', 'foo'] exactly (simple, last element)"), pytest.param("foo", ["foobar"], False, False, id="'foo' does not match 'foobar' exactly (simple)"), pytest.param( "foo", ["bar", "foobar"], False, False, id="'foo' does not match ['bar', 'foobar'] exactly (simple, last element)" ), pytest.param( "foo", ["path", "to", "foo"], False, True, id="'foo' matches ['path', 'to', 'foo'] exactly (simple, last element)" ), # Exact matches, absolute patterns (is_substring_match=False) pytest.param("/foo", ["foo"], False, True, id="'/foo' matches ['foo'] exactly (absolute simple)"), pytest.param("/foo", ["foo", "bar"], False, False, id="'/foo' does not match ['foo', 'bar'] (absolute simple, len mismatch)"), pytest.param("/foo", ["bar"], False, False, id="'/foo' does not match ['bar'] (absolute simple, name mismatch)"), pytest.param( "/foo", ["bar", "foo"], False, False, id="'/foo' does not match ['bar', 'foo'] (absolute simple, position mismatch)" ), # Substring matches, anywhere in the name (is_substring_match=True) pytest.param("foo", ["foobar"], True, True, id="'foo' matches 'foobar' as substring (simple)"), pytest.param("foo", ["bar", "foobar"], True, True, id="'foo' matches ['bar', 'foobar'] as substring (simple, last element)"), pytest.param( "foo", ["barfoo"], True, True, id="'foo' matches 'barfoo' as substring (simple)" ), # This was potentially ambiguous before pytest.param("foo", ["baz"], True, False, id="'foo' does not match 'baz' as substring (simple)"), pytest.param("foo", ["bar", "baz"], True, False, id="'foo' does not match ['bar', 'baz'] as substring (simple, last element)"), pytest.param("foo", ["my_foobar_func"], True, True, id="'foo' matches 'my_foobar_func' as substring (simple)"), pytest.param( "foo", ["ClassA", "my_foobar_method"], True, True, id="'foo' matches ['ClassA', 'my_foobar_method'] as substring (simple, last element)", ), pytest.param("foo", ["my_bar_func"], True, False, id="'foo' does not match 'my_bar_func' as substring (simple)"), # Substring matches, absolute patterns (is_substring_match=True) pytest.param("/foo", ["foobar"], True, True, id="'/foo' matches ['foobar'] as substring (absolute simple)"), pytest.param("/foo/", ["foobar"], True, True, id="'/foo/' matches ['foobar'] as substring (absolute simple, last element)"), pytest.param("/foo", ["barfoobaz"], True, True, id="'/foo' matches ['barfoobaz'] as substring (absolute simple)"), pytest.param( "/foo", ["foo", "bar"], True, False, id="'/foo' does not match ['foo', 'bar'] as substring (absolute simple, len mismatch)" ), pytest.param("/foo", ["bar"], True, False, id="'/foo' does not match ['bar'] (absolute simple, no substr)"), pytest.param( "/foo", ["bar", "foo"], True, False, id="'/foo' does not match ['bar', 'foo'] (absolute simple, position mismatch)" ), pytest.param( "/foo/", ["bar", "foo"], True, False, id="'/foo/' does not match ['bar', 'foo'] (absolute simple, position mismatch)" ), ], ) def test_match_simple_name(self, name_path_pattern, symbol_name_path_parts, is_substring_match, expected): """Tests matching for simple names (no '/' in pattern).""" symbol_name_path_components = [NamePathComponent(part) for part in symbol_name_path_parts] result = NamePathMatcher(name_path_pattern, is_substring_match).matches_reversed_components(reversed(symbol_name_path_components)) error_msg = self._create_assertion_error_message(name_path_pattern, symbol_name_path_parts, is_substring_match, expected, result) assert result == expected, error_msg @pytest.mark.parametrize( "name_path_pattern, symbol_name_path_parts, is_substring_match, expected", [ # --- Relative patterns (suffix matching) --- # Exact matches, relative patterns (is_substring_match=False) pytest.param("bar/foo", ["bar", "foo"], False, True, id="R: 'bar/foo' matches ['bar', 'foo'] exactly"), pytest.param("bar/foo", ["mod", "bar", "foo"], False, True, id="R: 'bar/foo' matches ['mod', 'bar', 'foo'] exactly (suffix)"), pytest.param( "bar/foo", ["bar", "foo", "baz"], False, False, id="R: 'bar/foo' does not match ['bar', 'foo', 'baz'] (pattern shorter)" ), pytest.param("bar/foo", ["bar"], False, False, id="R: 'bar/foo' does not match ['bar'] (pattern longer)"), pytest.param("bar/foo", ["baz", "foo"], False, False, id="R: 'bar/foo' does not match ['baz', 'foo'] (first part mismatch)"), pytest.param("bar/foo", ["bar", "baz"], False, False, id="R: 'bar/foo' does not match ['bar', 'baz'] (last part mismatch)"), pytest.param("bar/foo", ["foo"], False, False, id="R: 'bar/foo' does not match ['foo'] (pattern longer)"), pytest.param( "bar/foo", ["other", "foo"], False, False, id="R: 'bar/foo' does not match ['other', 'foo'] (first part mismatch)" ), pytest.param( "bar/foo", ["bar", "otherfoo"], False, False, id="R: 'bar/foo' does not match ['bar', 'otherfoo'] (last part mismatch)" ), # Substring matches, relative patterns (is_substring_match=True) pytest.param("bar/foo", ["bar", "foobar"], True, True, id="R: 'bar/foo' matches ['bar', 'foobar'] as substring"), pytest.param( "bar/foo", ["mod", "bar", "foobar"], True, True, id="R: 'bar/foo' matches ['mod', 'bar', 'foobar'] as substring (suffix)" ), pytest.param("bar/foo", ["bar", "bazfoo"], True, True, id="R: 'bar/foo' matches ['bar', 'bazfoo'] as substring"), pytest.param("bar/fo", ["bar", "foo"], True, True, id="R: 'bar/fo' matches ['bar', 'foo'] as substring"), # codespell:ignore pytest.param("bar/foo", ["bar", "baz"], True, False, id="R: 'bar/foo' does not match ['bar', 'baz'] (last no substr)"), pytest.param( "bar/foo", ["baz", "foobar"], True, False, id="R: 'bar/foo' does not match ['baz', 'foobar'] (first part mismatch)" ), pytest.param( "bar/foo", ["bar", "my_foobar_method"], True, True, id="R: 'bar/foo' matches ['bar', 'my_foobar_method'] as substring" ), pytest.param( "bar/foo", ["mod", "bar", "my_foobar_method"], True, True, id="R: 'bar/foo' matches ['mod', 'bar', 'my_foobar_method'] as substring (suffix)", ), pytest.param( "bar/foo", ["bar", "another_method"], True, False, id="R: 'bar/foo' does not match ['bar', 'another_method'] (last no substr)", ), pytest.param( "bar/foo", ["other", "my_foobar_method"], True, False, id="R: 'bar/foo' does not match ['other', 'my_foobar_method'] (first part mismatch)", ), pytest.param("bar/f", ["bar", "foo"], True, True, id="R: 'bar/f' matches ['bar', 'foo'] as substring"), # Exact matches, absolute patterns (is_substring_match=False) pytest.param("/bar/foo", ["bar", "foo"], False, True, id="A: '/bar/foo' matches ['bar', 'foo'] exactly"), pytest.param( "/bar/foo", ["bar", "foo", "baz"], False, False, id="A: '/bar/foo' does not match ['bar', 'foo', 'baz'] (pattern shorter)" ), pytest.param("/bar/foo", ["bar"], False, False, id="A: '/bar/foo' does not match ['bar'] (pattern longer)"), pytest.param("/bar/foo", ["baz", "foo"], False, False, id="A: '/bar/foo' does not match ['baz', 'foo'] (first part mismatch)"), pytest.param( "/bar/foo", ["baz", "bar", "foo"], False, False, id="A: '/bar/foo' does not match ['baz', 'bar', 'foo'] (only suffix match for abs pattern)", ), pytest.param("/bar/foo", ["bar", "baz"], False, False, id="A: '/bar/foo' does not match ['bar', 'baz'] (last part mismatch)"), # Substring matches (is_substring_match=True) pytest.param("/bar/foo", ["bar", "foobar"], True, True, id="A: '/bar/foo' matches ['bar', 'foobar'] as substring"), pytest.param("/bar/foo", ["bar", "bazfoo"], True, True, id="A: '/bar/foo' matches ['bar', 'bazfoo'] as substring"), pytest.param("/bar/fo", ["bar", "foo"], True, True, id="A: '/bar/fo' matches ['bar', 'foo'] as substring"), # codespell:ignore pytest.param("/bar/foo", ["bar", "baz"], True, False, id="A: '/bar/foo' does not match ['bar', 'baz'] (last no substr)"), pytest.param( "/bar/foo", ["baz", "foobar"], True, False, id="A: '/bar/foo' does not match ['baz', 'foobar'] (first part mismatch)" ), ], ) def test_match_name_path_pattern_path_len_2(self, name_path_pattern, symbol_name_path_parts, is_substring_match, expected): """Tests matching for qualified names (e.g. 'module/class/func').""" symbol_name_path_components = [NamePathComponent(part) for part in symbol_name_path_parts] result = NamePathMatcher(name_path_pattern, is_substring_match).matches_reversed_components(reversed(symbol_name_path_components)) error_msg = self._create_assertion_error_message(name_path_pattern, symbol_name_path_parts, is_substring_match, expected, result) assert result == expected, error_msg @pytest.mark.parametrize( "name_path_pattern, symbol_name_path_components, expected", [ pytest.param( "bar/foo", [NamePathComponent("bar"), NamePathComponent("foo", 0)], True, id="R: 'bar/foo' matches ['bar', 'foo'] with overload_index=0", ), pytest.param( "bar/foo", [NamePathComponent("bar"), NamePathComponent("foo", 1)], True, id="R: 'bar/foo' matches ['bar', 'foo'] with overload_index=1", ), pytest.param( "bar/foo[0]", [NamePathComponent("bar"), NamePathComponent("foo", 0)], True, id="R: 'bar/foo[0]' matches ['bar', 'foo'] with overload_index=0", ), pytest.param( "bar/foo[1]", [NamePathComponent("bar"), NamePathComponent("foo", 0)], False, id="R: 'bar/foo[1]' does not match ['bar', 'foo'] with overload_index=0", ), pytest.param( "bar/foo", [NamePathComponent("bar", 0), NamePathComponent("foo")], True, id="R: 'bar/foo' matches ['bar[0]', 'foo']" ), pytest.param( "bar/foo", [NamePathComponent("bar", 0), NamePathComponent("foo", 1)], True, id="R: 'bar/foo' matches ['bar[0]', 'foo[1]']" ), pytest.param( "bar[0]/foo", [NamePathComponent("bar", 0), NamePathComponent("foo")], True, id="R: 'bar[0]/foo' matches ['bar[0]', 'foo']" ), pytest.param( "bar[0]/foo[1]", [NamePathComponent("bar", 0), NamePathComponent("foo", 1)], True, id="R: 'bar[0]/foo[1]' matches ['bar[0]', 'foo[1]']", ), pytest.param( "bar[0]/foo[1]", [NamePathComponent("bar", 1), NamePathComponent("foo", 0)], False, id="R: 'bar[0]/foo[1]' does not match ['bar[1]', 'foo[0]']", ), ], ) def test_match_name_path_pattern_with_overload_idx(self, name_path_pattern, symbol_name_path_components, expected): """Tests matching for qualified names (e.g. 'module/class/func').""" matcher = NamePathMatcher(name_path_pattern, False) result = matcher.matches_reversed_components(reversed(symbol_name_path_components)) error_msg = self._create_assertion_error_message(name_path_pattern, symbol_name_path_components, False, expected, result) assert result == expected, error_msg @pytest.mark.python class TestLanguageServerSymbolRetriever: @pytest.mark.parametrize("project_with_ls", [Language.PYTHON], indirect=True) def test_request_info(self, project_with_ls: Project): symbol_retriever = LanguageServerSymbolRetriever(project_with_ls) create_user_method_symbol = symbol_retriever.find("UserService/create_user", within_relative_path="test_repo/services.py")[0] create_user_method_symbol_info = symbol_retriever.request_info_for_symbol(create_user_method_symbol) assert "Create a new user and store it" in create_user_method_symbol_info class TestSymbolDictTypes: @staticmethod def check_key_type(dict_type: type, key_type: type): """ :param dict_type: a TypedDict type :param key_type: the corresponding key type (Literal[...]) that the dict should have for keys """ dict_type_keys = dict_type.__annotations__.keys() assert len(dict_type_keys) == len( key_type.__args__ # type: ignore ), f"Expected {len(key_type.__args__)} keys in {dict_type}, but got {len(dict_type_keys)}" # type: ignore for expected_key in key_type.__args__: # type: ignore assert expected_key in dict_type_keys, f"Expected key '{expected_key}' not found in {dict_type}" def test_ls_symbol_dict_type(self): self.check_key_type(LanguageServerSymbol.OutputDict, LanguageServerSymbol.OutputDictKey) def test_jb_symbol_dict_type(self): self.check_key_type(SymbolDTO, SymbolDTOKey) def _make_mock_symbols(count: int, *, relative_path: str = "test_repo/services.py") -> list[MagicMock]: symbols: list[MagicMock] = [] for i in range(count): sym = MagicMock() sym.relative_path = relative_path sym.line = i + 1 sym.column = 0 sym.symbol_root = {} symbols.append(sym) return symbols @pytest.mark.python class TestHoverBudget: """Tests for symbol_info_budget time budget behavior.""" @pytest.mark.parametrize("project_with_ls", [Language.PYTHON], indirect=True) def test_budget_not_exceeded_all_lookups_performed(self, project_with_ls: Project, monkeypatch: pytest.MonkeyPatch): """With a large budget, all hover lookups are performed.""" # Create symbol retriever with a mock agent that has large budget project_with_ls.serena_config.symbol_info_budget = 10.0 project_with_ls.project_config.symbol_info_budget = 10.0 symbol_retriever = LanguageServerSymbolRetriever(project_with_ls) # Track _request_info calls call_count = 0 def counting_request_info(file_path, line, column, **kwargs): nonlocal call_count call_count += 1 return f"info:{line}:{column}" monkeypatch.setattr(symbol_retriever, "_request_info", counting_request_info) # Create mock symbols with unique (line, col) pairs symbols = _make_mock_symbols(3) result = symbol_retriever.request_info_for_symbol_batch(symbols) # All 3 symbols should have info (no budget exceeded) assert call_count == 3 assert all(info is not None for info in result.values()) assert len(result) == 3 @pytest.mark.parametrize("project_with_ls", [Language.PYTHON], indirect=True) def test_budget_exceeded_partial_info(self, project_with_ls: Project, monkeypatch: pytest.MonkeyPatch): """With a small budget, hover lookups stop and remaining symbols get None info.""" project_with_ls.serena_config.symbol_info_budget = 0.1 project_with_ls.project_config.symbol_info_budget = 0.1 symbol_retriever = LanguageServerSymbolRetriever(project_with_ls) # Track _request_info calls and simulate 0.05s per call call_count = 0 simulated_time = [0.0] def slow_request_info(file_path, line, column, **kwargs): nonlocal call_count call_count += 1 # Simulate each hover taking 0.05s simulated_time[0] += 0.05 return f"info:{line}:{column}" # Mock perf_counter to return simulated time for hover duration def mock_perf_counter(): return simulated_time[0] monkeypatch.setattr(symbol_retriever, "_request_info", slow_request_info) monkeypatch.setattr("serena.symbol.perf_counter", mock_perf_counter) # Create 5 mock symbols with unique (line, col) pairs symbols = _make_mock_symbols(5) result = symbol_retriever.request_info_for_symbol_batch(symbols) # Budget is 0.1s, each call takes 0.05s, so only 2 calls should succeed # After 2 calls: 0.1s >= 0.1s budget, remaining 3 should be skipped assert call_count == 2 assert len(result) == 5 # First 2 symbols should have info, last 3 should be None result_list = list(result.values()) assert result_list[0] is not None assert result_list[1] is not None assert result_list[2] is None assert result_list[3] is None assert result_list[4] is None @pytest.mark.parametrize("project_with_ls", [Language.PYTHON], indirect=True) def test_budget_zero_means_unlimited(self, project_with_ls: Project, monkeypatch: pytest.MonkeyPatch): """With budget=0, all hover lookups proceed (no early stopping).""" project_with_ls.serena_config.symbol_info_budget = 0.0 project_with_ls.project_config.symbol_info_budget = 0.0 symbol_retriever = LanguageServerSymbolRetriever(project_with_ls) # Track _request_info calls call_count = 0 def counting_request_info(file_path, line, column, **kwargs): nonlocal call_count call_count += 1 return f"info:{line}:{column}" monkeypatch.setattr(symbol_retriever, "_request_info", counting_request_info) # Create mock symbols symbols = _make_mock_symbols(5) result = symbol_retriever.request_info_for_symbol_batch(symbols) # All 5 symbols should be looked up (no budget limit) assert call_count == 5 assert all(info is not None for info in result.values()) @pytest.mark.parametrize("project_with_ls", [Language.PYTHON], indirect=True) def test_project_budget_overrides_global(self, project_with_ls: Project, monkeypatch: pytest.MonkeyPatch): """Project-level budget overrides global budget.""" # Create symbol retriever with global budget 10.0 but project budget 0.05 project_with_ls.project_config.symbol_info_budget = 0.05 project_with_ls.serena_config.symbol_info_budget = 10.0 symbol_retriever = LanguageServerSymbolRetriever(project_with_ls) # Track _request_info calls and simulate time call_count = 0 simulated_time = [0.0] def slow_request_info(file_path, line, column, **kwargs): nonlocal call_count call_count += 1 simulated_time[0] += 0.03 return f"info:{line}:{column}" def mock_perf_counter(): return simulated_time[0] monkeypatch.setattr(symbol_retriever, "_request_info", slow_request_info) monkeypatch.setattr("serena.symbol.perf_counter", mock_perf_counter) # Create 5 mock symbols symbols = _make_mock_symbols(5) symbol_retriever.request_info_for_symbol_batch(symbols) # Project budget is 0.05s, each call takes 0.03s # Budget check happens BEFORE starting a new call: # - Before call 1: spent=0 < 0.05, proceed, spent becomes 0.03 # - Before call 2: spent=0.03 < 0.05, proceed, spent becomes 0.06 # - Before call 3: spent=0.06 >= 0.05, skip # So 2 calls succeed (proving project budget 0.05 overrode global 10.0) assert call_count == 2 @pytest.mark.parametrize("project_with_ls", [Language.PYTHON], indirect=True) def test_project_null_inherits_global(self, project_with_ls: Project, monkeypatch: pytest.MonkeyPatch): """When project budget is None, global budget is used.""" # Create symbol retriever with project budget=None (inherit global) project_with_ls.project_config.symbol_info_budget = None project_with_ls.serena_config.symbol_info_budget = 10.0 symbol_retriever = LanguageServerSymbolRetriever(project_with_ls) # Track _request_info calls call_count = 0 def counting_request_info(file_path, line, column, **kwargs): nonlocal call_count call_count += 1 return f"info:{line}:{column}" monkeypatch.setattr(symbol_retriever, "_request_info", counting_request_info) # Create 3 mock symbols symbols = _make_mock_symbols(3) result = symbol_retriever.request_info_for_symbol_batch(symbols) # Global budget is 10s, all 3 should succeed assert call_count == 3 assert all(info is not None for info in result.values()) ================================================ FILE: test/serena/test_symbol_editing.py ================================================ """ Snapshot tests using the (awesome) syrupy pytest plugin https://github.com/syrupy-project/syrupy. Recreate the snapshots with `pytest --snapshot-update`. """ import logging import os import shutil import sys import tempfile import time from abc import ABC, abstractmethod from collections.abc import Iterator from contextlib import contextmanager from dataclasses import dataclass, field from difflib import SequenceMatcher from pathlib import Path from typing import Literal, NamedTuple import pytest from overrides import overrides from syrupy import SnapshotAssertion from serena.code_editor import CodeEditor, LanguageServerCodeEditor from solidlsp.ls_config import Language from src.serena.symbol import LanguageServerSymbolRetriever from test.conftest import get_repo_path, project_with_ls_context pytestmark = pytest.mark.snapshot log = logging.getLogger(__name__) class LineChange(NamedTuple): """Represents a change to a specific line or range of lines.""" operation: Literal["insert", "delete", "replace"] original_start: int original_end: int modified_start: int modified_end: int original_lines: list[str] modified_lines: list[str] @dataclass class CodeDiff: """ Represents the difference between original and modified code. Provides object-oriented access to diff information including line numbers. """ relative_path: str original_content: str modified_content: str _line_changes: list[LineChange] = field(init=False) def __post_init__(self) -> None: """Compute the diff using difflib's SequenceMatcher.""" original_lines = self.original_content.splitlines(keepends=True) modified_lines = self.modified_content.splitlines(keepends=True) matcher = SequenceMatcher(None, original_lines, modified_lines) self._line_changes = [] for tag, orig_start, orig_end, mod_start, mod_end in matcher.get_opcodes(): if tag == "equal": continue if tag == "insert": self._line_changes.append( LineChange( operation="insert", original_start=orig_start, original_end=orig_start, modified_start=mod_start, modified_end=mod_end, original_lines=[], modified_lines=modified_lines[mod_start:mod_end], ) ) elif tag == "delete": self._line_changes.append( LineChange( operation="delete", original_start=orig_start, original_end=orig_end, modified_start=mod_start, modified_end=mod_start, original_lines=original_lines[orig_start:orig_end], modified_lines=[], ) ) elif tag == "replace": self._line_changes.append( LineChange( operation="replace", original_start=orig_start, original_end=orig_end, modified_start=mod_start, modified_end=mod_end, original_lines=original_lines[orig_start:orig_end], modified_lines=modified_lines[mod_start:mod_end], ) ) @property def line_changes(self) -> list[LineChange]: """Get all line changes in the diff.""" return self._line_changes @property def has_changes(self) -> bool: """Check if there are any changes.""" return len(self._line_changes) > 0 @property def added_lines(self) -> list[tuple[int, str]]: """Get all added lines with their line numbers (0-based) in the modified file.""" result = [] for change in self._line_changes: if change.operation in ("insert", "replace"): for i, line in enumerate(change.modified_lines): result.append((change.modified_start + i, line)) return result @property def deleted_lines(self) -> list[tuple[int, str]]: """Get all deleted lines with their line numbers (0-based) in the original file.""" result = [] for change in self._line_changes: if change.operation in ("delete", "replace"): for i, line in enumerate(change.original_lines): result.append((change.original_start + i, line)) return result @property def modified_line_numbers(self) -> list[int]: """Get all line numbers (0-based) that were modified in the modified file.""" line_nums: set[int] = set() for change in self._line_changes: if change.operation in ("insert", "replace"): line_nums.update(range(change.modified_start, change.modified_end)) return sorted(line_nums) @property def affected_original_line_numbers(self) -> list[int]: """Get all line numbers (0-based) that were affected in the original file.""" line_nums: set[int] = set() for change in self._line_changes: if change.operation in ("delete", "replace"): line_nums.update(range(change.original_start, change.original_end)) return sorted(line_nums) def get_unified_diff(self, context_lines: int = 3) -> str: """Get the unified diff as a string.""" import difflib original_lines = self.original_content.splitlines(keepends=True) modified_lines = self.modified_content.splitlines(keepends=True) diff = difflib.unified_diff( original_lines, modified_lines, fromfile=f"a/{self.relative_path}", tofile=f"b/{self.relative_path}", n=context_lines ) return "".join(diff) def get_context_diff(self, context_lines: int = 3) -> str: """Get the context diff as a string.""" import difflib original_lines = self.original_content.splitlines(keepends=True) modified_lines = self.modified_content.splitlines(keepends=True) diff = difflib.context_diff( original_lines, modified_lines, fromfile=f"a/{self.relative_path}", tofile=f"b/{self.relative_path}", n=context_lines ) return "".join(diff) class EditingTest(ABC): def __init__(self, language: Language, rel_path: str): """ :param language: the language :param rel_path: the relative path of the edited file """ self.rel_path = rel_path self.language = language self.original_repo_path = get_repo_path(language) self.repo_path: Path | None = None @contextmanager def _setup(self) -> Iterator[LanguageServerSymbolRetriever]: """Context manager for setup/teardown with a temporary directory, providing the symbol manager.""" temp_dir = Path(tempfile.mkdtemp()) self.repo_path = temp_dir / self.original_repo_path.name try: print(f"Copying repo from {self.original_repo_path} to {self.repo_path}") shutil.copytree(self.original_repo_path, self.repo_path) # prevent deadlock on Windows due to file locks caused by antivirus or some other external software # wait for a long time here if os.name == "nt": time.sleep(0.1) log.info(f"Creating language server for {self.language} {self.rel_path}") with project_with_ls_context(self.language, str(self.repo_path)) as project: yield LanguageServerSymbolRetriever(project) finally: # prevent deadlock on Windows due to lingering file locks if os.name == "nt": time.sleep(0.1) log.info(f"Removing temp directory {temp_dir}") shutil.rmtree(temp_dir, ignore_errors=True) log.info(f"Temp directory {temp_dir} removed") def _read_file(self, rel_path: str) -> str: """Read the content of a file in the test repository.""" assert self.repo_path is not None file_path = self.repo_path / rel_path with open(file_path, encoding="utf-8") as f: return f.read() def run_test(self, content_after_ground_truth: SnapshotAssertion) -> None: with self._setup() as symbol_retriever: content_before = self._read_file(self.rel_path) code_editor = LanguageServerCodeEditor(symbol_retriever) self._apply_edit(code_editor) content_after = self._read_file(self.rel_path) code_diff = CodeDiff(self.rel_path, original_content=content_before, modified_content=content_after) self._test_diff(code_diff, content_after_ground_truth) @abstractmethod def _apply_edit(self, code_editor: CodeEditor) -> None: pass def _test_diff(self, code_diff: CodeDiff, snapshot: SnapshotAssertion) -> None: assert code_diff.has_changes, f"Sanity check failed: No changes detected in {code_diff.relative_path}" assert code_diff.modified_content == snapshot # Python test file path PYTHON_TEST_REL_FILE_PATH = os.path.join("test_repo", "variables.py") # TypeScript test file path TYPESCRIPT_TEST_FILE = "index.ts" class DeleteSymbolTest(EditingTest): def __init__(self, language: Language, rel_path: str, deleted_symbol: str): super().__init__(language, rel_path) self.deleted_symbol = deleted_symbol self.rel_path = rel_path def _apply_edit(self, code_editor: CodeEditor) -> None: code_editor.delete_symbol(self.deleted_symbol, self.rel_path) @pytest.mark.parametrize( "test_case", [ pytest.param( DeleteSymbolTest( Language.PYTHON, PYTHON_TEST_REL_FILE_PATH, "VariableContainer", ), marks=pytest.mark.python, ), pytest.param( DeleteSymbolTest( Language.TYPESCRIPT, TYPESCRIPT_TEST_FILE, "DemoClass", ), marks=pytest.mark.typescript, ), ], ) def test_delete_symbol(test_case, snapshot: SnapshotAssertion): test_case.run_test(content_after_ground_truth=snapshot) NEW_PYTHON_FUNCTION = """def new_inserted_function(): print("This is a new function inserted before another.")""" NEW_PYTHON_CLASS_WITH_LEADING_NEWLINES = """ class NewInsertedClass: pass """ NEW_PYTHON_CLASS_WITH_TRAILING_NEWLINES = """class NewInsertedClass: pass """ NEW_TYPESCRIPT_FUNCTION = """function newInsertedFunction(): void { console.log("This is a new function inserted before another."); }""" NEW_PYTHON_VARIABLE = 'new_module_var = "Inserted after typed_module_var"' NEW_TYPESCRIPT_FUNCTION_AFTER = """function newFunctionAfterClass(): void { console.log("This function is after DemoClass."); }""" class InsertInRelToSymbolTest(EditingTest): def __init__( self, language: Language, rel_path: str, symbol_name: str, new_content: str, mode: Literal["before", "after"] | None = None ): super().__init__(language, rel_path) self.symbol_name = symbol_name self.new_content = new_content self.mode: Literal["before", "after"] | None = mode def set_mode(self, mode: Literal["before", "after"]): self.mode = mode def _apply_edit(self, code_editor: CodeEditor) -> None: assert self.mode is not None if self.mode == "before": code_editor.insert_before_symbol(self.symbol_name, self.rel_path, self.new_content) elif self.mode == "after": code_editor.insert_after_symbol(self.symbol_name, self.rel_path, self.new_content) @pytest.mark.parametrize("mode", ["before", "after"]) @pytest.mark.parametrize( "test_case", [ pytest.param( InsertInRelToSymbolTest( Language.PYTHON, PYTHON_TEST_REL_FILE_PATH, "typed_module_var", NEW_PYTHON_VARIABLE, ), marks=pytest.mark.python, ), pytest.param( InsertInRelToSymbolTest( Language.PYTHON, PYTHON_TEST_REL_FILE_PATH, "use_module_variables", NEW_PYTHON_FUNCTION, ), marks=pytest.mark.python, ), pytest.param( InsertInRelToSymbolTest( Language.TYPESCRIPT, TYPESCRIPT_TEST_FILE, "DemoClass", NEW_TYPESCRIPT_FUNCTION_AFTER, ), marks=pytest.mark.typescript, ), pytest.param( InsertInRelToSymbolTest( Language.TYPESCRIPT, TYPESCRIPT_TEST_FILE, "helperFunction", NEW_TYPESCRIPT_FUNCTION, ), marks=pytest.mark.typescript, ), ], ) def test_insert_in_rel_to_symbol(test_case: InsertInRelToSymbolTest, mode: Literal["before", "after"], snapshot: SnapshotAssertion): test_case.set_mode(mode) test_case.run_test(content_after_ground_truth=snapshot) @pytest.mark.python def test_insert_python_class_before(snapshot: SnapshotAssertion): InsertInRelToSymbolTest( Language.PYTHON, PYTHON_TEST_REL_FILE_PATH, "VariableDataclass", NEW_PYTHON_CLASS_WITH_TRAILING_NEWLINES, mode="before", ).run_test(snapshot) @pytest.mark.python def test_insert_python_class_after(snapshot: SnapshotAssertion): InsertInRelToSymbolTest( Language.PYTHON, PYTHON_TEST_REL_FILE_PATH, "VariableDataclass", NEW_PYTHON_CLASS_WITH_LEADING_NEWLINES, mode="after", ).run_test(snapshot) PYTHON_REPLACED_BODY = """def modify_instance_var(self): # This body has been replaced self.instance_var = "Replaced!" self.reassignable_instance_var = 999 """ TYPESCRIPT_REPLACED_BODY = """function printValue() { // This body has been replaced console.warn("New value: " + this.value); } """ class ReplaceBodyTest(EditingTest): def __init__(self, language: Language, rel_path: str, symbol_name: str, new_body: str): super().__init__(language, rel_path) self.symbol_name = symbol_name self.new_body = new_body def _apply_edit(self, code_editor: CodeEditor) -> None: code_editor.replace_body(self.symbol_name, self.rel_path, self.new_body) @pytest.mark.parametrize( "test_case", [ pytest.param( ReplaceBodyTest( Language.PYTHON, PYTHON_TEST_REL_FILE_PATH, "VariableContainer/modify_instance_var", PYTHON_REPLACED_BODY, ), marks=pytest.mark.python, ), pytest.param( ReplaceBodyTest( Language.TYPESCRIPT, TYPESCRIPT_TEST_FILE, "DemoClass/printValue", TYPESCRIPT_REPLACED_BODY, ), marks=pytest.mark.typescript, ), ], ) def test_replace_body(test_case: ReplaceBodyTest, snapshot: SnapshotAssertion): # assert "a" in snapshot test_case.run_test(content_after_ground_truth=snapshot) NIX_ATTR_REPLACEMENT = """c = 3;""" class NixAttrReplacementTest(EditingTest): """Test for replacing individual attributes in Nix that should NOT result in double semicolons.""" def __init__(self, language: Language, rel_path: str, symbol_name: str, new_body: str): super().__init__(language, rel_path) self.symbol_name = symbol_name self.new_body = new_body def _apply_edit(self, code_editor: CodeEditor) -> None: code_editor.replace_body(self.symbol_name, self.rel_path, self.new_body) @pytest.mark.nix @pytest.mark.skipif(sys.platform == "win32", reason="nixd language server doesn't run on Windows") def test_nix_symbol_replacement_no_double_semicolon(snapshot: SnapshotAssertion): """ Test that replacing a Nix attribute does not result in double semicolons. This test exercises the bug where: - Original: users.users.example = { isSystemUser = true; group = "example"; description = "Example service user"; }; - Replacement: c = 3; - Bug result would be: c = 3;; (double semicolon) - Correct result should be: c = 3; (single semicolon) The replacement body includes a semicolon, but the language server's range extension logic should prevent double semicolons. """ test_case = NixAttrReplacementTest( Language.NIX, "default.nix", "testUser", # Simple attrset with multiple key-value pairs NIX_ATTR_REPLACEMENT, ) test_case.run_test(content_after_ground_truth=snapshot) class RenameSymbolTest(EditingTest): def __init__(self, language: Language, rel_path: str, symbol_name: str, new_name: str): super().__init__(language, rel_path) self.symbol_name = symbol_name self.new_name = new_name def _apply_edit(self, code_editor: CodeEditor) -> None: code_editor.rename_symbol(self.symbol_name, self.rel_path, self.new_name) @overrides def _test_diff(self, code_diff: CodeDiff, snapshot: SnapshotAssertion) -> None: # sanity check (e.g., for newly generated snapshots) that the new name is actually in the modified content assert self.new_name in code_diff.modified_content, f"New name '{self.new_name}' not found in modified content." return super()._test_diff(code_diff, snapshot) @pytest.mark.python def test_rename_symbol(snapshot: SnapshotAssertion): test_case = RenameSymbolTest( Language.PYTHON, PYTHON_TEST_REL_FILE_PATH, "typed_module_var", "renamed_typed_module_var", ) test_case.run_test(content_after_ground_truth=snapshot) # ===== VUE WRITE OPERATIONS TESTS ===== VUE_TEST_FILE = os.path.join("src", "components", "CalculatorButton.vue") VUE_STORE_FILE = os.path.join("src", "stores", "calculator.ts") NEW_VUE_HANDLER = """const handleDoubleClick = () => { pressCount.value++; emit('click', props.label); }""" @pytest.mark.parametrize( "test_case", [ pytest.param( DeleteSymbolTest( Language.VUE, VUE_TEST_FILE, "handleMouseEnter", ), marks=pytest.mark.vue, ), ], ) def test_delete_symbol_vue(test_case: DeleteSymbolTest, snapshot: SnapshotAssertion) -> None: test_case.run_test(content_after_ground_truth=snapshot) @pytest.mark.parametrize("mode", ["before", "after"]) @pytest.mark.parametrize( "test_case", [ pytest.param( InsertInRelToSymbolTest( Language.VUE, VUE_TEST_FILE, "handleClick", NEW_VUE_HANDLER, ), marks=pytest.mark.vue, ), ], ) def test_insert_in_rel_to_symbol_vue( test_case: InsertInRelToSymbolTest, mode: Literal["before", "after"], snapshot: SnapshotAssertion, ) -> None: test_case.set_mode(mode) test_case.run_test(content_after_ground_truth=snapshot) VUE_REPLACED_HANDLECLICK_BODY = """const handleClick = () => { if (!props.disabled) { pressCount.value = 0; // Reset instead of incrementing emit('click', props.label); } }""" @pytest.mark.parametrize( "test_case", [ pytest.param( ReplaceBodyTest( Language.VUE, VUE_TEST_FILE, "handleClick", VUE_REPLACED_HANDLECLICK_BODY, ), marks=pytest.mark.vue, ), ], ) def test_replace_body_vue(test_case: ReplaceBodyTest, snapshot: SnapshotAssertion) -> None: test_case.run_test(content_after_ground_truth=snapshot) VUE_REPLACED_PRESSCOUNT_BODY = """const pressCount = ref(100)""" @pytest.mark.parametrize( "test_case", [ pytest.param( ReplaceBodyTest( Language.VUE, VUE_TEST_FILE, "pressCount", VUE_REPLACED_PRESSCOUNT_BODY, ), marks=pytest.mark.vue, ), ], ) def test_replace_body_vue_with_disambiguation(test_case: ReplaceBodyTest, snapshot: SnapshotAssertion) -> None: """Test symbol disambiguation when replacing body in Vue files. This test verifies the fix for the Vue LSP symbol duplication issue. When the LSP returns two symbols with the same name (e.g., pressCount appears both as a definition `const pressCount = ref(0)` and as a shorthand property in `defineExpose({ pressCount })`), the _find_unique_symbol method should prefer the symbol with the larger range (the definition). The test exercises this by calling replace_body on 'pressCount', which internally calls _find_unique_symbol and should correctly select the definition (line 40, 19 chars) over the reference (line 97, 10 chars). """ test_case.run_test(content_after_ground_truth=snapshot) VUE_STORE_REPLACED_CLEAR_BODY = """function clear() { // Modified: Reset to initial state with a log console.log('Clearing calculator state'); displayValue.value = '0'; expression.value = ''; operationHistory.value = []; lastResult.value = undefined; }""" @pytest.mark.parametrize( "test_case", [ pytest.param( ReplaceBodyTest( Language.VUE, VUE_STORE_FILE, "clear", VUE_STORE_REPLACED_CLEAR_BODY, ), marks=pytest.mark.vue, ), ], ) def test_replace_body_vue_ts_file(test_case: ReplaceBodyTest, snapshot: SnapshotAssertion) -> None: """Test that TypeScript files within Vue projects can be edited.""" test_case.run_test(content_after_ground_truth=snapshot) ================================================ FILE: test/serena/test_task_executor.py ================================================ import time import pytest from serena.task_executor import TaskExecutor @pytest.fixture def executor(): """ Fixture for a basic SerenaAgent without a project """ return TaskExecutor("TestExecutor") class Task: def __init__(self, delay: float, exception: bool = False): self.delay = delay self.exception = exception self.did_run = False def run(self): self.did_run = True time.sleep(self.delay) if self.exception: raise ValueError("Task failed") return True def test_task_executor_sequence(executor): """ Tests that a sequence of tasks is executed correctly """ future1 = executor.issue_task(Task(1).run, name="task1") future2 = executor.issue_task(Task(1).run, name="task2") assert future1.result() is True assert future2.result() is True def test_task_executor_exception(executor): """ Tests that tasks that raise exceptions are handled correctly, i.e. that * the exception is propagated, * subsequent tasks are still executed. """ future1 = executor.issue_task(Task(1, exception=True).run, name="task1") future2 = executor.issue_task(Task(1).run, name="task2") have_exception = False try: assert future1.result() except Exception as e: assert isinstance(e, ValueError) have_exception = True assert have_exception assert future2.result() is True def test_task_executor_cancel_current(executor): """ Tests that tasks that are cancelled are handled correctly, i.e. that * subsequent tasks are executed as soon as cancellation ensues. * the cancelled task raises CancelledError when result() is called. """ start_time = time.time() future1 = executor.issue_task(Task(10).run, name="task1") future2 = executor.issue_task(Task(1).run, name="task2") time.sleep(1) future1.cancel() assert future2.result() is True end_time = time.time() assert (end_time - start_time) < 9, "Cancelled task did not stop in time" have_cancelled_error = False try: future1.result() except Exception as e: assert e.__class__.__name__ == "CancelledError" have_cancelled_error = True assert have_cancelled_error def test_task_executor_cancel_future(executor): """ Tests that when a future task is cancelled, it is never run at all """ task1 = Task(10) task2 = Task(1) future1 = executor.issue_task(task1.run, name="task1") future2 = executor.issue_task(task2.run, name="task2") time.sleep(1) future2.cancel() future1.cancel() try: future2.result() except: pass assert task1.did_run assert not task2.did_run def test_task_executor_cancellation_via_task_info(executor): start_time = time.time() executor.issue_task(Task(10).run, "task1") executor.issue_task(Task(10).run, "task2") task_infos = executor.get_current_tasks() task_infos2 = executor.get_current_tasks() # test expected tasks assert len(task_infos) == 2 assert "task1" in task_infos[0].name assert "task2" in task_infos[1].name # test task identifiers being stable assert task_infos2[0].task_id == task_infos[0].task_id # test cancellation task_infos[0].cancel() time.sleep(0.5) task_infos3 = executor.get_current_tasks() assert len(task_infos3) == 1 # Cancelled task is gone from the queue task_infos3[0].cancel() try: task_infos3[0].future.result() except: pass end_time = time.time() assert (end_time - start_time) < 9, "Cancelled task did not stop in time" ================================================ FILE: test/serena/test_text_utils.py ================================================ import re import pytest from serena.util.text_utils import LineType, search_files, search_text class TestSearchText: def test_search_text_with_string_pattern(self): """Test searching with a simple string pattern.""" content = """ def hello_world(): print("Hello, World!") return 42 """ # Search for a simple string pattern matches = search_text("print", content=content) assert len(matches) == 1 assert matches[0].num_matched_lines == 1 assert matches[0].start_line == 3 assert matches[0].end_line == 3 assert matches[0].lines[0].line_content.strip() == 'print("Hello, World!")' def test_search_text_with_regex_pattern(self): """Test searching with a regex pattern.""" content = """ class DataProcessor: def __init__(self, data): self.data = data def process(self): return [x * 2 for x in self.data if x > 0] def filter(self, predicate): return [x for x in self.data if predicate(x)] """ # Search for a regex pattern matching method definitions pattern = r"def\s+\w+\s*\([^)]*\):" matches = search_text(pattern, content=content) assert len(matches) == 3 assert matches[0].lines[0].match_type == LineType.MATCH assert "def __init__" in matches[0].lines[0].line_content assert "def process" in matches[1].lines[0].line_content assert "def filter" in matches[2].lines[0].line_content def test_search_text_with_compiled_regex(self): """Test searching with a pre-compiled regex pattern.""" content = """ import os import sys from pathlib import Path # Configuration variables DEBUG = True MAX_RETRIES = 3 def configure_logging(): log_level = "DEBUG" if DEBUG else "INFO" print(f"Setting log level to {log_level}") """ # Search for variable assignments with a compiled regex pattern = re.compile(r"^\s*[A-Z_]+ = .+$") matches = search_text(pattern, content=content) assert len(matches) == 2 assert "DEBUG = True" in matches[0].lines[0].line_content assert "MAX_RETRIES = 3" in matches[1].lines[0].line_content def test_search_text_with_context_lines(self): """Test searching with context lines before and after the match.""" content = """ def complex_function(a, b, c): # This is a complex function that does something. if a > b: return a * c elif b > a: return b * c else: return (a + b) * c """ # Search with context lines matches = search_text("return", content=content, context_lines_before=1, context_lines_after=1) assert len(matches) == 3 # Check the first match with context first_match = matches[0] assert len(first_match.lines) == 3 assert first_match.lines[0].match_type == LineType.BEFORE_MATCH assert first_match.lines[1].match_type == LineType.MATCH assert first_match.lines[2].match_type == LineType.AFTER_MATCH # Verify the content of lines assert "if a > b:" in first_match.lines[0].line_content assert "return a * c" in first_match.lines[1].line_content assert "elif b > a:" in first_match.lines[2].line_content def test_search_text_with_multiline_match(self): """Test searching with multiline pattern matching.""" content = """ def factorial(n): if n <= 1: return 1 else: return n * factorial(n-1) result = factorial(5) # Should be 120 """ # Search for a pattern that spans multiple lines (if-else block) pattern = r"if.*?else.*?return" matches = search_text(pattern, content=content, allow_multiline_match=True) assert len(matches) == 1 multiline_match = matches[0] assert multiline_match.num_matched_lines >= 3 assert "if n <= 1:" in multiline_match.lines[0].line_content # All matched lines should have match_type == LineType.MATCH match_lines = [line for line in multiline_match.lines if line.match_type == LineType.MATCH] assert len(match_lines) >= 3 def test_search_text_with_glob_pattern(self): """Test searching with glob-like patterns.""" content = """ class UserService: def get_user(self, user_id): return {"id": user_id, "name": "Test User"} def create_user(self, user_data): print(f"Creating user: {user_data}") return {"id": 123, **user_data} def update_user(self, user_id, user_data): print(f"Updating user {user_id} with {user_data}") return True """ # Search with a glob pattern for all user methods matches = search_text("*_user*", content=content, is_glob=True) assert len(matches) == 3 assert "get_user" in matches[0].lines[0].line_content assert "create_user" in matches[1].lines[0].line_content assert "update_user" in matches[2].lines[0].line_content def test_search_text_with_complex_glob_pattern(self): """Test searching with more complex glob patterns.""" content = """ def process_data(data): return [transform(item) for item in data] def transform(item): if isinstance(item, dict): return {k: v.upper() if isinstance(v, str) else v for k, v in item.items()} elif isinstance(item, list): return [x * 2 for x in item if isinstance(x, (int, float))] elif isinstance(item, str): return item.upper() else: return item """ # Search with a simplified glob pattern to find all isinstance occurrences matches = search_text("*isinstance*", content=content, is_glob=True) # Should match lines with isinstance(item, dict) and isinstance(item, list) assert len(matches) >= 2 instance_matches = [ line.line_content for match in matches for line in match.lines if line.match_type == LineType.MATCH and "isinstance(item," in line.line_content ] assert len(instance_matches) >= 2 assert any("isinstance(item, dict)" in line for line in instance_matches) assert any("isinstance(item, list)" in line for line in instance_matches) def test_search_text_glob_with_special_chars(self): """Glob patterns containing regex special characters should match literally.""" content = """ def func_square(): print("value[42]") def func_curly(): print("value{bar}") """ matches_square = search_text(r"*\[42\]*", content=content, is_glob=True) assert len(matches_square) == 1 assert "[42]" in matches_square[0].lines[0].line_content matches_curly = search_text("*{bar}*", content=content, is_glob=True) assert len(matches_curly) == 1 assert "{bar}" in matches_curly[0].lines[0].line_content def test_search_text_no_matches(self): """Test searching with a pattern that doesn't match anything.""" content = """ def calculate_average(numbers): if not numbers: return 0 return sum(numbers) / len(numbers) """ # Search for a pattern that doesn't exist in the content matches = search_text("missing_function", content=content) assert len(matches) == 0 # Mock file reader that always returns matching content def mock_reader_always_match(file_path: str) -> str: """Mock file reader that returns content guaranteed to match the simple pattern.""" return "This line contains a match." class TestSearchFiles: @pytest.mark.parametrize( "file_paths, pattern, paths_include_glob, paths_exclude_glob, expected_matched_files, description", [ # Basic cases (["a.py", "b.txt"], "match", None, None, ["a.py", "b.txt"], "No filters"), (["a.py", "b.txt"], "match", "*.py", None, ["a.py"], "Include only .py files"), (["a.py", "b.txt"], "match", None, "*.txt", ["a.py"], "Exclude .txt files"), (["a.py", "b.txt", "c.py"], "match", "*.py", "c.*", ["a.py"], "Include .py, exclude c.*"), # Directory matching - Using pathspec patterns (["main.c", "test/main.c"], "match", "test/*", None, ["test/main.c"], "Include files in test/ subdir"), (["data/a.csv", "data/b.log"], "match", "data/*", "*.log", ["data/a.csv"], "Include data/*, exclude *.log"), (["src/a.py", "tests/b.py"], "match", "src/**", "tests/**", ["src/a.py"], "Include src/**, exclude tests/**"), (["src/mod/a.py", "tests/b.py"], "match", "**/*.py", "tests/**", ["src/mod/a.py"], "Include **/*.py, exclude tests/**"), (["file.py", "dir/file.py"], "match", "dir/*.py", None, ["dir/file.py"], "Include files directly in dir"), (["file.py", "dir/sub/file.py"], "match", "dir/**/*.py", None, ["dir/sub/file.py"], "Include files recursively in dir"), # Overlap and edge cases (["file.py", "dir/file.py"], "match", "*.py", "dir/*", ["file.py"], "Include *.py, exclude files directly in dir"), (["root.py", "adir/a.py", "bdir/b.py"], "match", "a*/*.py", None, ["adir/a.py"], "Include files in dirs starting with 'a'"), (["a.txt", "b.log"], "match", "*.py", None, [], "No files match include pattern"), (["a.py", "b.py"], "match", None, "*.py", [], "All files match exclude pattern"), (["a.py", "b.py"], "match", "a.*", "*.py", [], "Include a.* but exclude *.py -> empty"), (["a.py", "b.py"], "match", "*.py", "b.*", ["a.py"], "Include *.py but exclude b.* -> a.py"), ], ids=lambda x: x if isinstance(x, str) else "", # Use description as test ID ) def test_search_files_include_exclude( self, file_paths, pattern, paths_include_glob, paths_exclude_glob, expected_matched_files, description ): """ Test the include/exclude glob filtering logic in search_files using PathSpec patterns. """ results = search_files( relative_file_paths=file_paths, pattern=pattern, file_reader=mock_reader_always_match, paths_include_glob=paths_include_glob, paths_exclude_glob=paths_exclude_glob, context_lines_before=0, # No context needed for this test focus context_lines_after=0, ) # Extract the source file paths from the results actual_matched_files = sorted([result.source_file_path for result in results if result.source_file_path]) # Assert that the matched files are exactly the ones expected assert actual_matched_files == sorted(expected_matched_files) # Basic check on results structure if files were expected if expected_matched_files: assert len(results) == len(expected_matched_files) for result in results: assert len(result.matched_lines) == 1 # Mock reader returns one matching line assert result.matched_lines[0].line_content == "This line contains a match." assert result.matched_lines[0].match_type == LineType.MATCH @pytest.mark.parametrize( "file_paths, pattern, paths_include_glob, paths_exclude_glob, expected_matched_files, description", [ # Glob patterns that were problematic with gitignore syntax ( ["src/serena/agent.py", "src/serena/process_isolated_agent.py", "test/agent.py"], "match", "src/**agent.py", None, ["src/serena/agent.py", "src/serena/process_isolated_agent.py"], "Glob: src/**agent.py should match files ending with agent.py under src/", ), ( ["src/serena/agent.py", "src/serena/process_isolated_agent.py", "other/agent.py"], "match", "**agent.py", None, ["src/serena/agent.py", "src/serena/process_isolated_agent.py", "other/agent.py"], "Glob: **agent.py should match files ending with agent.py anywhere", ), ( ["dir/subdir/file.py", "dir/other/file.py", "elsewhere/file.py"], "match", "dir/**file.py", None, ["dir/subdir/file.py", "dir/other/file.py"], "Glob: dir/**file.py should match files ending with file.py under dir/", ), ( ["src/a/b/c/test.py", "src/x/test.py", "other/test.py"], "match", "src/**/test.py", None, ["src/a/b/c/test.py", "src/x/test.py"], "Glob: src/**/test.py should match test.py files under src/ at any depth", ), # Edge cases for ** patterns ( ["agent.py", "src/agent.py", "src/serena/agent.py"], "match", "**agent.py", None, ["agent.py", "src/agent.py", "src/serena/agent.py"], "Glob: **agent.py should match at root and any depth", ), (["file.txt", "src/file.txt"], "match", "src/**", None, ["src/file.txt"], "Glob: src/** should match everything under src/"), ], ids=lambda x: x if isinstance(x, str) else "", # Use description as test ID ) def test_search_files_glob_patterns( self, file_paths, pattern, paths_include_glob, paths_exclude_glob, expected_matched_files, description ): """ Test glob patterns that were problematic with the previous gitignore-based implementation. """ results = search_files( relative_file_paths=file_paths, pattern=pattern, file_reader=mock_reader_always_match, paths_include_glob=paths_include_glob, paths_exclude_glob=paths_exclude_glob, context_lines_before=0, context_lines_after=0, ) # Extract the source file paths from the results actual_matched_files = sorted([result.source_file_path for result in results if result.source_file_path]) # Assert that the matched files are exactly the ones expected assert actual_matched_files == sorted( expected_matched_files ), f"Pattern '{paths_include_glob}' failed: expected {sorted(expected_matched_files)}, got {actual_matched_files}" # Basic check on results structure if files were expected if expected_matched_files: assert len(results) == len(expected_matched_files) for result in results: assert len(result.matched_lines) == 1 # Mock reader returns one matching line assert result.matched_lines[0].line_content == "This line contains a match." assert result.matched_lines[0].match_type == LineType.MATCH @pytest.mark.parametrize( "file_paths, pattern, paths_include_glob, paths_exclude_glob, expected_matched_files, description", [ # Brace expansion in include glob ( ["a.py", "b.js", "c.txt"], "match", "*.{py,js}", None, ["a.py", "b.js"], "Brace expansion in include glob", ), # Brace expansion in exclude glob ( ["a.py", "b.log", "c.txt"], "match", "*.{py,log,txt}", "*.{log,txt}", ["a.py"], "Brace expansion in exclude glob", ), # Brace expansion in both include and exclude ( ["src/a.ts", "src/b.js", "test/a.ts", "test/b.js"], "match", "**/*.{ts,js}", "test/**/*.{ts,js}", ["src/a.ts", "src/b.js"], "Brace expansion in both include and exclude", ), # No matching files with brace expansion ( ["a.py", "b.js"], "match", "*.{c,h}", None, [], "Brace expansion with no matching files", ), # Multiple brace expansions ( ["src/a/a.py", "src/b/b.py", "lib/a/a.py", "lib/b/b.py"], "match", "{src,lib}/{a,b}/*.py", "lib/b/*.py", ["src/a/a.py", "src/b/b.py", "lib/a/a.py"], "Multiple brace expansions in include/exclude", ), ], ids=lambda x: x if isinstance(x, str) else "", ) def test_search_files_with_brace_expansion( self, file_paths, pattern, paths_include_glob, paths_exclude_glob, expected_matched_files, description ): """Test search_files with glob patterns containing brace expansions.""" results = search_files( relative_file_paths=file_paths, pattern=pattern, file_reader=mock_reader_always_match, paths_include_glob=paths_include_glob, paths_exclude_glob=paths_exclude_glob, ) actual_matched_files = sorted([result.source_file_path for result in results if result.source_file_path]) assert actual_matched_files == sorted(expected_matched_files), f"Test failed: {description}" def test_search_files_no_pattern_match_in_content(self): """Test that no results are returned if the pattern doesn't match the file content, even if files pass filters.""" file_paths = ["a.py", "b.txt"] pattern = "non_existent_pattern_in_mock_content" # This won't match mock_reader_always_match content results = search_files( relative_file_paths=file_paths, pattern=pattern, file_reader=mock_reader_always_match, # Content is "This line contains a match." paths_include_glob=None, # Both files would pass filters paths_exclude_glob=None, ) assert len(results) == 0, "Should not find matches if pattern doesn't match content" def test_search_files_regex_pattern_with_filters(self): """Test using a regex pattern works correctly along with include/exclude filters.""" def specific_mock_reader(file_path: str) -> str: # Provide different content for different files to test regex matching if file_path == "a.py": # noqa: SIM116 return "File A: value=123\nFile A: value=456" elif file_path == "b.py": return "File B: value=789" elif file_path == "c.txt": return "File C: value=000" return "No values here." file_paths = ["a.py", "b.py", "c.txt"] pattern = r"value=(\d+)" results = search_files( relative_file_paths=file_paths, pattern=pattern, file_reader=specific_mock_reader, paths_include_glob="*.py", # Only include .py files paths_exclude_glob="b.*", # Exclude files starting with b ) # Expected: a.py included, b.py excluded by glob, c.txt excluded by glob # a.py has two matches for the regex pattern assert len(results) == 2, "Expected 2 matches only from a.py" actual_matched_files = sorted([result.source_file_path for result in results if result.source_file_path]) assert actual_matched_files == ["a.py", "a.py"], "Both matches should be from a.py" # Check the content of the matched lines assert results[0].matched_lines[0].line_content == "File A: value=123" assert results[1].matched_lines[0].line_content == "File A: value=456" def test_search_files_context_lines_with_filters(self): """Test context lines are included correctly when filters are active.""" def context_mock_reader(file_path: str) -> str: if file_path == "include_me.txt": return "Line before 1\nLine before 2\nMATCH HERE\nLine after 1\nLine after 2" elif file_path == "exclude_me.log": return "Noise\nMATCH HERE\nNoise" return "No match" file_paths = ["include_me.txt", "exclude_me.log"] pattern = "MATCH HERE" results = search_files( relative_file_paths=file_paths, pattern=pattern, file_reader=context_mock_reader, paths_include_glob="*.txt", # Only include .txt files paths_exclude_glob=None, context_lines_before=1, context_lines_after=1, ) # Expected: Only include_me.txt should be processed and matched assert len(results) == 1, "Expected only one result from the included file" result = results[0] assert result.source_file_path == "include_me.txt" assert len(result.lines) == 3, "Expected 3 lines (1 before, 1 match, 1 after)" assert result.lines[0].line_content == "Line before 2", "Incorrect 'before' context line" assert result.lines[0].match_type == LineType.BEFORE_MATCH assert result.lines[1].line_content == "MATCH HERE", "Incorrect 'match' line" assert result.lines[1].match_type == LineType.MATCH assert result.lines[2].line_content == "Line after 1", "Incorrect 'after' context line" assert result.lines[2].match_type == LineType.AFTER_MATCH class TestGlobMatch: """Test the glob_match function directly.""" @pytest.mark.parametrize( "pattern, path, expected", [ # Basic wildcard patterns ("*.py", "file.py", True), ("*.py", "file.txt", False), ("*agent.py", "agent.py", True), ("*agent.py", "process_isolated_agent.py", True), ("*agent.py", "agent_test.py", False), # Double asterisk patterns ("**agent.py", "agent.py", True), ("**agent.py", "src/agent.py", True), ("**agent.py", "src/serena/agent.py", True), ("**agent.py", "src/serena/process_isolated_agent.py", True), ("**agent.py", "agent_test.py", False), # Prefix with double asterisk ("src/**agent.py", "src/agent.py", True), ("src/**agent.py", "src/serena/agent.py", True), ("src/**agent.py", "src/serena/process_isolated_agent.py", True), ("src/**agent.py", "other/agent.py", False), ("src/**agent.py", "src/agent_test.py", False), # Directory patterns ("src/**", "src/file.py", True), ("src/**", "src/dir/file.py", True), ("src/**", "other/file.py", False), # Exact matches with double asterisk ("src/**/test.py", "src/test.py", True), ("src/**/test.py", "src/a/b/test.py", True), ("src/**/test.py", "src/test_file.py", False), # Simple patterns without asterisks ("src/file.py", "src/file.py", True), ("src/file.py", "src/other.py", False), ], ) def test_glob_match(self, pattern, path, expected): """Test glob_match function with various patterns.""" from serena.util.text_utils import glob_match assert glob_match(pattern, path) == expected class TestExpandBraces: """Test the expand_braces function.""" @pytest.mark.parametrize( "pattern, expected", [ # Basic case ("src/*.{js,ts}", ["src/*.js", "src/*.ts"]), # No braces ("src/*.py", ["src/*.py"]), # Multiple brace sets ("src/{a,b}/{c,d}.py", ["src/a/c.py", "src/a/d.py", "src/b/c.py", "src/b/d.py"]), # Empty string ("", [""]), # Braces with empty elements ("src/{a,,b}.py", ["src/a.py", "src/.py", "src/b.py"]), # No commas ("src/{a}.py", ["src/a.py"]), ], ) def test_expand_braces(self, pattern, expected): """Test brace expansion for glob patterns.""" from serena.util.text_utils import expand_braces assert sorted(expand_braces(pattern)) == sorted(expected) ================================================ FILE: test/serena/test_tool_parameter_types.py ================================================ import logging import pytest from serena.config.serena_config import SerenaConfig from serena.mcp import SerenaMCPFactory from serena.tools.tools_base import ToolRegistry @pytest.mark.parametrize("context", ("chatgpt", "codex", "oaicompat-agent")) def test_all_tool_parameters_have_type(context): """ For every tool exposed by Serena, ensure that the generated Open‑AI schema contains a ``type`` entry for each parameter. """ cfg = SerenaConfig(gui_log_window=False, web_dashboard=False, log_level=logging.ERROR) registry = ToolRegistry() cfg.included_optional_tools = tuple(registry.get_tool_names_optional()) factory = SerenaMCPFactory(context=context) # Initialize the agent so that the tools are available factory.agent = factory._create_serena_agent(cfg) tools = list(factory._iter_tools()) for tool in tools: mcp_tool = factory.make_mcp_tool(tool, openai_tool_compatible=True) params = mcp_tool.parameters # Collect any parameter that lacks a type issues = [] print(f"Checking tool {tool}") if "properties" not in params: issues.append(f"Tool {tool.get_name()!r} missing properties section") else: for pname, prop in params["properties"].items(): if "type" not in prop: issues.append(f"Tool {tool.get_name()!r} parameter {pname!r} missing 'type'") if issues: raise AssertionError("\n".join(issues)) ================================================ FILE: test/serena/util/test_exception.py ================================================ import os from unittest.mock import MagicMock, Mock, patch import pytest from serena.util.exception import is_headless_environment, show_fatal_exception_safe class TestHeadlessEnvironmentDetection: """Test class for headless environment detection functionality.""" def test_is_headless_no_display(self): """Test that environment without DISPLAY is detected as headless on Linux.""" with patch("sys.platform", "linux"): with patch.dict(os.environ, {}, clear=True): assert is_headless_environment() is True def test_is_headless_ssh_connection(self): """Test that SSH sessions are detected as headless.""" with patch("sys.platform", "linux"): with patch.dict(os.environ, {"SSH_CONNECTION": "192.168.1.1 22 192.168.1.2 22", "DISPLAY": ":0"}): assert is_headless_environment() is True with patch.dict(os.environ, {"SSH_CLIENT": "192.168.1.1 22 22", "DISPLAY": ":0"}): assert is_headless_environment() is True def test_is_headless_wsl(self): """Test that WSL environment is detected as headless.""" # Skip this test on Windows since os.uname doesn't exist if not hasattr(os, "uname"): pytest.skip("os.uname not available on this platform") with patch("sys.platform", "linux"): with patch("os.uname") as mock_uname: mock_uname.return_value = Mock(release="5.15.153.1-microsoft-standard-WSL2") with patch.dict(os.environ, {"DISPLAY": ":0"}): assert is_headless_environment() is True def test_is_headless_docker(self): """Test that Docker containers are detected as headless.""" with patch("sys.platform", "linux"): # Test with CI environment variable with patch.dict(os.environ, {"CI": "true", "DISPLAY": ":0"}): assert is_headless_environment() is True # Test with CONTAINER environment variable with patch.dict(os.environ, {"CONTAINER": "docker", "DISPLAY": ":0"}): assert is_headless_environment() is True # Test with .dockerenv file with patch("os.path.exists") as mock_exists: mock_exists.return_value = True with patch.dict(os.environ, {"DISPLAY": ":0"}): assert is_headless_environment() is True def test_is_not_headless_windows(self): """Test that Windows is never detected as headless.""" with patch("sys.platform", "win32"): # Even without DISPLAY, Windows should not be headless with patch.dict(os.environ, {}, clear=True): assert is_headless_environment() is False class TestShowFatalExceptionSafe: """Test class for safe fatal exception display functionality.""" @patch("serena.util.exception.is_headless_environment", return_value=True) @patch("serena.util.exception.log") def test_show_fatal_exception_safe_headless(self, mock_log, mock_is_headless): """Test that GUI is not attempted in headless environment.""" test_exception = ValueError("Test error") # The import should never happen in headless mode with patch("serena.gui_log_viewer.show_fatal_exception") as mock_show_gui: show_fatal_exception_safe(test_exception) mock_show_gui.assert_not_called() # Verify debug log about skipping GUI mock_log.debug.assert_called_once_with("Skipping GUI error display in headless environment") @patch("serena.util.exception.is_headless_environment", return_value=False) @patch("serena.util.exception.log") def test_show_fatal_exception_safe_with_gui(self, mock_log, mock_is_headless): """Test that GUI is attempted when not in headless environment.""" test_exception = ValueError("Test error") # Mock the GUI function with patch("serena.gui_log_viewer.show_fatal_exception") as mock_show_gui: show_fatal_exception_safe(test_exception) mock_show_gui.assert_called_once_with(test_exception) @patch("serena.util.exception.is_headless_environment", return_value=False) @patch("serena.util.exception.log") def test_show_fatal_exception_safe_gui_failure(self, mock_log, mock_is_headless): """Test graceful handling when GUI display fails.""" test_exception = ValueError("Test error") gui_error = ImportError("No module named 'tkinter'") # Mock the GUI function to raise an exception with patch("serena.gui_log_viewer.show_fatal_exception", side_effect=gui_error): show_fatal_exception_safe(test_exception) # Verify debug log about GUI failure mock_log.debug.assert_called_with(f"Failed to show GUI error dialog: {gui_error}") def test_show_fatal_exception_safe_prints_to_stderr(self): """Test that exceptions are always printed to stderr.""" test_exception = ValueError("Test error message") with patch("sys.stderr", new_callable=MagicMock) as mock_stderr: with patch("serena.util.exception.is_headless_environment", return_value=True): with patch("serena.util.exception.log"): show_fatal_exception_safe(test_exception) # Verify print was called with the correct arguments mock_stderr.write.assert_any_call("Fatal exception: Test error message") ================================================ FILE: test/serena/util/test_file_system.py ================================================ import os import shutil import tempfile from pathlib import Path # Assuming the gitignore parser code is in a module named 'gitignore_parser' from serena.util.file_system import GitignoreParser, GitignoreSpec class TestGitignoreParser: """Test class for GitignoreParser functionality.""" def setup_method(self): """Set up test environment before each test method.""" # Create a temporary directory for testing self.test_dir = tempfile.mkdtemp() self.repo_path = Path(self.test_dir) # Create test repository structure self._create_repo_structure() def teardown_method(self): """Clean up test environment after each test method.""" # Remove the temporary directory shutil.rmtree(self.test_dir) def _create_repo_structure(self): """ Create a test repository structure with multiple gitignore files. Structure: repo/ ├── .gitignore ├── file1.txt ├── test.log ├── src/ │ ├── .gitignore │ ├── main.py │ ├── test.log │ ├── build/ │ │ └── output.o │ └── lib/ │ ├── .gitignore │ └── cache.tmp └── docs/ ├── .gitignore ├── api.md └── temp/ └── draft.md """ # Create directories (self.repo_path / "src").mkdir() (self.repo_path / "src" / "build").mkdir() (self.repo_path / "src" / "lib").mkdir() (self.repo_path / "docs").mkdir() (self.repo_path / "docs" / "temp").mkdir() # Create files (self.repo_path / "file1.txt").touch() (self.repo_path / "test.log").touch() (self.repo_path / "src" / "main.py").touch() (self.repo_path / "src" / "test.log").touch() (self.repo_path / "src" / "build" / "output.o").touch() (self.repo_path / "src" / "lib" / "cache.tmp").touch() (self.repo_path / "docs" / "api.md").touch() (self.repo_path / "docs" / "temp" / "draft.md").touch() # Create root .gitignore root_gitignore = self.repo_path / ".gitignore" root_gitignore.write_text( """# Root gitignore *.log /build/ """ ) # Create src/.gitignore src_gitignore = self.repo_path / "src" / ".gitignore" src_gitignore.write_text( """# Source gitignore *.o build/ !important.log """ ) # Create src/lib/.gitignore (deeply nested) src_lib_gitignore = self.repo_path / "src" / "lib" / ".gitignore" src_lib_gitignore.write_text( """# Library gitignore *.tmp *.cache """ ) # Create docs/.gitignore docs_gitignore = self.repo_path / "docs" / ".gitignore" docs_gitignore.write_text( """# Docs gitignore temp/ *.tmp """ ) def test_initialization(self): """Test GitignoreParser initialization.""" parser = GitignoreParser(str(self.repo_path)) assert parser.repo_root == str(self.repo_path.absolute()) assert len(parser.get_ignore_specs()) == 4 def test_find_gitignore_files(self): """Test finding all gitignore files in repository, including deeply nested ones.""" parser = GitignoreParser(str(self.repo_path)) # Get file paths from specs gitignore_files = [spec.file_path for spec in parser.get_ignore_specs()] # Convert to relative paths for easier testing rel_paths = [os.path.relpath(f, self.repo_path) for f in gitignore_files] rel_paths.sort() assert len(rel_paths) == 4 assert ".gitignore" in rel_paths assert os.path.join("src", ".gitignore") in rel_paths assert os.path.join("src", "lib", ".gitignore") in rel_paths # Deeply nested assert os.path.join("docs", ".gitignore") in rel_paths def test_parse_patterns_root_directory(self): """Test parsing gitignore patterns in root directory.""" # Create a simple test case with only root gitignore test_dir = self.repo_path / "test_root" test_dir.mkdir() gitignore = test_dir / ".gitignore" gitignore.write_text( """*.log build/ /temp.txt """ ) parser = GitignoreParser(str(test_dir)) specs = parser.get_ignore_specs() assert len(specs) == 1 patterns = specs[0].patterns assert "*.log" in patterns assert "build/" in patterns assert "/temp.txt" in patterns def test_parse_patterns_subdirectory(self): """Test parsing gitignore patterns in subdirectory.""" # Create a test case with subdirectory gitignore test_dir = self.repo_path / "test_sub" test_dir.mkdir() subdir = test_dir / "src" subdir.mkdir() gitignore = subdir / ".gitignore" gitignore.write_text( """*.o /build/ test.log """ ) parser = GitignoreParser(str(test_dir)) specs = parser.get_ignore_specs() assert len(specs) == 1 patterns = specs[0].patterns # Non-anchored pattern should get ** prefix assert "src/**/*.o" in patterns # Anchored pattern should not get ** prefix assert "src/build/" in patterns # Non-anchored pattern without slash assert "src/**/test.log" in patterns def test_should_ignore_root_patterns(self): """Test ignoring files based on root .gitignore.""" parser = GitignoreParser(str(self.repo_path)) # Files that should be ignored assert parser.should_ignore("test.log") assert parser.should_ignore(str(self.repo_path / "test.log")) # Files that should NOT be ignored assert not parser.should_ignore("file1.txt") assert not parser.should_ignore("src/main.py") def test_should_ignore_subdirectory_patterns(self): """Test ignoring files based on subdirectory .gitignore files.""" parser = GitignoreParser(str(self.repo_path)) # .o files in src should be ignored assert parser.should_ignore("src/build/output.o") # build/ directory in src should be ignored assert parser.should_ignore("src/build/") # temp/ directory in docs should be ignored assert parser.should_ignore("docs/temp/draft.md") # But temp/ outside docs should not be ignored by docs/.gitignore assert not parser.should_ignore("temp/file.txt") # Test deeply nested .gitignore in src/lib/ # .tmp files in src/lib should be ignored assert parser.should_ignore("src/lib/cache.tmp") # .cache files in src/lib should also be ignored assert parser.should_ignore("src/lib/data.cache") # But .tmp files outside src/lib should not be ignored by src/lib/.gitignore assert not parser.should_ignore("src/other.tmp") def test_anchored_vs_non_anchored_patterns(self): """Test the difference between anchored and non-anchored patterns.""" # Create new test structure test_dir = self.repo_path / "test_anchored" test_dir.mkdir() (test_dir / "src").mkdir() (test_dir / "src" / "subdir").mkdir() (test_dir / "src" / "subdir" / "deep").mkdir() # Create src/.gitignore with both anchored and non-anchored patterns gitignore = test_dir / "src" / ".gitignore" gitignore.write_text( """/temp.txt data.json """ ) # Create test files (test_dir / "src" / "temp.txt").touch() (test_dir / "src" / "data.json").touch() (test_dir / "src" / "subdir" / "temp.txt").touch() (test_dir / "src" / "subdir" / "data.json").touch() (test_dir / "src" / "subdir" / "deep" / "data.json").touch() parser = GitignoreParser(str(test_dir)) # Anchored pattern /temp.txt should only match in src/ assert parser.should_ignore("src/temp.txt") assert not parser.should_ignore("src/subdir/temp.txt") # Non-anchored pattern data.json should match anywhere under src/ assert parser.should_ignore("src/data.json") assert parser.should_ignore("src/subdir/data.json") assert parser.should_ignore("src/subdir/deep/data.json") def test_root_anchored_patterns(self): """Test anchored patterns in root .gitignore only match root-level files.""" # Create new test structure for root anchored patterns test_dir = self.repo_path / "test_root_anchored" test_dir.mkdir() (test_dir / "src").mkdir() (test_dir / "docs").mkdir() (test_dir / "src" / "nested").mkdir() # Create root .gitignore with anchored patterns gitignore = test_dir / ".gitignore" gitignore.write_text( """/config.json /temp.log /build *.pyc """ ) # Create test files at root level (test_dir / "config.json").touch() (test_dir / "temp.log").touch() (test_dir / "build").mkdir() (test_dir / "file.pyc").touch() # Create same-named files in subdirectories (test_dir / "src" / "config.json").touch() (test_dir / "src" / "temp.log").touch() (test_dir / "src" / "build").mkdir() (test_dir / "src" / "file.pyc").touch() (test_dir / "docs" / "config.json").touch() (test_dir / "docs" / "temp.log").touch() (test_dir / "src" / "nested" / "config.json").touch() (test_dir / "src" / "nested" / "temp.log").touch() (test_dir / "src" / "nested" / "build").mkdir() parser = GitignoreParser(str(test_dir)) # Anchored patterns should only match root-level files assert parser.should_ignore("config.json") assert not parser.should_ignore("src/config.json") assert not parser.should_ignore("docs/config.json") assert not parser.should_ignore("src/nested/config.json") assert parser.should_ignore("temp.log") assert not parser.should_ignore("src/temp.log") assert not parser.should_ignore("docs/temp.log") assert not parser.should_ignore("src/nested/temp.log") assert parser.should_ignore("build") assert not parser.should_ignore("src/build") assert not parser.should_ignore("src/nested/build") # Non-anchored patterns should match everywhere assert parser.should_ignore("file.pyc") assert parser.should_ignore("src/file.pyc") def test_mixed_anchored_and_non_anchored_root_patterns(self): """Test mix of anchored and non-anchored patterns in root .gitignore.""" test_dir = self.repo_path / "test_mixed_patterns" test_dir.mkdir() (test_dir / "app").mkdir() (test_dir / "tests").mkdir() (test_dir / "app" / "modules").mkdir() # Create root .gitignore with mixed patterns gitignore = test_dir / ".gitignore" gitignore.write_text( """/secrets.env /dist/ node_modules/ *.tmp /app/local.config debug.log """ ) # Create test files and directories (test_dir / "secrets.env").touch() (test_dir / "dist").mkdir() (test_dir / "node_modules").mkdir() (test_dir / "file.tmp").touch() (test_dir / "app" / "local.config").touch() (test_dir / "debug.log").touch() # Create same files in subdirectories (test_dir / "app" / "secrets.env").touch() (test_dir / "app" / "dist").mkdir() (test_dir / "app" / "node_modules").mkdir() (test_dir / "app" / "file.tmp").touch() (test_dir / "app" / "debug.log").touch() (test_dir / "tests" / "secrets.env").touch() (test_dir / "tests" / "node_modules").mkdir() (test_dir / "tests" / "debug.log").touch() (test_dir / "app" / "modules" / "local.config").touch() parser = GitignoreParser(str(test_dir)) # Anchored patterns should only match at root assert parser.should_ignore("secrets.env") assert not parser.should_ignore("app/secrets.env") assert not parser.should_ignore("tests/secrets.env") assert parser.should_ignore("dist") assert not parser.should_ignore("app/dist") assert parser.should_ignore("app/local.config") assert not parser.should_ignore("app/modules/local.config") # Non-anchored patterns should match everywhere assert parser.should_ignore("node_modules") assert parser.should_ignore("app/node_modules") assert parser.should_ignore("tests/node_modules") assert parser.should_ignore("file.tmp") assert parser.should_ignore("app/file.tmp") assert parser.should_ignore("debug.log") assert parser.should_ignore("app/debug.log") assert parser.should_ignore("tests/debug.log") def test_negation_patterns(self): """Test negation patterns are parsed correctly.""" test_dir = self.repo_path / "test_negation" test_dir.mkdir() gitignore = test_dir / ".gitignore" gitignore.write_text( """*.log !important.log !src/keep.log """ ) parser = GitignoreParser(str(test_dir)) specs = parser.get_ignore_specs() assert len(specs) == 1 patterns = specs[0].patterns assert "*.log" in patterns assert "!important.log" in patterns assert "!src/keep.log" in patterns def test_comments_and_empty_lines(self): """Test that comments and empty lines are ignored.""" test_dir = self.repo_path / "test_comments" test_dir.mkdir() gitignore = test_dir / ".gitignore" gitignore.write_text( """# This is a comment *.log # Another comment # Indented comment build/ """ ) parser = GitignoreParser(str(test_dir)) specs = parser.get_ignore_specs() assert len(specs) == 1 patterns = specs[0].patterns assert len(patterns) == 2 assert "*.log" in patterns assert "build/" in patterns def test_escaped_characters(self): """Test escaped special characters.""" test_dir = self.repo_path / "test_escaped" test_dir.mkdir() gitignore = test_dir / ".gitignore" gitignore.write_text( """\\#not-a-comment.txt \\!not-negation.txt """ ) parser = GitignoreParser(str(test_dir)) specs = parser.get_ignore_specs() assert len(specs) == 1 patterns = specs[0].patterns assert "#not-a-comment.txt" in patterns assert "!not-negation.txt" in patterns def test_escaped_negation_patterns(self): test_dir = self.repo_path / "test_escaped_negation" test_dir.mkdir() gitignore = test_dir / ".gitignore" gitignore.write_text( """*.log \\!not-negation.log !actual-negation.log """ ) parser = GitignoreParser(str(test_dir)) specs = parser.get_ignore_specs() assert len(specs) == 1 patterns = specs[0].patterns # Key assertions: escaped exclamation becomes literal, real negation preserved assert "!not-negation.log" in patterns # escaped -> literal assert "!actual-negation.log" in patterns # real negation preserved # Test the actual behavioral difference between escaped and real negation: # *.log pattern should ignore test.log assert parser.should_ignore("test.log") # Escaped negation file should still be ignored by *.log pattern assert parser.should_ignore("!not-negation.log") # Actual negation should override the *.log pattern assert not parser.should_ignore("actual-negation.log") def test_glob_patterns(self): """Test various glob patterns work correctly.""" test_dir = self.repo_path / "test_glob" test_dir.mkdir() gitignore = test_dir / ".gitignore" gitignore.write_text( """*.pyc **/*.tmp src/*.o !src/important.o [Tt]est* """ ) # Create test files (test_dir / "src").mkdir() (test_dir / "src" / "nested").mkdir() (test_dir / "file.pyc").touch() (test_dir / "src" / "file.pyc").touch() (test_dir / "file.tmp").touch() (test_dir / "src" / "nested" / "file.tmp").touch() (test_dir / "src" / "file.o").touch() (test_dir / "src" / "important.o").touch() (test_dir / "Test.txt").touch() (test_dir / "test.log").touch() parser = GitignoreParser(str(test_dir)) # *.pyc should match everywhere assert parser.should_ignore("file.pyc") assert parser.should_ignore("src/file.pyc") # **/*.tmp should match all .tmp files assert parser.should_ignore("file.tmp") assert parser.should_ignore("src/nested/file.tmp") # src/*.o should only match .o files directly in src/ assert parser.should_ignore("src/file.o") # Character class patterns assert parser.should_ignore("Test.txt") assert parser.should_ignore("test.log") def test_empty_gitignore(self): """Test handling of empty gitignore files.""" test_dir = self.repo_path / "test_empty" test_dir.mkdir() gitignore = test_dir / ".gitignore" gitignore.write_text("") parser = GitignoreParser(str(test_dir)) # Should not crash and should return empty list assert len(parser.get_ignore_specs()) == 0 def test_malformed_gitignore(self): """Test handling of malformed gitignore content.""" test_dir = self.repo_path / "test_malformed" test_dir.mkdir() gitignore = test_dir / ".gitignore" gitignore.write_text( """# Only comments and empty lines # More comments """ ) parser = GitignoreParser(str(test_dir)) # Should handle gracefully assert len(parser.get_ignore_specs()) == 0 def test_reload(self): """Test reloading gitignore files.""" test_dir = self.repo_path / "test_reload" test_dir.mkdir() # Create initial gitignore gitignore = test_dir / ".gitignore" gitignore.write_text("*.log") parser = GitignoreParser(str(test_dir)) assert len(parser.get_ignore_specs()) == 1 assert parser.should_ignore("test.log") # Modify gitignore gitignore.write_text("*.tmp") # Without reload, should still use old patterns assert parser.should_ignore("test.log") assert not parser.should_ignore("test.tmp") # After reload, should use new patterns parser.reload() assert not parser.should_ignore("test.log") assert parser.should_ignore("test.tmp") def test_gitignore_spec_matches(self): """Test GitignoreSpec.matches method.""" spec = GitignoreSpec("/path/to/.gitignore", ["*.log", "build/", "!important.log"]) assert spec.matches("test.log") assert spec.matches("build/output.o") assert spec.matches("src/test.log") # Note: Negation patterns in pathspec work differently than in git # This is a limitation of the pathspec library def test_subdirectory_gitignore_pattern_scoping(self): """Test that subdirectory .gitignore patterns are scoped correctly.""" # Create test structure: foo/ with subdirectory bar/ test_dir = self.repo_path / "test_subdir_scoping" test_dir.mkdir() (test_dir / "foo").mkdir() (test_dir / "foo" / "bar").mkdir() # Create files in various locations (test_dir / "foo.txt").touch() # root level (test_dir / "foo" / "foo.txt").touch() # in foo/ (test_dir / "foo" / "bar" / "foo.txt").touch() # in foo/bar/ # Test case 1: foo.txt in foo/.gitignore should only ignore in foo/ subtree gitignore = test_dir / "foo" / ".gitignore" gitignore.write_text("foo.txt\n") parser = GitignoreParser(str(test_dir)) # foo.txt at root should NOT be ignored by foo/.gitignore assert not parser.should_ignore("foo.txt"), "Root foo.txt should not be ignored by foo/.gitignore" # foo.txt in foo/ should be ignored assert parser.should_ignore("foo/foo.txt"), "foo/foo.txt should be ignored" # foo.txt in foo/bar/ should be ignored (within foo/ subtree) assert parser.should_ignore("foo/bar/foo.txt"), "foo/bar/foo.txt should be ignored" def test_anchored_pattern_in_subdirectory(self): """Test that anchored patterns in subdirectory only match immediate children.""" test_dir = self.repo_path / "test_anchored_subdir" test_dir.mkdir() (test_dir / "foo").mkdir() (test_dir / "foo" / "bar").mkdir() # Create files (test_dir / "foo.txt").touch() # root level (test_dir / "foo" / "foo.txt").touch() # in foo/ (test_dir / "foo" / "bar" / "foo.txt").touch() # in foo/bar/ # Test case 2: /foo.txt in foo/.gitignore should only match foo/foo.txt gitignore = test_dir / "foo" / ".gitignore" gitignore.write_text("/foo.txt\n") parser = GitignoreParser(str(test_dir)) # foo.txt at root should NOT be ignored assert not parser.should_ignore("foo.txt"), "Root foo.txt should not be ignored" # foo.txt directly in foo/ should be ignored assert parser.should_ignore("foo/foo.txt"), "foo/foo.txt should be ignored by /foo.txt pattern" # foo.txt in foo/bar/ should NOT be ignored (anchored pattern only matches immediate children) assert not parser.should_ignore("foo/bar/foo.txt"), "foo/bar/foo.txt should NOT be ignored by /foo.txt pattern" def test_double_star_pattern_scoping(self): """Test that **/pattern in subdirectory only applies within that subtree.""" test_dir = self.repo_path / "test_doublestar_scope" test_dir.mkdir() (test_dir / "foo").mkdir() (test_dir / "foo" / "bar").mkdir() (test_dir / "other").mkdir() # Create files (test_dir / "foo.txt").touch() # root level (test_dir / "foo" / "foo.txt").touch() # in foo/ (test_dir / "foo" / "bar" / "foo.txt").touch() # in foo/bar/ (test_dir / "other" / "foo.txt").touch() # in other/ # Test case 3: **/foo.txt in foo/.gitignore should only ignore within foo/ subtree gitignore = test_dir / "foo" / ".gitignore" gitignore.write_text("**/foo.txt\n") parser = GitignoreParser(str(test_dir)) # foo.txt at root should NOT be ignored assert not parser.should_ignore("foo.txt"), "Root foo.txt should not be ignored by foo/.gitignore" # foo.txt in foo/ should be ignored assert parser.should_ignore("foo/foo.txt"), "foo/foo.txt should be ignored" # foo.txt in foo/bar/ should be ignored (within foo/ subtree) assert parser.should_ignore("foo/bar/foo.txt"), "foo/bar/foo.txt should be ignored" # foo.txt in other/ should NOT be ignored (outside foo/ subtree) assert not parser.should_ignore("other/foo.txt"), "other/foo.txt should NOT be ignored by foo/.gitignore" def test_anchored_double_star_pattern(self): """Test that /**/pattern in subdirectory works correctly.""" test_dir = self.repo_path / "test_anchored_doublestar" test_dir.mkdir() (test_dir / "foo").mkdir() (test_dir / "foo" / "bar").mkdir() (test_dir / "other").mkdir() # Create files (test_dir / "foo.txt").touch() # root level (test_dir / "foo" / "foo.txt").touch() # in foo/ (test_dir / "foo" / "bar" / "foo.txt").touch() # in foo/bar/ (test_dir / "other" / "foo.txt").touch() # in other/ # Test case 4: /**/foo.txt in foo/.gitignore should correctly ignore only within foo/ subtree gitignore = test_dir / "foo" / ".gitignore" gitignore.write_text("/**/foo.txt\n") parser = GitignoreParser(str(test_dir)) # foo.txt at root should NOT be ignored assert not parser.should_ignore("foo.txt"), "Root foo.txt should not be ignored" # foo.txt in foo/ should be ignored assert parser.should_ignore("foo/foo.txt"), "foo/foo.txt should be ignored" # foo.txt in foo/bar/ should be ignored (within foo/ subtree) assert parser.should_ignore("foo/bar/foo.txt"), "foo/bar/foo.txt should be ignored" # foo.txt in other/ should NOT be ignored (outside foo/ subtree) assert not parser.should_ignore("other/foo.txt"), "other/foo.txt should NOT be ignored by foo/.gitignore" ================================================ FILE: test/solidlsp/al/test_al_basic.py ================================================ import os import pytest from serena.symbol import LanguageServerSymbol from solidlsp import SolidLanguageServer from solidlsp.language_servers.al_language_server import ALLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils from test.conftest import language_tests_enabled pytestmark = [pytest.mark.al, pytest.mark.skipif(not language_tests_enabled(Language.AL), reason="AL tests are disabled")] class TestExtractALDisplayName: """Tests for the ALLanguageServer._extract_al_display_name method.""" def test_table_with_quoted_name(self) -> None: """Test extraction from Table with quoted name.""" assert ALLanguageServer._extract_al_display_name('Table 50000 "TEST Customer"') == "TEST Customer" def test_page_with_quoted_name(self) -> None: """Test extraction from Page with quoted name.""" assert ALLanguageServer._extract_al_display_name('Page 50001 "TEST Customer Card"') == "TEST Customer Card" def test_codeunit_unquoted(self) -> None: """Test extraction from Codeunit with unquoted name.""" assert ALLanguageServer._extract_al_display_name("Codeunit 50000 CustomerMgt") == "CustomerMgt" def test_enum_unquoted(self) -> None: """Test extraction from Enum with unquoted name.""" assert ALLanguageServer._extract_al_display_name("Enum 50000 CustomerType") == "CustomerType" def test_interface_no_id(self) -> None: """Test extraction from Interface (no ID).""" assert ALLanguageServer._extract_al_display_name("Interface IPaymentProcessor") == "IPaymentProcessor" def test_table_extension(self) -> None: """Test extraction from TableExtension.""" assert ALLanguageServer._extract_al_display_name('TableExtension 50000 "Ext Customer"') == "Ext Customer" def test_page_extension(self) -> None: """Test extraction from PageExtension.""" assert ALLanguageServer._extract_al_display_name('PageExtension 50000 "My Page Ext"') == "My Page Ext" def test_non_al_object_unchanged(self) -> None: """Test that non-AL-object names pass through unchanged.""" assert ALLanguageServer._extract_al_display_name("fields") == "fields" assert ALLanguageServer._extract_al_display_name("CreateCustomer") == "CreateCustomer" assert ALLanguageServer._extract_al_display_name("Name") == "Name" def test_report_with_quoted_name(self) -> None: """Test extraction from Report.""" assert ALLanguageServer._extract_al_display_name('Report 50000 "Sales Invoice"') == "Sales Invoice" def test_query_unquoted(self) -> None: """Test extraction from Query.""" assert ALLanguageServer._extract_al_display_name("Query 50000 CustomerQuery") == "CustomerQuery" @pytest.mark.al class TestALLanguageServer: @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) def test_symbol_names_are_normalized(self, language_server: SolidLanguageServer) -> None: """Test that AL symbol names are normalized (metadata stripped).""" file_path = os.path.join("src", "Tables", "Customer.Table.al") symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() _all_symbols, root_symbols = symbols customer_table = None for sym in root_symbols: if sym.get("name") == "TEST Customer": customer_table = sym break assert customer_table is not None, "Could not find 'TEST Customer' table symbol (name should be normalized)" # Name should be just "TEST Customer", not "Table 50000 'TEST Customer'" assert customer_table["name"] == "TEST Customer", f"Expected normalized name 'TEST Customer', got '{customer_table['name']}'" @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) def test_find_symbol_exact_match(self, language_server: SolidLanguageServer) -> None: """Test that find_symbol can match AL symbols by normalized name without substring_matching.""" file_path = os.path.join("src", "Tables", "Customer.Table.al") symbols = language_server.request_document_symbols(file_path) # Find symbols that match 'TEST Customer' using LanguageServerSymbol.find() for root in symbols.root_symbols: ls_symbol = LanguageServerSymbol(root) matches = ls_symbol.find("TEST Customer", substring_matching=False) if matches: assert len(matches) >= 1, "Should find at least one match for 'TEST Customer'" assert matches[0].name == "TEST Customer", f"Expected 'TEST Customer', got '{matches[0].name}'" return pytest.fail("Could not find 'TEST Customer' symbol by exact name match") @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) def test_find_codeunit_exact_match(self, language_server: SolidLanguageServer) -> None: """Test finding a codeunit by its normalized name.""" file_path = os.path.join("src", "Codeunits", "CustomerMgt.Codeunit.al") symbols = language_server.request_document_symbols(file_path) for root in symbols.root_symbols: ls_symbol = LanguageServerSymbol(root) matches = ls_symbol.find("CustomerMgt", substring_matching=False) if matches: assert len(matches) >= 1 assert matches[0].name == "CustomerMgt" return pytest.fail("Could not find 'CustomerMgt' symbol by exact name match") @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) def test_find_symbol(self, language_server: SolidLanguageServer) -> None: """Test that AL Language Server can find symbols in the test repository with normalized names.""" symbols = language_server.request_full_symbol_tree() # Check for table symbols - names should be normalized (no "Table 50000" prefix) assert SymbolUtils.symbol_tree_contains_name(symbols, "TEST Customer"), "TEST Customer table not found in symbol tree" # Check for page symbols assert SymbolUtils.symbol_tree_contains_name(symbols, "TEST Customer Card"), "TEST Customer Card page not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "TEST Customer List"), "TEST Customer List page not found in symbol tree" # Check for codeunit symbols assert SymbolUtils.symbol_tree_contains_name(symbols, "CustomerMgt"), "CustomerMgt codeunit not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name( symbols, "PaymentProcessorImpl" ), "PaymentProcessorImpl codeunit not found in symbol tree" # Check for enum symbol assert SymbolUtils.symbol_tree_contains_name(symbols, "CustomerType"), "CustomerType enum not found in symbol tree" # Check for interface symbol assert SymbolUtils.symbol_tree_contains_name(symbols, "IPaymentProcessor"), "IPaymentProcessor interface not found in symbol tree" @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) def test_find_table_fields(self, language_server: SolidLanguageServer) -> None: """Test that AL Language Server can find fields within a table.""" file_path = os.path.join("src", "Tables", "Customer.Table.al") symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() # AL tables should have their fields as child symbols customer_table = None _all_symbols, root_symbols = symbols for sym in root_symbols: if sym.get("name") == "TEST Customer": customer_table = sym break assert customer_table is not None, "Could not find TEST Customer table symbol" # Check for field symbols (AL nests fields under a "fields" group) if "children" in customer_table: # Find the fields group fields_group = None for child in customer_table.get("children", []): if child.get("name") == "fields": fields_group = child break assert fields_group is not None, "Fields group not found in Customer table" # Check actual field names if "children" in fields_group: field_names = [child.get("name", "") for child in fields_group.get("children", [])] assert any("Name" in name for name in field_names), f"Name field not found. Fields: {field_names}" assert any("Balance" in name for name in field_names), f"Balance field not found. Fields: {field_names}" @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) def test_find_procedures(self, language_server: SolidLanguageServer) -> None: """Test that AL Language Server can find procedures in codeunits.""" file_path = os.path.join("src", "Codeunits", "CustomerMgt.Codeunit.al") symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() # Find the codeunit symbol - name should be normalized to 'CustomerMgt' codeunit_symbol = None _all_symbols, root_symbols = symbols for sym in root_symbols: if sym.get("name") == "CustomerMgt": codeunit_symbol = sym break assert codeunit_symbol is not None, "Could not find CustomerMgt codeunit symbol" # Check for procedure symbols (if hierarchical) if "children" in codeunit_symbol: procedure_names = [child.get("name", "") for child in codeunit_symbol.get("children", [])] assert any("CreateCustomer" in name for name in procedure_names), "CreateCustomer procedure not found" assert any("TestNoSeries" in name for name in procedure_names), "TestNoSeries procedure not found" @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) def test_find_referencing_symbols(self, language_server: SolidLanguageServer) -> None: """Test that AL Language Server can find references to symbols.""" # Find references to the Customer table from the CustomerMgt codeunit table_file = os.path.join("src", "Tables", "Customer.Table.al") symbols = language_server.request_document_symbols(table_file).get_all_symbols_and_roots() # Find the Customer table symbol (name is normalized) customer_symbol = None _all_symbols, root_symbols = symbols for sym in root_symbols: if sym.get("name") == "TEST Customer": customer_symbol = sym break if customer_symbol and "selectionRange" in customer_symbol: sel_start = customer_symbol["selectionRange"]["start"] refs = language_server.request_references(table_file, sel_start["line"], sel_start["character"]) # The Customer table should be referenced in CustomerMgt.Codeunit.al assert any( "CustomerMgt.Codeunit.al" in ref.get("relativePath", "") for ref in refs ), "Customer table should be referenced in CustomerMgt.Codeunit.al" # It should also be referenced in CustomerCard.Page.al assert any( "CustomerCard.Page.al" in ref.get("relativePath", "") for ref in refs ), "Customer table should be referenced in CustomerCard.Page.al" @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) def test_cross_file_symbols(self, language_server: SolidLanguageServer) -> None: """Test that AL Language Server can handle cross-file symbol relationships.""" # Get all symbols to verify cross-file visibility symbols = language_server.request_full_symbol_tree() # Count how many AL object symbols we found (names are now normalized) al_object_names = [] def collect_symbols(syms: list) -> None: for sym in syms: if isinstance(sym, dict): name = sym.get("name", "") # These are normalized names now, so just collect them al_object_names.append(name) if "children" in sym: collect_symbols(sym["children"]) collect_symbols(symbols) # We should find expected normalized names assert "TEST Customer" in al_object_names, f"TEST Customer not found in: {al_object_names}" assert "CustomerMgt" in al_object_names, f"CustomerMgt not found in: {al_object_names}" assert "CustomerType" in al_object_names, f"CustomerType not found in: {al_object_names}" @pytest.mark.al class TestALHoverInjection: """Tests for hover injection of original AL object names with type and ID.""" def _get_symbol_hover(self, language_server: SolidLanguageServer, file_path: str, symbol_name: str) -> tuple[dict | None, str | None]: """Helper to get hover info for a symbol by name. Returns (hover_info, hover_value) tuple. """ symbols = language_server.request_document_symbols(file_path) for sym in symbols.root_symbols: if sym.get("name") == symbol_name: sel_range = sym.get("selectionRange", {}) start = sel_range.get("start", {}) line = start.get("line", 0) char = start.get("character", 0) hover = language_server.request_hover(file_path, line, char) if hover and "contents" in hover: return hover, hover["contents"].get("value", "") return hover, None return None, None def _get_child_symbol_hover( self, language_server: SolidLanguageServer, file_path: str, parent_name: str, child_name_contains: str ) -> tuple[dict | None, str | None]: """Helper to get hover info for a child symbol. Returns (hover_info, hover_value) tuple. """ symbols = language_server.request_document_symbols(file_path) for sym in symbols.root_symbols: if sym.get("name") == parent_name: for child in sym.get("children", []): if child_name_contains in child.get("name", ""): sel_range = child.get("selectionRange", {}) start = sel_range.get("start", {}) line = start.get("line", 0) char = start.get("character", 0) hover = language_server.request_hover(file_path, line, char) if hover and "contents" in hover: return hover, hover["contents"].get("value", "") return hover, None return None, None @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) def test_hover_table_injects_full_name(self, language_server: SolidLanguageServer) -> None: """Test that hovering over a Table symbol shows the full object name with ID.""" file_path = os.path.join("src", "Tables", "Customer.Table.al") hover, value = self._get_symbol_hover(language_server, file_path, "TEST Customer") assert hover is not None, "Hover should return a result for Table symbol" assert value is not None, "Hover should have content" assert '**Table 50000 "TEST Customer"**' in value, f"Hover should contain full Table name with ID. Got: {value[:200]}" @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) def test_hover_page_injects_full_name(self, language_server: SolidLanguageServer) -> None: """Test that hovering over a Page symbol shows the full object name with ID.""" file_path = os.path.join("src", "Pages", "CustomerCard.Page.al") hover, value = self._get_symbol_hover(language_server, file_path, "TEST Customer Card") assert hover is not None, "Hover should return a result for Page symbol" assert value is not None, "Hover should have content" assert '**Page 50001 "TEST Customer Card"**' in value, f"Hover should contain full Page name with ID. Got: {value[:200]}" @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) def test_hover_codeunit_injects_full_name(self, language_server: SolidLanguageServer) -> None: """Test that hovering over a Codeunit symbol shows the full object name with ID.""" file_path = os.path.join("src", "Codeunits", "CustomerMgt.Codeunit.al") hover, value = self._get_symbol_hover(language_server, file_path, "CustomerMgt") assert hover is not None, "Hover should return a result for Codeunit symbol" assert value is not None, "Hover should have content" assert "**Codeunit 50000 CustomerMgt**" in value, f"Hover should contain full Codeunit name with ID. Got: {value[:200]}" @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) def test_hover_enum_injects_full_name(self, language_server: SolidLanguageServer) -> None: """Test that hovering over an Enum symbol shows the full object name with ID.""" file_path = os.path.join("src", "Enums", "CustomerType.Enum.al") hover, value = self._get_symbol_hover(language_server, file_path, "CustomerType") assert hover is not None, "Hover should return a result for Enum symbol" assert value is not None, "Hover should have content" assert "**Enum 50000 CustomerType**" in value, f"Hover should contain full Enum name with ID. Got: {value[:200]}" @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) def test_hover_interface_injects_full_name(self, language_server: SolidLanguageServer) -> None: """Test that hovering over an Interface symbol shows the full object name (no ID for interfaces).""" file_path = os.path.join("src", "Interfaces", "IPaymentProcessor.Interface.al") hover, value = self._get_symbol_hover(language_server, file_path, "IPaymentProcessor") assert hover is not None, "Hover should return a result for Interface symbol" assert value is not None, "Hover should have content" assert "**Interface IPaymentProcessor**" in value, f"Hover should contain full Interface name. Got: {value[:200]}" @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) def test_hover_procedure_no_injection(self, language_server: SolidLanguageServer) -> None: """Test that hovering over a procedure does NOT inject object name (procedures are not normalized).""" file_path = os.path.join("src", "Codeunits", "CustomerMgt.Codeunit.al") hover, value = self._get_child_symbol_hover(language_server, file_path, "CustomerMgt", "CreateCustomer") assert hover is not None, "Hover should return a result for procedure" assert value is not None, "Hover should have content" # Procedure hover should NOT start with ** (no injection) assert not value.startswith("**"), f"Procedure hover should not have injected name. Got: {value[:200]}" # But should contain procedure info assert "CreateCustomer" in value, f"Hover should contain procedure name. Got: {value[:200]}" @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) def test_hover_field_no_injection(self, language_server: SolidLanguageServer) -> None: """Test that hovering over a field does NOT inject object name (fields are not normalized).""" file_path = os.path.join("src", "Tables", "Customer.Table.al") symbols = language_server.request_document_symbols(file_path) # Navigate to a field: Table -> fields -> specific field for sym in symbols.root_symbols: if sym.get("name") == "TEST Customer": for child in sym.get("children", []): if child.get("name") == "fields": for field in child.get("children", []): if "Name" in field.get("name", ""): sel_range = field.get("selectionRange", {}) start = sel_range.get("start", {}) line = start.get("line", 0) char = start.get("character", 0) hover = language_server.request_hover(file_path, line, char) assert hover is not None, "Hover should return a result for field" value = hover.get("contents", {}).get("value", "") # Field hover should NOT start with ** (no injection) assert not value.startswith("**"), f"Field hover should not have injected name. Got: {value[:200]}" return pytest.fail("Could not find a field to test hover on") @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) def test_hover_multiple_objects_correct_injection(self, language_server: SolidLanguageServer) -> None: """Test that multiple AL objects each get their correct full name injected.""" test_cases = [ (os.path.join("src", "Tables", "Customer.Table.al"), "TEST Customer", 'Table 50000 "TEST Customer"'), (os.path.join("src", "Codeunits", "CustomerMgt.Codeunit.al"), "CustomerMgt", "Codeunit 50000 CustomerMgt"), (os.path.join("src", "Enums", "CustomerType.Enum.al"), "CustomerType", "Enum 50000 CustomerType"), ] for file_path, symbol_name, expected_full_name in test_cases: hover, value = self._get_symbol_hover(language_server, file_path, symbol_name) assert hover is not None, f"Hover should return a result for {symbol_name}" assert value is not None, f"Hover should have content for {symbol_name}" assert ( f"**{expected_full_name}**" in value ), f"Hover for {symbol_name} should contain '{expected_full_name}'. Got: {value[:200]}" @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) def test_hover_contains_separator_after_injection(self, language_server: SolidLanguageServer) -> None: """Test that injected hover has a separator between injected name and original content.""" file_path = os.path.join("src", "Tables", "Customer.Table.al") hover, value = self._get_symbol_hover(language_server, file_path, "TEST Customer") assert hover is not None, "Hover should return a result" assert value is not None, "Hover should have content" # Should have the separator after the bold name assert "---" in value, f"Hover should contain separator. Got: {value[:300]}" # The separator should come after the injected name bold_end = value.find("**", 2) # Find closing ** separator_pos = value.find("---") assert separator_pos > bold_end, "Separator should come after the injected name" @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) def test_hover_preserves_original_content(self, language_server: SolidLanguageServer) -> None: """Test that the original hover content is preserved after the injected name.""" file_path = os.path.join("src", "Tables", "Customer.Table.al") hover, value = self._get_symbol_hover(language_server, file_path, "TEST Customer") assert hover is not None, "Hover should return a result" assert value is not None, "Hover should have content" # Original AL hover content should still be present (the table structure) assert "```al" in value, f"Hover should contain original AL code block. Got: {value[:500]}" assert 'Table "TEST Customer"' in value, f"Hover should contain original table definition. Got: {value[:500]}" @pytest.mark.al class TestALPathNormalization: """Tests for path normalization in hover injection cache.""" @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) def test_hover_with_forward_slash_path(self, language_server: SolidLanguageServer) -> None: """Test that hover injection works with forward slash paths.""" file_path = "src/Tables/Customer.Table.al" symbols = language_server.request_document_symbols(file_path) for sym in symbols.root_symbols: if sym.get("name") == "TEST Customer": sel_range = sym.get("selectionRange", {}) start = sel_range.get("start", {}) line = start.get("line", 0) char = start.get("character", 0) hover = language_server.request_hover(file_path, line, char) assert hover is not None, "Hover should return a result" value = hover.get("contents", {}).get("value", "") assert '**Table 50000 "TEST Customer"**' in value, f"Hover should have injection. Got: {value[:200]}" return pytest.fail("Could not find TEST Customer symbol") @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) def test_hover_with_backslash_path(self, language_server: SolidLanguageServer) -> None: """Test that hover injection works with backslash paths (Windows style).""" file_path = "src\\Tables\\Customer.Table.al" symbols = language_server.request_document_symbols(file_path) for sym in symbols.root_symbols: if sym.get("name") == "TEST Customer": sel_range = sym.get("selectionRange", {}) start = sel_range.get("start", {}) line = start.get("line", 0) char = start.get("character", 0) hover = language_server.request_hover(file_path, line, char) assert hover is not None, "Hover should return a result" value = hover.get("contents", {}).get("value", "") assert '**Table 50000 "TEST Customer"**' in value, f"Hover should have injection. Got: {value[:200]}" return pytest.fail("Could not find TEST Customer symbol") @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) def test_hover_with_mixed_path_formats_symbols_backslash_hover_forward(self, language_server: SolidLanguageServer) -> None: """Test hover works when symbols requested with backslash but hover with forward slash.""" file_path_backslash = "src\\Tables\\Customer.Table.al" file_path_forward = "src/Tables/Customer.Table.al" # Request symbols with backslash path symbols = language_server.request_document_symbols(file_path_backslash) for sym in symbols.root_symbols: if sym.get("name") == "TEST Customer": sel_range = sym.get("selectionRange", {}) start = sel_range.get("start", {}) line = start.get("line", 0) char = start.get("character", 0) # Request hover with forward slash path (different format) hover = language_server.request_hover(file_path_forward, line, char) assert hover is not None, "Hover should return a result" value = hover.get("contents", {}).get("value", "") assert ( '**Table 50000 "TEST Customer"**' in value ), f"Hover injection should work with mixed path formats. Got: {value[:200]}" return pytest.fail("Could not find TEST Customer symbol") @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) def test_hover_with_mixed_path_formats_symbols_forward_hover_backslash(self, language_server: SolidLanguageServer) -> None: """Test hover works when symbols requested with forward slash but hover with backslash.""" file_path_forward = "src/Tables/Customer.Table.al" file_path_backslash = "src\\Tables\\Customer.Table.al" # Request symbols with forward slash path symbols = language_server.request_document_symbols(file_path_forward) for sym in symbols.root_symbols: if sym.get("name") == "TEST Customer": sel_range = sym.get("selectionRange", {}) start = sel_range.get("start", {}) line = start.get("line", 0) char = start.get("character", 0) # Request hover with backslash path (different format) hover = language_server.request_hover(file_path_backslash, line, char) assert hover is not None, "Hover should return a result" value = hover.get("contents", {}).get("value", "") assert ( '**Table 50000 "TEST Customer"**' in value ), f"Hover injection should work with mixed path formats. Got: {value[:200]}" return pytest.fail("Could not find TEST Customer symbol") @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) def test_hover_caching_multiple_files_different_path_formats(self, language_server: SolidLanguageServer) -> None: """Test that hover injection cache works correctly across multiple files with different path formats.""" test_cases = [ ("src/Tables/Customer.Table.al", "src\\Tables\\Customer.Table.al", "TEST Customer", 'Table 50000 "TEST Customer"'), ( "src\\Codeunits\\CustomerMgt.Codeunit.al", "src/Codeunits/CustomerMgt.Codeunit.al", "CustomerMgt", "Codeunit 50000 CustomerMgt", ), ] for symbols_path, hover_path, symbol_name, expected_injection in test_cases: # Request symbols with one path format symbols = language_server.request_document_symbols(symbols_path) for sym in symbols.root_symbols: if sym.get("name") == symbol_name: sel_range = sym.get("selectionRange", {}) start = sel_range.get("start", {}) line = start.get("line", 0) char = start.get("character", 0) # Request hover with different path format hover = language_server.request_hover(hover_path, line, char) assert hover is not None, f"Hover should return a result for {symbol_name}" value = hover.get("contents", {}).get("value", "") assert ( f"**{expected_injection}**" in value ), f"Hover for {symbol_name} should have injection with mixed paths. Got: {value[:200]}" break ================================================ FILE: test/solidlsp/ansible/__init__.py ================================================ ================================================ FILE: test/solidlsp/ansible/test_ansible_basic.py ================================================ """ Basic integration tests for the Ansible language server. These tests validate initialization, hover, and completion capabilities using the standard Ansible test repository. They work with the standard @ansible/ansible-language-server from npm. """ from pathlib import Path import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language @pytest.mark.ansible class TestAnsibleLanguageServerBasics: """Test basic Ansible language server functionality.""" @pytest.mark.parametrize("language_server", [Language.ANSIBLE], indirect=True) @pytest.mark.parametrize("repo_path", [Language.ANSIBLE], indirect=True) def test_ls_is_running(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Language server starts and points to the correct repo.""" assert language_server.is_running() assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve() @pytest.mark.parametrize("language_server", [Language.ANSIBLE], indirect=True) def test_hover_on_module_contains_documentation(self, language_server: SolidLanguageServer) -> None: """Hover on ansible.builtin.package returns module documentation.""" # playbook.yml line 10 (0-indexed): "ansible.builtin.package:" result = language_server.request_hover("playbook.yml", 10, 8) assert result is not None, "Expected hover info for ansible.builtin.package" hover_value = result["contents"] if isinstance(hover_value, dict): hover_text = hover_value.get("value", "") elif isinstance(hover_value, list): hover_text = " ".join(str(v) for v in hover_value) else: hover_text = str(hover_value) assert "package" in hover_text.lower(), f"Hover should mention 'package', got: {hover_text[:300]}" @pytest.mark.parametrize("language_server", [Language.ANSIBLE], indirect=True) def test_completions_contain_module_names(self, language_server: SolidLanguageServer) -> None: """Completions at a task keyword position return Ansible module names.""" # playbook.yml line 10 (0-indexed), col 6: inside a task block result = language_server.request_completions("playbook.yml", 10, 6) assert result is not None, "Expected completion results" assert len(result) > 0, "Expected non-empty completion list" labels = [item["completionText"] for item in result if "completionText" in item] assert labels, f"Expected completions with completionText, got: {result[:3]}" ================================================ FILE: test/solidlsp/bash/__init__.py ================================================ ================================================ FILE: test/solidlsp/bash/test_bash_basic.py ================================================ """ Basic integration tests for the bash language server functionality. These tests validate the functionality of the language server APIs like request_document_symbols using the bash test repository. """ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language @pytest.mark.bash class TestBashLanguageServerBasics: """Test basic functionality of the bash language server.""" @pytest.mark.parametrize("language_server", [Language.BASH], indirect=True) def test_bash_language_server_initialization(self, language_server: SolidLanguageServer) -> None: """Test that bash language server can be initialized successfully.""" assert language_server is not None assert language_server.language == Language.BASH @pytest.mark.parametrize("language_server", [Language.BASH], indirect=True) def test_bash_request_document_symbols(self, language_server: SolidLanguageServer) -> None: """Test request_document_symbols for bash files.""" # Test getting symbols from main.sh all_symbols, _root_symbols = language_server.request_document_symbols("main.sh").get_all_symbols_and_roots() # Extract function symbols (LSP Symbol Kind 12) function_symbols = [symbol for symbol in all_symbols if symbol.get("kind") == 12] function_names = [symbol["name"] for symbol in function_symbols] # Should detect all 3 functions from main.sh assert "greet_user" in function_names, "Should find greet_user function" assert "process_items" in function_names, "Should find process_items function" assert "main" in function_names, "Should find main function" assert len(function_symbols) >= 3, f"Should find at least 3 functions, found {len(function_symbols)}" @pytest.mark.parametrize("language_server", [Language.BASH], indirect=True) def test_bash_request_document_symbols_with_body(self, language_server: SolidLanguageServer) -> None: """Test request_document_symbols with body extraction.""" # Test with include_body=True all_symbols, _root_symbols = language_server.request_document_symbols("main.sh").get_all_symbols_and_roots() function_symbols = [symbol for symbol in all_symbols if symbol.get("kind") == 12] # Find greet_user function and check it has body greet_user_symbol = next((sym for sym in function_symbols if sym["name"] == "greet_user"), None) assert greet_user_symbol is not None, "Should find greet_user function" if "body" in greet_user_symbol: body = greet_user_symbol["body"].get_text() assert "function greet_user()" in body, "Function body should contain function definition" assert "case" in body.lower(), "Function body should contain case statement" @pytest.mark.parametrize("language_server", [Language.BASH], indirect=True) def test_bash_utils_functions(self, language_server: SolidLanguageServer) -> None: """Test function detection in utils.sh file.""" # Test with utils.sh as well utils_all_symbols, _utils_root_symbols = language_server.request_document_symbols("utils.sh").get_all_symbols_and_roots() utils_function_symbols = [symbol for symbol in utils_all_symbols if symbol.get("kind") == 12] utils_function_names = [symbol["name"] for symbol in utils_function_symbols] # Should detect functions from utils.sh expected_utils_functions = [ "to_uppercase", "to_lowercase", "trim_whitespace", "backup_file", "contains_element", "log_message", "is_valid_email", "is_number", ] for func_name in expected_utils_functions: assert func_name in utils_function_names, f"Should find {func_name} function in utils.sh" assert len(utils_function_symbols) >= 8, f"Should find at least 8 functions in utils.sh, found {len(utils_function_symbols)}" @pytest.mark.parametrize("language_server", [Language.BASH], indirect=True) def test_bash_function_syntax_patterns(self, language_server: SolidLanguageServer) -> None: """Test that LSP detects different bash function syntax patterns correctly.""" # Test main.sh (has both 'function' keyword and traditional syntax) main_all_symbols, _main_root_symbols = language_server.request_document_symbols("main.sh").get_all_symbols_and_roots() main_functions = [symbol for symbol in main_all_symbols if symbol.get("kind") == 12] main_function_names = [func["name"] for func in main_functions] # Test utils.sh (all use 'function' keyword) utils_all_symbols, _utils_root_symbols = language_server.request_document_symbols("utils.sh").get_all_symbols_and_roots() utils_functions = [symbol for symbol in utils_all_symbols if symbol.get("kind") == 12] utils_function_names = [func["name"] for func in utils_functions] # Verify LSP detects both syntax patterns # main() uses traditional syntax: main() { assert "main" in main_function_names, "LSP should detect traditional function syntax" # Functions with 'function' keyword: function name() { assert "greet_user" in main_function_names, "LSP should detect function keyword syntax" assert "process_items" in main_function_names, "LSP should detect function keyword syntax" # Verify all expected utils functions are detected by LSP expected_utils = [ "to_uppercase", "to_lowercase", "trim_whitespace", "backup_file", "contains_element", "log_message", "is_valid_email", "is_number", ] for expected_func in expected_utils: assert expected_func in utils_function_names, f"LSP should detect {expected_func} function" # Verify total counts match expectations assert len(main_functions) >= 3, f"Should find at least 3 functions in main.sh, found {len(main_functions)}" assert len(utils_functions) >= 8, f"Should find at least 8 functions in utils.sh, found {len(utils_functions)}" ================================================ FILE: test/solidlsp/clojure/__init__.py ================================================ from pathlib import Path from solidlsp.language_servers.clojure_lsp import verify_clojure_cli def _test_clojure_cli() -> bool: try: verify_clojure_cli() return False except (FileNotFoundError, RuntimeError): return True CLI_FAIL = _test_clojure_cli() TEST_APP_PATH = Path("src") / "test_app" CORE_PATH = str(TEST_APP_PATH / "core.clj") UTILS_PATH = str(TEST_APP_PATH / "utils.clj") def is_clojure_cli_available() -> bool: return not CLI_FAIL ================================================ FILE: test/solidlsp/clojure/test_clojure_basic.py ================================================ import pytest from serena.project import Project from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_types import UnifiedSymbolInformation from test.conftest import language_tests_enabled from . import CORE_PATH, UTILS_PATH @pytest.mark.skipif(not language_tests_enabled(Language.CLOJURE), reason="Clojure tests are disabled") @pytest.mark.clojure class TestLanguageServerBasics: @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) def test_basic_definition(self, language_server: SolidLanguageServer): """ Test finding definition of 'greet' function call in core.clj """ result = language_server.request_definition(CORE_PATH, 20, 12) # Position of 'greet' in (greet "World") assert isinstance(result, list) assert len(result) >= 1 definition = result[0] assert definition["relativePath"] == CORE_PATH assert definition["range"]["start"]["line"] == 2, "Should find the definition of greet function at line 2" @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) def test_cross_file_references(self, language_server: SolidLanguageServer): """ Test finding references to 'multiply' function from core.clj """ result = language_server.request_references(CORE_PATH, 12, 6) assert isinstance(result, list) and len(result) >= 2, "Should find definition + usage in utils.clj" usage_found = any( item["relativePath"] == UTILS_PATH and item["range"]["start"]["line"] == 6 # multiply usage in calculate-area for item in result ) assert usage_found, "Should find multiply usage in utils.clj" @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) def test_completions(self, language_server: SolidLanguageServer): with language_server.open_file(UTILS_PATH): # After "core/" in calculate-area result = language_server.request_completions(UTILS_PATH, 6, 8) assert isinstance(result, list) and len(result) > 0 completion_texts = [item["completionText"] for item in result] assert any("multiply" in text for text in completion_texts), "Should find 'multiply' function in completions after 'core/'" @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) def test_document_symbols(self, language_server: SolidLanguageServer): symbols, _ = language_server.request_document_symbols(CORE_PATH).get_all_symbols_and_roots() assert isinstance(symbols, list) and len(symbols) >= 4, "greet, add, multiply, -main functions" # Check that we find the expected function symbols symbol_names = [symbol["name"] for symbol in symbols] expected_functions = ["greet", "add", "multiply", "-main"] for func_name in expected_functions: assert func_name in symbol_names, f"Should find {func_name} function in symbols" @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) def test_hover(self, language_server: SolidLanguageServer): """Test hover on greet function""" result = language_server.request_hover(CORE_PATH, 2, 7) assert result is not None, "Hover should return information for greet function" assert "contents" in result # Should contain function signature or documentation contents = result["contents"] if isinstance(contents, str): assert "greet" in contents.lower() elif isinstance(contents, dict) and "value" in contents: assert "greet" in contents["value"].lower() else: assert False, f"Unexpected contents format: {type(contents)}" @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) def test_workspace_symbols(self, language_server: SolidLanguageServer): # Search for functions containing "add" result = language_server.request_workspace_symbol("add") assert isinstance(result, list) and len(result) > 0, "Should find at least one symbol containing 'add'" # Should find the 'add' function symbol_names = [symbol["name"] for symbol in result] assert any("add" in name.lower() for name in symbol_names), f"Should find 'add' function in symbols: {symbol_names}" @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) def test_namespace_functions(self, language_server: SolidLanguageServer): """Test definition lookup for core/greet usage in utils.clj""" # Position of 'greet' in core/greet call result = language_server.request_definition(UTILS_PATH, 11, 25) assert isinstance(result, list) assert len(result) >= 1 definition = result[0] assert definition["relativePath"] == CORE_PATH, "Should find the definition of greet in core.clj" @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) def test_request_references_with_content(self, language_server: SolidLanguageServer): """Test references to multiply function with content""" references = language_server.request_references(CORE_PATH, 12, 6) result = [ language_server.retrieve_content_around_line(ref1["relativePath"], ref1["range"]["start"]["line"], 3, 0) for ref1 in references ] assert result is not None, "Should find references with content" assert isinstance(result, list) assert len(result) >= 2, "Should find definition + usage in utils.clj" for ref in result: assert ref.source_file_path is not None, "Each reference should have a source file path" content_str = ref.to_display_string() assert len(content_str) > 0, "Content should not be empty" # Verify we find the reference in utils.clj with context utils_refs = [ref for ref in result if ref.source_file_path and "utils.clj" in ref.source_file_path] assert len(utils_refs) > 0, "Should find reference in utils.clj" # The context should contain the calculate-area function utils_content = utils_refs[0].to_display_string() assert "calculate-area" in utils_content @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) def test_request_full_symbol_tree(self, language_server: SolidLanguageServer): """Test retrieving the full symbol tree for project overview We just check that we find some expected symbols. """ result = language_server.request_full_symbol_tree() assert result is not None, "Should return symbol tree" assert isinstance(result, list), "Symbol tree should be a list" assert len(result) > 0, "Should find symbols in the project" def traverse_symbols(symbols, indent=0): """Recursively traverse symbols to print their structure""" info = [] for s in symbols: name = getattr(s, "name", "NO_NAME") kind = getattr(s, "kind", "NO_KIND") info.append(f"{' ' * indent}Symbol: {name}, Kind: {kind}") if hasattr(s, "children") and s.children: info.append(" " * indent + "Children:") info.extend(traverse_symbols(s.children, indent + 2)) return info def list_all_symbols(symbols: list[UnifiedSymbolInformation]): found = [] for symbol in symbols: found.append(symbol["name"]) found.extend(list_all_symbols(symbol["children"])) return found all_symbol_names = list_all_symbols(result) expected_symbols = ["greet", "add", "multiply", "-main", "calculate-area", "format-greeting", "sum-list"] found_expected = [name for name in expected_symbols if any(name in symbol_name for symbol_name in all_symbol_names)] if len(found_expected) < 7: pytest.fail( f"Expected to find at least 3 symbols from {expected_symbols}, but found: {found_expected}.\n" f"All symbol names: {all_symbol_names}\n" f"Symbol tree structure:\n{traverse_symbols(result)}" ) @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) def test_request_referencing_symbols(self, language_server: SolidLanguageServer): """Test finding symbols that reference a given symbol Finds references to the 'multiply' function. """ result = language_server.request_referencing_symbols(CORE_PATH, 12, 6) assert isinstance(result, list) and len(result) > 0, "Should find at least one referencing symbol" found_relevant_references = False for ref in result: if hasattr(ref, "symbol") and "calculate-area" in ref.symbol["name"]: found_relevant_references = True break assert found_relevant_references, f"Should have found calculate-area referencing multiply, but got: {result}" class TestProjectBasics: @pytest.mark.parametrize("project", [Language.CLOJURE], indirect=True) def test_retrieve_content_around_line(self, project: Project): """Test retrieving content around specific lines""" # Test retrieving content around the greet function definition (line 2) result = project.retrieve_content_around_line(CORE_PATH, 2, 2) assert result is not None, "Should retrieve content around line 2" content_str = result.to_display_string() assert "greet" in content_str, "Should contain the greet function definition" assert "defn" in content_str, "Should contain defn keyword" # Test retrieving content around multiply function (around line 13) result = project.retrieve_content_around_line(CORE_PATH, 13, 1) assert result is not None, "Should retrieve content around line 13" content_str = result.to_display_string() assert "multiply" in content_str, "Should contain multiply function" @pytest.mark.parametrize("project", [Language.CLOJURE], indirect=True) def test_search_files_for_pattern(self, project: Project) -> None: result = project.search_source_files_for_pattern("defn.*greet") assert result is not None, "Pattern search should return results" assert len(result) > 0, "Should find at least one match for 'defn.*greet'" core_matches = [match for match in result if match.source_file_path and "core.clj" in match.source_file_path] assert len(core_matches) > 0, "Should find greet function in core.clj" result = project.search_source_files_for_pattern(":require") assert result is not None, "Should find require statements" utils_matches = [match for match in result if match.source_file_path and "utils.clj" in match.source_file_path] assert len(utils_matches) > 0, "Should find require statement in utils.clj" ================================================ FILE: test/solidlsp/cpp/__init__.py ================================================ ================================================ FILE: test/solidlsp/cpp/test_cpp_basic.py ================================================ """ Basic tests for C/C++ language server integration (clangd and ccls). This module tests both Language.CPP (clangd) and Language.CPP_CCLS (ccls) using the same test repository. Tests are skipped if the respective language server is not available. """ import os import pathlib import shutil import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils def _ccls_available() -> bool: return shutil.which("ccls") is not None _cpp_servers: list[Language] = [Language.CPP] if _ccls_available(): _cpp_servers.append(Language.CPP_CCLS) @pytest.mark.cpp @pytest.mark.skipif(not _cpp_servers, reason="No C++ language server (clangd or ccls) available") class TestCppLanguageServer: """Tests for C/C++ language servers (clangd and ccls).""" @pytest.mark.parametrize("language_server", _cpp_servers, indirect=True) def test_find_symbol(self, language_server: SolidLanguageServer) -> None: """Test that symbol tree contains expected functions.""" symbols = language_server.request_full_symbol_tree() assert SymbolUtils.symbol_tree_contains_name(symbols, "add"), "Function 'add' not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "main"), "Function 'main' not found in symbol tree" @pytest.mark.parametrize("language_server", _cpp_servers, indirect=True) def test_get_document_symbols(self, language_server: SolidLanguageServer) -> None: """Test document symbols for a.cpp.""" file_path = os.path.join("a.cpp") symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() # Flatten nested structure if needed symbol_list = symbols[0] if symbols and isinstance(symbols[0], list) else symbols names = [s.get("name") for s in symbol_list] assert "main" in names, f"Expected 'main' in document symbols, got: {names}" @pytest.mark.parametrize("language_server", _cpp_servers, indirect=True) def test_find_referencing_symbols_across_files(self, language_server: SolidLanguageServer) -> None: """Test finding references to 'add' function across files.""" # Locate 'add' in b.cpp file_path = os.path.join("b.cpp") symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() symbol_list = symbols[0] if symbols and isinstance(symbols[0], list) else symbols add_symbol = None for sym in symbol_list: if sym.get("name") == "add": add_symbol = sym break assert add_symbol is not None, "Could not find 'add' function symbol in b.cpp" sel_start = add_symbol["selectionRange"]["start"] refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) ref_files = [ref.get("relativePath", "") for ref in refs] assert any("a.cpp" in ref_file for ref_file in ref_files), f"Should find reference in a.cpp, {refs=}" # Verify second call returns same results (stability check) def _ref_key(ref: dict) -> tuple: rp = ref.get("relativePath", "") rng = ref.get("range") or {} s = rng.get("start") or {} e = rng.get("end") or {} return ( rp, s.get("line", -1), s.get("character", -1), e.get("line", -1), e.get("character", -1), ) refs2 = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) assert sorted(map(_ref_key, refs2)) == sorted(map(_ref_key, refs)), "Reference results should be stable across calls" @pytest.mark.parametrize("language_server", _cpp_servers, indirect=True) @pytest.mark.xfail( strict=True, reason=("Both clangd and ccls do not support cross-file references for newly created files that were never opened by the LS."), ) def test_find_references_in_newly_written_file(self, language_server: SolidLanguageServer) -> None: # Create a new file that references the 'add' function from b.cpp new_file_path = os.path.join("temp_new_file.cpp") new_file_abs_path = os.path.join(language_server.repository_root_path, new_file_path) try: # Write the new file with a reference to add() with open(new_file_abs_path, "w", encoding="utf-8") as f: f.write( """ #include "b.hpp" int use_add() { int result = add(5, 3); return result; } """ ) # Open the new file so clangd knows about it with language_server.open_file(new_file_path): # Request document symbols to ensure the file is fully loaded by clangd new_file_symbols = language_server.request_document_symbols(new_file_path).get_all_symbols_and_roots() assert new_file_symbols, "New file should have symbols" # Verify the file stays in open_file_buffers after the context exits uri = pathlib.Path(new_file_abs_path).as_uri() assert uri in language_server.open_file_buffers, "File should remain in open_file_buffers" # Find the 'add' symbol in b.cpp b_file_path = os.path.join("b.cpp") symbols = language_server.request_document_symbols(b_file_path).get_all_symbols_and_roots() symbol_list = symbols[0] if symbols and isinstance(symbols[0], list) else symbols add_symbol = None for sym in symbol_list: if sym.get("name") == "add": add_symbol = sym break assert add_symbol is not None, "Could not find 'add' function symbol in b.cpp" # Request references for 'add' sel_start = add_symbol["selectionRange"]["start"] refs = language_server.request_references(b_file_path, sel_start["line"], sel_start["character"]) ref_files = [ref.get("relativePath", "") for ref in refs] # Should find reference in the newly written file assert any( "temp_new_file.cpp" in ref_file for ref_file in ref_files ), f"Should find reference in newly written temp_new_file.cpp, {ref_files=}" finally: # Clean up the new file if os.path.exists(new_file_abs_path): os.remove(new_file_abs_path) ================================================ FILE: test/solidlsp/csharp/test_csharp_basic.py ================================================ import os import tempfile from pathlib import Path from typing import cast from unittest.mock import Mock, patch import pytest from sensai.util import logging from serena.util.logging import SuspendedLoggersContext from solidlsp import SolidLanguageServer from solidlsp.language_servers.csharp_language_server import ( CSharpLanguageServer, breadth_first_file_scan, find_solution_or_project_file, ) from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.ls_utils import SymbolUtils from solidlsp.settings import SolidLSPSettings @pytest.mark.csharp class TestCSharpLanguageServer: @pytest.mark.parametrize("language_server", [Language.CSHARP], indirect=True) def test_find_symbol(self, language_server: SolidLanguageServer) -> None: """Test finding symbols in the full symbol tree.""" symbols = language_server.request_full_symbol_tree() assert SymbolUtils.symbol_tree_contains_name(symbols, "Program"), "Program class not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "Calculator"), "Calculator class not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "Add"), "Add method not found in symbol tree" @pytest.mark.parametrize("language_server", [Language.CSHARP], indirect=True) def test_get_document_symbols(self, language_server: SolidLanguageServer) -> None: """Test getting document symbols from a C# file.""" file_path = os.path.join("Program.cs") symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() # Check that we have symbols assert len(symbols) > 0 # Flatten the symbols if they're nested if isinstance(symbols[0], list): symbols = symbols[0] # Look for expected classes class_names = [s.get("name") for s in symbols if s.get("kind") == 5] # 5 is class assert "Program" in class_names assert "Calculator" in class_names @pytest.mark.parametrize("language_server", [Language.CSHARP], indirect=True) def test_find_referencing_symbols(self, language_server: SolidLanguageServer) -> None: """Test finding references using symbol selection range.""" file_path = os.path.join("Program.cs") symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() add_symbol = None # Handle nested symbol structure symbol_list = symbols[0] if symbols and isinstance(symbols[0], list) else symbols for sym in symbol_list: # Symbol names are normalized to base form (e.g., "Add" not "Add(int, int) : int") if sym.get("name") == "Add": add_symbol = sym break assert add_symbol is not None, "Could not find 'Add' method symbol in Program.cs" sel_start = add_symbol["selectionRange"]["start"] refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"] + 1) assert any( "Program.cs" in ref.get("relativePath", "") for ref in refs ), "Program.cs should reference Add method (tried all positions in selectionRange)" @pytest.mark.parametrize("language_server", [Language.CSHARP], indirect=True) def test_nested_namespace_symbols(self, language_server: SolidLanguageServer) -> None: """Test getting symbols from nested namespace.""" file_path = os.path.join("Models", "Person.cs") symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() # Check that we have symbols assert len(symbols) > 0 # Flatten the symbols if they're nested if isinstance(symbols[0], list): symbols = symbols[0] # Check that we have the Person class assert any(s.get("name") == "Person" and s.get("kind") == 5 for s in symbols) # Check for properties and methods (names are normalized to base form) symbol_names = [s.get("name") for s in symbols] assert "Name" in symbol_names, "Name property not found" assert "Age" in symbol_names, "Age property not found" assert "Email" in symbol_names, "Email property not found" assert "ToString" in symbol_names, "ToString method not found" assert "IsAdult" in symbol_names, "IsAdult method not found" @pytest.mark.parametrize("language_server", [Language.CSHARP], indirect=True) def test_find_referencing_symbols_across_files(self, language_server: SolidLanguageServer) -> None: """Test finding references to Calculator.Subtract method across files.""" # First, find the Subtract method in Program.cs file_path = os.path.join("Program.cs") symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() # Flatten the symbols if they're nested symbol_list = symbols[0] if symbols and isinstance(symbols[0], list) else symbols subtract_symbol = None for sym in symbol_list: # Symbol names are normalized to base form (e.g., "Subtract" not "Subtract(int, int) : int") if sym.get("name") == "Subtract": subtract_symbol = sym break assert subtract_symbol is not None, "Could not find 'Subtract' method symbol in Program.cs" # Get references to the Subtract method sel_start = subtract_symbol["selectionRange"]["start"] refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"] + 1) # Should find references where the method is called ref_files = cast(list[str], [ref.get("relativePath", "") for ref in refs]) print(f"Found references: {refs}") print(f"Reference files: {ref_files}") # Check that we have reference in Models/Person.cs where Calculator.Subtract is called # Note: New Roslyn version doesn't include the definition itself as a reference (more correct behavior) assert any( os.path.join("Models", "Person.cs") in ref_file for ref_file in ref_files ), "Should find reference in Models/Person.cs where Calculator.Subtract is called" assert len(refs) > 0, "Should find at least one reference" # check for a second time, since the first call may trigger initialization and change the state of the LS refs_second_call = language_server.request_references(file_path, sel_start["line"], sel_start["character"] + 1) assert refs_second_call == refs, "Second call to request_references should return the same results" @pytest.mark.parametrize("language_server", [Language.CSHARP], indirect=True) def test_hover_includes_type_information(self, language_server: SolidLanguageServer) -> None: """Test that hover information is available and includes type information.""" file_path = os.path.join("Models", "Person.cs") # Open the file first language_server.open_file(file_path) # Test 1: Hover over the Name property (line 6, column 23 - on "Name") # Source: public string Name { get; set; } hover_info = language_server.request_hover(file_path, 6, 23) # Verify hover returns content assert hover_info is not None, "Hover should return information for Name property" assert isinstance(hover_info, dict), "Hover should be a dict" assert "contents" in hover_info, "Hover should have contents" contents = hover_info["contents"] assert isinstance(contents, dict), "Hover contents should be a dict" assert "value" in contents, "Hover contents should have value" hover_text = contents["value"] # Verify the hover contains property signature with type assert "string" in hover_text, f"Hover should include 'string' type, got: {hover_text}" assert "Name" in hover_text, f"Hover should include 'Name' property name, got: {hover_text}" # Test 2: Hover over the IsAdult method (line 22, column 21 - on "IsAdult") # Source: public bool IsAdult() hover_method = language_server.request_hover(file_path, 22, 21) # Verify method hover returns content assert hover_method is not None, "Hover should return information for IsAdult method" assert isinstance(hover_method, dict), "Hover should be a dict" assert "contents" in hover_method, "Hover should have contents" contents = hover_method["contents"] assert isinstance(contents, dict), "Hover contents should be a dict" assert "value" in contents, "Hover contents should have value" method_hover_text = contents["value"] # Verify the hover contains method signature with return type assert "bool" in method_hover_text, f"Hover should include 'bool' return type, got: {method_hover_text}" assert "IsAdult" in method_hover_text, f"Hover should include 'IsAdult' method name, got: {method_hover_text}" @pytest.mark.csharp class TestCSharpSolutionProjectOpening: """Test C# language server solution and project opening functionality.""" def test_breadth_first_file_scan(self): """Test that breadth_first_file_scan finds files in breadth-first order.""" with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) # Create test directory structure (temp_path / "file1.txt").touch() (temp_path / "subdir1").mkdir() (temp_path / "subdir1" / "file2.txt").touch() (temp_path / "subdir2").mkdir() (temp_path / "subdir2" / "file3.txt").touch() (temp_path / "subdir1" / "subdir3").mkdir() (temp_path / "subdir1" / "subdir3" / "file4.txt").touch() # Scan files files = list(breadth_first_file_scan(str(temp_path))) filenames = [os.path.basename(f) for f in files] # Should find all files assert len(files) == 4 assert "file1.txt" in filenames assert "file2.txt" in filenames assert "file3.txt" in filenames assert "file4.txt" in filenames # file1.txt should be found first (breadth-first) assert filenames[0] == "file1.txt" def test_find_solution_or_project_file_with_solution(self): """Test that find_solution_or_project_file prefers .sln files.""" with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) # Create both .sln and .csproj files solution_file = temp_path / "MySolution.sln" project_file = temp_path / "MyProject.csproj" solution_file.touch() project_file.touch() result = find_solution_or_project_file(str(temp_path)) # Should prefer .sln file assert result == str(solution_file) def test_find_solution_or_project_file_with_project_only(self): """Test that find_solution_or_project_file falls back to .csproj files.""" with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) # Create only .csproj file project_file = temp_path / "MyProject.csproj" project_file.touch() result = find_solution_or_project_file(str(temp_path)) # Should return .csproj file assert result == str(project_file) def test_find_solution_or_project_file_with_nested_files(self): """Test that find_solution_or_project_file finds files in subdirectories.""" with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) # Create nested structure (temp_path / "src").mkdir() solution_file = temp_path / "src" / "MySolution.sln" solution_file.touch() result = find_solution_or_project_file(str(temp_path)) # Should find nested .sln file assert result == str(solution_file) def test_find_solution_or_project_file_returns_none_when_no_files(self): """Test that find_solution_or_project_file returns None when no .sln or .csproj files exist.""" with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) # Create some other files (temp_path / "readme.txt").touch() (temp_path / "other.cs").touch() result = find_solution_or_project_file(str(temp_path)) # Should return None assert result is None def test_find_solution_or_project_file_prefers_solution_breadth_first(self): """Test that solution files are preferred even when deeper in the tree.""" with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) # Create .csproj at root and .sln in subdirectory project_file = temp_path / "MyProject.csproj" project_file.touch() (temp_path / "src").mkdir() solution_file = temp_path / "src" / "MySolution.sln" solution_file.touch() result = find_solution_or_project_file(str(temp_path)) # Should still prefer .sln file even though it's deeper assert result == str(solution_file) @patch("solidlsp.language_servers.csharp_language_server.CSharpLanguageServer.DependencyProvider._ensure_server_installed") @patch("solidlsp.language_servers.csharp_language_server.CSharpLanguageServer._start_server") def test_csharp_language_server_logs_solution_discovery(self, mock_start_server, mock_ensure_server_installed): """Test that CSharpLanguageServer logs solution/project discovery during initialization.""" mock_ensure_server_installed.return_value = ("/usr/bin/dotnet", "/path/to/server.dll") # Create test directory with solution file with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) solution_file = temp_path / "TestSolution.sln" solution_file.touch() mock_config = Mock(spec=LanguageServerConfig) mock_config.ignored_paths = [] # Create CSharpLanguageServer instance mock_settings = Mock(spec=SolidLSPSettings) mock_settings.ls_resources_dir = "/tmp/test_ls_resources" mock_settings.project_data_path = str(temp_path / "project_data") with SuspendedLoggersContext(): logging.getLogger().setLevel(logging.DEBUG) with logging.MemoryLoggerContext() as mem_log: CSharpLanguageServer(mock_config, str(temp_path), mock_settings) # Verify that logger was called with solution file discovery expected_log_msg = f"Found solution/project file: {solution_file}" assert expected_log_msg in mem_log.get_log() @patch("solidlsp.language_servers.csharp_language_server.CSharpLanguageServer.DependencyProvider._ensure_server_installed") @patch("solidlsp.language_servers.csharp_language_server.CSharpLanguageServer._start_server") def test_csharp_language_server_logs_no_solution_warning(self, mock_start_server, mock_ensure_server_installed): """Test that CSharpLanguageServer logs warning when no solution/project files are found.""" # Mock the server installation mock_ensure_server_installed.return_value = ("/usr/bin/dotnet", "/path/to/server.dll") # Create empty test directory with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) # Mock logger to capture log messages mock_config = Mock(spec=LanguageServerConfig) mock_config.ignored_paths = [] mock_settings = Mock(spec=SolidLSPSettings) mock_settings.ls_resources_dir = "/tmp/test_ls_resources" mock_settings.project_data_path = str(temp_path / "project_data") # Create CSharpLanguageServer instance with SuspendedLoggersContext(): logging.getLogger().setLevel(logging.DEBUG) with logging.MemoryLoggerContext() as mem_log: CSharpLanguageServer(mock_config, str(temp_path), mock_settings) # Verify that logger was called with warning about no solution/project files expected_log_msg = "No .sln/.slnx or .csproj file found, language server will attempt auto-discovery" assert expected_log_msg in mem_log.get_log() def test_solution_and_project_opening_with_real_test_repo(self): """Test solution and project opening with the actual C# test repository.""" # Get the C# test repo path test_repo_path = Path(__file__).parent.parent.parent / "resources" / "repos" / "csharp" / "test_repo" if not test_repo_path.exists(): pytest.skip("C# test repository not found") # Test solution/project discovery in the real test repo result = find_solution_or_project_file(str(test_repo_path)) # Should find either .sln or .csproj file assert result is not None assert result.endswith((".sln", ".csproj")) # Verify the file actually exists assert os.path.exists(result) ================================================ FILE: test/solidlsp/csharp/test_csharp_nuget_download.py ================================================ """Tests for C# language server NuGet package download from NuGet.org.""" import tempfile from unittest.mock import patch import pytest from solidlsp.language_servers.common import RuntimeDependency from solidlsp.language_servers.csharp_language_server import CSharpLanguageServer from solidlsp.settings import SolidLSPSettings @pytest.mark.csharp class TestNuGetOrgDownload: """Test downloading Roslyn language server packages from NuGet.org.""" def test_download_nuget_package_uses_direct_url(self): """Test that _download_nuget_package uses the URL from RuntimeDependency directly.""" with tempfile.TemporaryDirectory() as temp_dir: # Create a RuntimeDependency with a NuGet.org URL test_dependency = RuntimeDependency( id="TestPackage", description="Test package from NuGet.org", package_name="roslyn-language-server.linux-x64", package_version="5.5.0-2.26078.4", url="https://www.nuget.org/api/v2/package/roslyn-language-server.linux-x64/5.5.0-2.26078.4", platform_id="linux-x64", archive_type="nupkg", binary_name="Microsoft.CodeAnalysis.LanguageServer.dll", extract_path="content/LanguageServer/linux-x64", ) # Mock the dependency provider mock_settings = SolidLSPSettings() custom_settings = SolidLSPSettings.CustomLSSettings({}) dependency_provider = CSharpLanguageServer.DependencyProvider( custom_settings=custom_settings, ls_resources_dir=temp_dir, solidlsp_settings=mock_settings, repository_root_path="/fake/repo", ) # Mock urllib.request.urlretrieve to capture the URL being used with patch("solidlsp.language_servers.csharp_language_server.urllib.request.urlretrieve") as mock_retrieve: with patch("solidlsp.language_servers.csharp_language_server.SafeZipExtractor"): try: dependency_provider._download_nuget_package(test_dependency) except Exception: # Expected to fail since we're mocking, but we want to check the URL pass # Verify that urlretrieve was called with the NuGet.org URL assert mock_retrieve.called, "urlretrieve should be called" called_url = mock_retrieve.call_args[0][0] assert called_url == test_dependency.url, f"Should use URL from RuntimeDependency: {test_dependency.url}" assert "nuget.org" in called_url, "Should use NuGet.org URL" assert "azure" not in called_url.lower(), "Should not use Azure feed" def test_runtime_dependencies_use_nuget_org_urls(self): """Test that _RUNTIME_DEPENDENCIES are configured with NuGet.org URLs.""" from solidlsp.language_servers.csharp_language_server import _RUNTIME_DEPENDENCIES # Check language server dependencies lang_server_deps = [dep for dep in _RUNTIME_DEPENDENCIES if dep.id == "CSharpLanguageServer"] assert len(lang_server_deps) == 6, "Should have 6 language server platform variants" for dep in lang_server_deps: # Verify package name uses roslyn-language-server assert dep.package_name is not None, f"Package name should be set for {dep.platform_id}" assert dep.package_name.startswith( "roslyn-language-server." ), f"Package name should start with 'roslyn-language-server.' but got: {dep.package_name}" # Verify version is the newer NuGet.org version assert dep.package_version == "5.5.0-2.26078.4", f"Should use NuGet.org version 5.5.0-2.26078.4, got: {dep.package_version}" # Verify URL points to NuGet.org assert dep.url is not None, f"URL should be set for {dep.platform_id}" assert "nuget.org" in dep.url, f"URL should point to nuget.org, got: {dep.url}" assert "azure" not in dep.url.lower(), f"URL should not point to Azure feed, got: {dep.url}" def test_download_method_does_not_call_azure_feed(self): """Test that the new download method does not attempt to access Azure feed.""" with tempfile.TemporaryDirectory() as temp_dir: test_dependency = RuntimeDependency( id="TestPackage", description="Test package", package_name="roslyn-language-server.linux-x64", package_version="5.5.0-2.26078.4", url="https://www.nuget.org/api/v2/package/roslyn-language-server.linux-x64/5.5.0-2.26078.4", platform_id="linux-x64", archive_type="nupkg", binary_name="test.dll", ) mock_settings = SolidLSPSettings() custom_settings = SolidLSPSettings.CustomLSSettings({}) dependency_provider = CSharpLanguageServer.DependencyProvider( custom_settings=custom_settings, ls_resources_dir=temp_dir, solidlsp_settings=mock_settings, repository_root_path="/fake/repo", ) # Mock urllib.request.urlopen to track if Azure feed is accessed with patch("solidlsp.language_servers.csharp_language_server.urllib.request.urlopen") as mock_urlopen: with patch("solidlsp.language_servers.csharp_language_server.urllib.request.urlretrieve"): with patch("solidlsp.language_servers.csharp_language_server.SafeZipExtractor"): try: dependency_provider._download_nuget_package(test_dependency) except Exception: pass # Verify that urlopen was NOT called (no service index lookup) assert not mock_urlopen.called, "Should not call urlopen for Azure service index lookup" ================================================ FILE: test/solidlsp/dart/__init__.py ================================================ ================================================ FILE: test/solidlsp/dart/test_dart_basic.py ================================================ import os from pathlib import Path import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind from solidlsp.ls_utils import SymbolUtils @pytest.mark.dart class TestDartLanguageServer: @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) @pytest.mark.parametrize("repo_path", [Language.DART], indirect=True) def test_ls_is_running(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test that the language server starts and stops successfully.""" # The fixture already handles start and stop assert language_server.is_running() assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve() @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) @pytest.mark.parametrize("repo_path", [Language.DART], indirect=True) def test_find_definition_within_file(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test finding definition of a method within the same file.""" # In lib/main.dart: # Line 105: final result1 = calc.add(5, 3); // Reference to add method # Line 12: int add(int a, int b) { // Definition of add method # Find definition of 'add' method from its usage main_dart_path = str(repo_path / "lib" / "main.dart") # Position: calc.add(5, 3) - cursor on 'add' # Line 105 (1-indexed) = line 104 (0-indexed), char position around 22 definition_location_list = language_server.request_definition(main_dart_path, 104, 22) assert definition_location_list, f"Expected non-empty definition_location_list but got {definition_location_list=}" assert len(definition_location_list) >= 1 definition_location = definition_location_list[0] assert definition_location["uri"].endswith("main.dart") # Definition of add method should be around line 11 (0-indexed) # But language server may return different positions assert definition_location["range"]["start"]["line"] >= 0 @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) @pytest.mark.parametrize("repo_path", [Language.DART], indirect=True) def test_find_definition_across_files(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test finding definition across different files.""" # Test finding definition of MathHelper class which is in helper.dart # In lib/main.dart line 50: MathHelper.power(step1, 2) main_dart_path = str(repo_path / "lib" / "main.dart") # Position: MathHelper.power(step1, 2) - cursor on 'MathHelper' # Line 50 (1-indexed) = line 49 (0-indexed), char position around 18 definition_location_list = language_server.request_definition(main_dart_path, 49, 18) # Skip the test if language server doesn't find cross-file references # This is acceptable for a basic test - the important thing is that LS is working if not definition_location_list: pytest.skip("Language server doesn't support cross-file definition lookup for this case") assert len(definition_location_list) >= 1 definition_location = definition_location_list[0] assert definition_location["uri"].endswith("helper.dart") assert definition_location["range"]["start"]["line"] >= 0 @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) @pytest.mark.parametrize("repo_path", [Language.DART], indirect=True) def test_find_definition_class_method(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test finding definition of a class method.""" # In lib/main.dart: # Line 50: final step2 = MathHelper.power(step1, 2); // Reference to MathHelper.power method # In lib/helper.dart: # Line 14: static double power(double base, int exponent) { // Definition of power method main_dart_path = str(repo_path / "lib" / "main.dart") # Position: MathHelper.power(step1, 2) - cursor on 'power' # Line 50 (1-indexed) = line 49 (0-indexed), char position around 30 definition_location_list = language_server.request_definition(main_dart_path, 49, 30) assert definition_location_list, f"Expected non-empty definition_location_list but got {definition_location_list=}" assert len(definition_location_list) >= 1 definition_location = definition_location_list[0] assert definition_location["uri"].endswith("helper.dart") # Definition of power method should be around line 13 (0-indexed) assert 12 <= definition_location["range"]["start"]["line"] <= 16 @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) @pytest.mark.parametrize("repo_path", [Language.DART], indirect=True) def test_find_references_within_file(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test finding references to a method within the same file.""" main_dart_path = str(repo_path / "lib" / "main.dart") # Find references to the 'add' method from its definition # Line 12: int add(int a, int b) { // Definition of add method # Line 105: final result1 = calc.add(5, 3); // Usage of add method references = language_server.request_references(main_dart_path, 11, 6) # cursor on 'add' in definition assert references, f"Expected non-empty references but got {references=}" # Should find at least the usage of add method assert len(references) >= 1 # Check that we have a reference in main.dart main_dart_references = [ref for ref in references if ref["uri"].endswith("main.dart")] assert len(main_dart_references) >= 1 @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) @pytest.mark.parametrize("repo_path", [Language.DART], indirect=True) def test_find_references_across_files(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test finding references across different files.""" helper_dart_path = str(repo_path / "lib" / "helper.dart") # Find references to the 'subtract' function from its definition in helper.dart # Definition is in helper.dart, usage is in main.dart references = language_server.request_references(helper_dart_path, 4, 4) # cursor on 'subtract' in definition assert references, f"Expected non-empty references for subtract function but got {references=}" # Should find references in main.dart main_dart_references = [ref for ref in references if ref["uri"].endswith("main.dart")] assert len(main_dart_references) >= 1 @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) @pytest.mark.parametrize("repo_path", [Language.DART], indirect=True) def test_find_definition_constructor(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test finding definition of a constructor call.""" main_dart_path = str(repo_path / "lib" / "main.dart") # In lib/main.dart: # Line 104: final calc = Calculator(); // Reference to Calculator constructor # Line 4: class Calculator { // Definition of Calculator class definition_location_list = language_server.request_definition(main_dart_path, 103, 18) # cursor on 'Calculator' assert definition_location_list, f"Expected non-empty definition_location_list but got {definition_location_list=}" assert len(definition_location_list) >= 1 definition_location = definition_location_list[0] assert definition_location["uri"].endswith("main.dart") # Definition of Calculator class should be around line 3 (0-indexed) assert 3 <= definition_location["range"]["start"]["line"] <= 7 @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) @pytest.mark.parametrize("repo_path", [Language.DART], indirect=True) def test_find_definition_import(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test finding definition through imports.""" models_dart_path = str(repo_path / "lib" / "models.dart") # Test finding definition of User class name where it's used # In lib/models.dart line 27 (constructor): User(this.id, this.name, this.email, this._age); definition_location_list = language_server.request_definition(models_dart_path, 26, 2) # cursor on 'User' in constructor # Skip if language server doesn't find definition in this case if not definition_location_list: pytest.skip("Language server doesn't support definition lookup for this case") assert len(definition_location_list) >= 1 definition_location = definition_location_list[0] # Language server might return SDK files instead of local files # This is acceptable behavior - the important thing is that it found a definition assert "dart" in definition_location["uri"].lower() @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) def test_find_symbol(self, language_server: SolidLanguageServer) -> None: """Test finding symbols in the full symbol tree.""" symbols = language_server.request_full_symbol_tree() assert SymbolUtils.symbol_tree_contains_name(symbols, "Calculator"), "Calculator class not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "add"), "add method not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "subtract"), "subtract function not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "MathHelper"), "MathHelper class not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "User"), "User class not found in symbol tree" @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) def test_find_referencing_symbols(self, language_server: SolidLanguageServer) -> None: """Test finding references using symbol selection range.""" file_path = os.path.join("lib", "main.dart") symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() # Handle nested symbol structure - symbols can be nested in lists symbol_list = symbols[0] if symbols and isinstance(symbols[0], list) else symbols # Find the 'add' method symbol in Calculator class add_symbol = None for sym in symbol_list: if sym.get("name") == "add": add_symbol = sym break # Check for nested symbols (methods inside classes) if "children" in sym and sym.get("name") == "Calculator": for child in sym["children"]: if child.get("name") == "add": add_symbol = child break if add_symbol: break assert add_symbol is not None, "Could not find 'add' method symbol in main.dart" sel_start = add_symbol["selectionRange"]["start"] refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) # Check that we found references - at least one should be in main.dart assert any( "main.dart" in ref.get("relativePath", "") or "main.dart" in ref.get("uri", "") for ref in refs ), "main.dart should reference add method (tried all positions in selectionRange)" @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) def test_request_containing_symbol_method(self, language_server: SolidLanguageServer) -> None: """Test request_containing_symbol for a method.""" file_path = os.path.join("lib", "main.dart") # Line 14 is inside the add method body (around 'final result = a + b;') containing_symbol = language_server.request_containing_symbol(file_path, 13, 10, include_body=True) # Verify that we found the containing symbol if containing_symbol is not None: assert containing_symbol["name"] == "add" assert containing_symbol["kind"] == SymbolKind.Method if "body" in containing_symbol: body = containing_symbol["body"].get_text() assert "add" in body or "final result" in body @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) def test_request_containing_symbol_class(self, language_server: SolidLanguageServer) -> None: """Test request_containing_symbol for a class.""" file_path = os.path.join("lib", "main.dart") # Line 4 is the Calculator class definition line containing_symbol = language_server.request_containing_symbol(file_path, 4, 6) # Verify that we found the containing symbol if containing_symbol is not None: assert containing_symbol["name"] == "Calculator" assert containing_symbol["kind"] == SymbolKind.Class @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) def test_request_containing_symbol_nested(self, language_server: SolidLanguageServer) -> None: """Test request_containing_symbol with nested scopes.""" file_path = os.path.join("lib", "main.dart") # Line 14 is inside the add method inside Calculator class containing_symbol = language_server.request_containing_symbol(file_path, 13, 20) # Verify that we found the innermost containing symbol (the method) if containing_symbol is not None: assert containing_symbol["name"] == "add" assert containing_symbol["kind"] == SymbolKind.Method @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) def test_request_defining_symbol_variable(self, language_server: SolidLanguageServer) -> None: """Test request_defining_symbol for a variable usage.""" file_path = os.path.join("lib", "main.dart") # Line 14 contains 'final result = a + b;' - test position on 'result' defining_symbol = language_server.request_defining_symbol(file_path, 13, 10) # The defining symbol might be the variable itself or the containing method # This is acceptable behavior - different language servers handle this differently if defining_symbol is not None: assert defining_symbol.get("name") in ["result", "add"] if defining_symbol.get("name") == "add": assert defining_symbol.get("kind") == SymbolKind.Method.value @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) def test_request_defining_symbol_imported_class(self, language_server: SolidLanguageServer) -> None: """Test request_defining_symbol for an imported class/function.""" file_path = os.path.join("lib", "main.dart") # Line 20 references 'subtract' which was imported from helper.dart defining_symbol = language_server.request_defining_symbol(file_path, 19, 18) # Verify that we found the defining symbol - this should be the subtract function from helper.dart if defining_symbol is not None: assert defining_symbol.get("name") == "subtract" # Could be Function or Method depending on language server interpretation assert defining_symbol.get("kind") in [SymbolKind.Function.value, SymbolKind.Method.value] @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) def test_request_defining_symbol_class_method(self, language_server: SolidLanguageServer) -> None: """Test request_defining_symbol for a static class method.""" file_path = os.path.join("lib", "main.dart") # Line 50 references MathHelper.power - test position on 'power' defining_symbol = language_server.request_defining_symbol(file_path, 49, 30) # Verify that we found the defining symbol - should be the power method if defining_symbol is not None: assert defining_symbol.get("name") == "power" assert defining_symbol.get("kind") == SymbolKind.Method.value @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) def test_request_document_symbols(self, language_server: SolidLanguageServer) -> None: """Test getting document symbols from a Dart file.""" file_path = os.path.join("lib", "main.dart") symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() # Check that we have symbols assert len(symbols) > 0 # Flatten the symbols if they're nested symbol_list = symbols[0] if symbols and isinstance(symbols[0], list) else symbols # Look for expected classes and methods symbol_names = [s.get("name") for s in symbol_list] assert "Calculator" in symbol_names # Check for nested symbols (methods inside classes) - optional calculator_symbol = next((s for s in symbol_list if s.get("name") == "Calculator"), None) if calculator_symbol and "children" in calculator_symbol and calculator_symbol["children"]: method_names = [child.get("name") for child in calculator_symbol["children"]] # If children are populated, we should find the add method assert "add" in method_names else: # Some language servers may not populate children in document symbols # This is acceptable behavior - the important thing is we found the class pass @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) def test_request_referencing_symbols_comprehensive(self, language_server: SolidLanguageServer) -> None: """Test comprehensive referencing symbols functionality.""" file_path = os.path.join("lib", "main.dart") symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() # Handle nested symbol structure symbol_list = symbols[0] if symbols and isinstance(symbols[0], list) else symbols # Find Calculator class and test its references calculator_symbol = None for sym in symbol_list: if sym.get("name") == "Calculator": calculator_symbol = sym break if calculator_symbol and "selectionRange" in calculator_symbol: sel_start = calculator_symbol["selectionRange"]["start"] refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) # Should find references to Calculator (constructor calls, etc.) if refs: # Verify the structure of referencing symbols for ref in refs: assert "uri" in ref or "relativePath" in ref if "range" in ref: assert "start" in ref["range"] assert "end" in ref["range"] @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) def test_cross_file_symbol_resolution(self, language_server: SolidLanguageServer) -> None: """Test symbol resolution across multiple files.""" helper_file_path = os.path.join("lib", "helper.dart") # Test finding references to subtract function from helper.dart in main.dart helper_symbols = language_server.request_document_symbols(helper_file_path).get_all_symbols_and_roots() symbol_list = helper_symbols[0] if helper_symbols and isinstance(helper_symbols[0], list) else helper_symbols subtract_symbol = next((s for s in symbol_list if s.get("name") == "subtract"), None) if subtract_symbol and "selectionRange" in subtract_symbol: sel_start = subtract_symbol["selectionRange"]["start"] refs = language_server.request_references(helper_file_path, sel_start["line"], sel_start["character"]) # Should find references in main.dart main_dart_refs = [ref for ref in refs if "main.dart" in ref.get("uri", "") or "main.dart" in ref.get("relativePath", "")] # Note: This may not always work depending on language server capabilities # So we don't assert - just verify the structure if we get results if main_dart_refs: for ref in main_dart_refs: assert "range" in ref or "location" in ref ================================================ FILE: test/solidlsp/elixir/__init__.py ================================================ def _test_expert_available() -> str: """Test if Expert is available and return error reason if not.""" # Try to import and check Elixir availability try: from solidlsp.language_servers.elixir_tools.elixir_tools import ElixirTools # Check if Elixir is installed elixir_version = ElixirTools._get_elixir_version() if not elixir_version: return "Elixir is not installed or not in PATH" return "" # No error, Expert should be available except ImportError as e: return f"Failed to import ElixirTools: {e}" except Exception as e: return f"Error checking Expert availability: {e}" EXPERT_UNAVAILABLE_REASON = _test_expert_available() EXPERT_UNAVAILABLE = bool(EXPERT_UNAVAILABLE_REASON) ================================================ FILE: test/solidlsp/elixir/conftest.py ================================================ """ Elixir-specific test configuration and fixtures. """ import os import subprocess import time from pathlib import Path import pytest def ensure_elixir_test_repo_compiled(repo_path: str) -> None: """Ensure the Elixir test repository dependencies are installed and project is compiled. Next LS requires the project to be fully compiled and indexed before providing complete references and symbol resolution. This function: 1. Installs dependencies via 'mix deps.get' 2. Compiles the project via 'mix compile' This is essential in CI environments where dependencies aren't pre-installed. Args: repo_path: Path to the Elixir project root directory """ # Check if this looks like an Elixir project mix_file = os.path.join(repo_path, "mix.exs") if not os.path.exists(mix_file): return # Check if already compiled (optimization for repeated runs) build_path = os.path.join(repo_path, "_build") deps_path = os.path.join(repo_path, "deps") if os.path.exists(build_path) and os.path.exists(deps_path): print(f"Elixir test repository already compiled in {repo_path}") return try: print("Installing dependencies and compiling Elixir test repository for optimal Next LS performance...") # First, install dependencies with increased timeout for CI print("=" * 60) print("Step 1/2: Installing Elixir dependencies...") print("=" * 60) start_time = time.time() deps_result = subprocess.run( ["mix", "deps.get"], cwd=repo_path, capture_output=True, text=True, timeout=180, check=False, # 3 minutes for dependency installation (CI can be slow) ) deps_duration = time.time() - start_time print(f"Dependencies installation completed in {deps_duration:.2f} seconds") # Always log the output for transparency if deps_result.stdout.strip(): print("Dependencies stdout:") print("-" * 40) print(deps_result.stdout) print("-" * 40) if deps_result.stderr.strip(): print("Dependencies stderr:") print("-" * 40) print(deps_result.stderr) print("-" * 40) if deps_result.returncode != 0: print(f"⚠️ Warning: Dependencies installation failed with exit code {deps_result.returncode}") # Continue anyway - some projects might not have dependencies else: print("✓ Dependencies installed successfully") # Then compile the project with increased timeout for CI print("=" * 60) print("Step 2/2: Compiling Elixir project...") print("=" * 60) start_time = time.time() compile_result = subprocess.run( ["mix", "compile"], cwd=repo_path, capture_output=True, text=True, timeout=300, check=False, # 5 minutes for compilation (Credo compilation can be slow in CI) ) compile_duration = time.time() - start_time print(f"Compilation completed in {compile_duration:.2f} seconds") # Always log the output for transparency if compile_result.stdout.strip(): print("Compilation stdout:") print("-" * 40) print(compile_result.stdout) print("-" * 40) if compile_result.stderr.strip(): print("Compilation stderr:") print("-" * 40) print(compile_result.stderr) print("-" * 40) if compile_result.returncode == 0: print(f"✓ Elixir test repository compiled successfully in {repo_path}") else: print(f"⚠️ Warning: Compilation completed with exit code {compile_result.returncode}") # Still continue - warnings are often non-fatal print("=" * 60) print(f"Total setup time: {time.time() - (start_time - compile_duration - deps_duration):.2f} seconds") print("=" * 60) except subprocess.TimeoutExpired as e: print("=" * 60) print(f"❌ TIMEOUT: Elixir setup timed out after {e.timeout} seconds") print(f"Command: {' '.join(e.cmd)}") print("This may indicate slow CI environment - Next LS may still work but with reduced functionality") # Try to get partial output if available if hasattr(e, "stdout") and e.stdout: print("Partial stdout before timeout:") print("-" * 40) print(e.stdout) print("-" * 40) if hasattr(e, "stderr") and e.stderr: print("Partial stderr before timeout:") print("-" * 40) print(e.stderr) print("-" * 40) print("=" * 60) except FileNotFoundError: print("❌ ERROR: 'mix' command not found - Elixir test repository may not be compiled") print("Please ensure Elixir is installed and available in PATH") except Exception as e: print(f"❌ ERROR: Failed to prepare Elixir test repository: {e}") @pytest.fixture(scope="session", autouse=True) def setup_elixir_test_environment(): """Automatically prepare Elixir test environment for all Elixir tests. This fixture runs once per test session and automatically: 1. Installs dependencies via 'mix deps.get' 2. Compiles the Elixir test repository via 'mix compile' It uses autouse=True so it runs automatically without needing to be explicitly requested by tests. This ensures Next LS has a fully prepared project to work with. Uses generous timeouts (3-5 minutes) to accommodate slow CI environments. All output is logged for transparency and debugging. """ # Get the test repo path relative to this conftest.py file test_repo_path = Path(__file__).parent.parent.parent / "resources" / "repos" / "elixir" / "test_repo" ensure_elixir_test_repo_compiled(str(test_repo_path)) return str(test_repo_path) @pytest.fixture(scope="session") def elixir_test_repo_path(setup_elixir_test_environment): """Get the path to the prepared Elixir test repository. This fixture depends on setup_elixir_test_environment to ensure dependencies are installed and compilation has completed before returning the path. """ return setup_elixir_test_environment ================================================ FILE: test/solidlsp/elixir/test_elixir_basic.py ================================================ """ Basic integration tests for the Elixir language server functionality. These tests validate the functionality of the language server APIs like request_references using the test repository. """ import os import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from . import EXPERT_UNAVAILABLE, EXPERT_UNAVAILABLE_REASON # These marks will be applied to all tests in this module pytestmark = [pytest.mark.elixir, pytest.mark.skipif(EXPERT_UNAVAILABLE, reason=f"Next LS not available: {EXPERT_UNAVAILABLE_REASON}")] class TestElixirBasic: """Basic Elixir language server functionality tests.""" @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) def test_request_references_function_definition(self, language_server: SolidLanguageServer): """Test finding references to a function definition.""" file_path = os.path.join("lib", "models.ex") symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() # Find the User module's 'new' function user_new_symbol = None for symbol in symbols[0]: # Top level symbols if symbol.get("name") == "User" and symbol.get("kind") == 2: # Module for child in symbol.get("children", []): if child.get("name", "").startswith("def new(") and child.get("kind") == 12: # Function user_new_symbol = child break break if not user_new_symbol or "selectionRange" not in user_new_symbol: pytest.skip("User.new function or its selectionRange not found") sel_start = user_new_symbol["selectionRange"]["start"] references = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) assert references is not None assert len(references) > 0 # Should find at least one reference (the definition itself) found_definition = any(ref["uri"].endswith("models.ex") for ref in references) assert found_definition, "Should find the function definition" @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) def test_request_references_create_user_function(self, language_server: SolidLanguageServer): """Test finding references to create_user function.""" file_path = os.path.join("lib", "services.ex") symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() # Find the UserService module's 'create_user' function create_user_symbol = None for symbol in symbols[0]: # Top level symbols if symbol.get("name") == "UserService" and symbol.get("kind") == 2: # Module for child in symbol.get("children", []): if child.get("name", "").startswith("def create_user(") and child.get("kind") == 12: # Function create_user_symbol = child break break if not create_user_symbol or "selectionRange" not in create_user_symbol: pytest.skip("UserService.create_user function or its selectionRange not found") sel_start = create_user_symbol["selectionRange"]["start"] references = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) assert references is not None assert len(references) > 0 @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) def test_request_referencing_symbols_function(self, language_server: SolidLanguageServer): """Test finding symbols that reference a specific function.""" file_path = os.path.join("lib", "models.ex") symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() # Find the User module's 'new' function user_new_symbol = None for symbol in symbols[0]: # Top level symbols if symbol.get("name") == "User" and symbol.get("kind") == 2: # Module for child in symbol.get("children", []): if child.get("name", "").startswith("def new(") and child.get("kind") == 12: # Function user_new_symbol = child break break if not user_new_symbol or "selectionRange" not in user_new_symbol: pytest.skip("User.new function or its selectionRange not found") sel_start = user_new_symbol["selectionRange"]["start"] referencing_symbols = language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"]) assert referencing_symbols is not None @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) def test_timeout_enumeration_bug(self, language_server: SolidLanguageServer): """Test that enumeration doesn't timeout (regression test).""" # This should complete without timing out symbols = language_server.request_document_symbols("lib/models.ex").get_all_symbols_and_roots() assert symbols is not None # Test multiple symbol requests in succession for _ in range(3): symbols = language_server.request_document_symbols("lib/services.ex").get_all_symbols_and_roots() assert symbols is not None ================================================ FILE: test/solidlsp/elixir/test_elixir_ignored_dirs.py ================================================ import os from collections.abc import Generator from pathlib import Path import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from test.conftest import start_ls_context from . import EXPERT_UNAVAILABLE, EXPERT_UNAVAILABLE_REASON # These marks will be applied to all tests in this module pytestmark = [pytest.mark.elixir, pytest.mark.skipif(EXPERT_UNAVAILABLE, reason=f"Expert not available: {EXPERT_UNAVAILABLE_REASON}")] # Skip slow tests in CI - they require multiple Expert instances which is too slow IN_CI = bool(os.environ.get("CI") or os.environ.get("GITHUB_ACTIONS")) SKIP_SLOW_IN_CI = pytest.mark.skipif( IN_CI, reason="Slow tests skipped in CI - require multiple Expert instances (~60-90s each)", ) @pytest.fixture(scope="session") def ls_with_ignored_dirs() -> Generator[SolidLanguageServer, None, None]: """Fixture to set up an LS for the elixir test repo with the 'scripts' directory ignored. Uses session scope to avoid restarting Expert for each test. """ ignored_paths = ["scripts", "ignored_dir"] with start_ls_context(language=Language.ELIXIR, ignored_paths=ignored_paths) as ls: yield ls @pytest.mark.slow @SKIP_SLOW_IN_CI def test_symbol_tree_ignores_dir(ls_with_ignored_dirs: SolidLanguageServer): """Tests that request_full_symbol_tree ignores the configured directory. Note: This test uses a separate Expert instance with custom ignored paths, which adds ~60-90s startup time. """ root = ls_with_ignored_dirs.request_full_symbol_tree()[0] root_children = root["children"] children_names = {child["name"] for child in root_children} # Should have lib and test directories, but not scripts or ignored_dir expected_dirs = {"lib", "test"} assert expected_dirs.issubset(children_names), f"Expected {expected_dirs} to be in {children_names}" assert "scripts" not in children_names, f"scripts should not be in {children_names}" assert "ignored_dir" not in children_names, f"ignored_dir should not be in {children_names}" @pytest.mark.slow @SKIP_SLOW_IN_CI def test_find_references_ignores_dir(ls_with_ignored_dirs: SolidLanguageServer): """Tests that find_references ignores the configured directory. Note: This test uses a separate Expert instance with custom ignored paths, which adds ~60-90s startup time. """ # Location of User struct, which is referenced in scripts and ignored_dir definition_file = "lib/models.ex" # Find the User struct definition symbols = ls_with_ignored_dirs.request_document_symbols(definition_file).get_all_symbols_and_roots() user_symbol = None for symbol_group in symbols: user_symbol = next((s for s in symbol_group if "User" in s.get("name", "")), None) if user_symbol: break if not user_symbol or "selectionRange" not in user_symbol: pytest.skip("User symbol not found for reference testing") sel_start = user_symbol["selectionRange"]["start"] references = ls_with_ignored_dirs.request_references(definition_file, sel_start["line"], sel_start["character"]) # Assert that scripts and ignored_dir do not appear in the references assert not any("scripts" in ref["relativePath"] for ref in references), "scripts should be ignored" assert not any("ignored_dir" in ref["relativePath"] for ref in references), "ignored_dir should be ignored" @pytest.mark.slow @SKIP_SLOW_IN_CI @pytest.mark.parametrize("repo_path", [Language.ELIXIR], indirect=True) def test_refs_and_symbols_with_glob_patterns(repo_path: Path) -> None: """Tests that refs and symbols with glob patterns are ignored. Note: This test uses a separate Expert instance with custom ignored paths, which adds ~60-90s startup time. """ ignored_paths = ["*cripts", "ignored_*"] # codespell:ignore cripts with start_ls_context(language=Language.ELIXIR, repo_path=str(repo_path), ignored_paths=ignored_paths) as ls: # Same as in the above tests root = ls.request_full_symbol_tree()[0] root_children = root["children"] children_names = {child["name"] for child in root_children} # Should have lib and test directories, but not scripts or ignored_dir expected_dirs = {"lib", "test"} assert expected_dirs.issubset(children_names), f"Expected {expected_dirs} to be in {children_names}" assert "scripts" not in children_names, f"scripts should not be in {children_names} (glob pattern)" assert "ignored_dir" not in children_names, f"ignored_dir should not be in {children_names} (glob pattern)" # Test that the refs and symbols with glob patterns are ignored definition_file = "lib/models.ex" # Find the User struct definition symbols = ls.request_document_symbols(definition_file).get_all_symbols_and_roots() user_symbol = None for symbol_group in symbols: user_symbol = next((s for s in symbol_group if "User" in s.get("name", "")), None) if user_symbol: break if user_symbol and "selectionRange" in user_symbol: sel_start = user_symbol["selectionRange"]["start"] references = ls.request_references(definition_file, sel_start["line"], sel_start["character"]) # Assert that scripts and ignored_dir do not appear in references assert not any("scripts" in ref["relativePath"] for ref in references), "scripts should be ignored (glob)" assert not any("ignored_dir" in ref["relativePath"] for ref in references), "ignored_dir should be ignored (glob)" @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) def test_default_ignored_directories(language_server: SolidLanguageServer): """Test that default Elixir directories are ignored.""" # Test that Elixir-specific directories are ignored by default assert language_server.is_ignored_dirname("_build"), "_build should be ignored" assert language_server.is_ignored_dirname("deps"), "deps should be ignored" assert language_server.is_ignored_dirname(".elixir_ls"), ".elixir_ls should be ignored" assert language_server.is_ignored_dirname("cover"), "cover should be ignored" assert language_server.is_ignored_dirname("node_modules"), "node_modules should be ignored" # Test that important directories are not ignored assert not language_server.is_ignored_dirname("lib"), "lib should not be ignored" assert not language_server.is_ignored_dirname("test"), "test should not be ignored" assert not language_server.is_ignored_dirname("config"), "config should not be ignored" assert not language_server.is_ignored_dirname("priv"), "priv should not be ignored" @pytest.mark.xfail( reason="Expert 0.1.0 bug: document_symbols may return nil for some files (flaky)", raises=Exception, ) @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) def test_symbol_tree_excludes_build_dirs(language_server: SolidLanguageServer): """Test that symbol tree excludes build and dependency directories.""" symbol_tree = language_server.request_full_symbol_tree() if symbol_tree: root = symbol_tree[0] children_names = {child["name"] for child in root.get("children", [])} # Build and dependency directories should not appear ignored_dirs = {"_build", "deps", ".elixir_ls", "cover", "node_modules"} found_ignored = ignored_dirs.intersection(children_names) assert len(found_ignored) == 0, f"Found ignored directories in symbol tree: {found_ignored}" # Important directories should appear important_dirs = {"lib", "test"} found_important = important_dirs.intersection(children_names) assert len(found_important) > 0, f"Expected to find important directories: {important_dirs}, got: {children_names}" ================================================ FILE: test/solidlsp/elixir/test_elixir_integration.py ================================================ """ Integration tests for Elixir language server with test repository. These tests verify that the language server works correctly with a real Elixir project and can perform advanced operations like cross-file symbol resolution. """ import os from pathlib import Path import pytest from serena.project import Project from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from . import EXPERT_UNAVAILABLE, EXPERT_UNAVAILABLE_REASON # These marks will be applied to all tests in this module pytestmark = [pytest.mark.elixir, pytest.mark.skipif(EXPERT_UNAVAILABLE, reason=f"Next LS not available: {EXPERT_UNAVAILABLE_REASON}")] class TestElixirIntegration: """Integration tests for Elixir language server with test repository.""" @pytest.fixture def elixir_test_repo_path(self): """Get the path to the Elixir test repository.""" test_dir = Path(__file__).parent.parent.parent return str(test_dir / "resources" / "repos" / "elixir" / "test_repo") def test_elixir_repo_structure(self, elixir_test_repo_path): """Test that the Elixir test repository has the expected structure.""" repo_path = Path(elixir_test_repo_path) # Check that key files exist assert (repo_path / "mix.exs").exists(), "mix.exs should exist" assert (repo_path / "lib" / "test_repo.ex").exists(), "main module should exist" assert (repo_path / "lib" / "utils.ex").exists(), "utils module should exist" assert (repo_path / "lib" / "models.ex").exists(), "models module should exist" assert (repo_path / "lib" / "services.ex").exists(), "services module should exist" assert (repo_path / "lib" / "examples.ex").exists(), "examples module should exist" assert (repo_path / "test" / "test_repo_test.exs").exists(), "test file should exist" assert (repo_path / "test" / "models_test.exs").exists(), "models test should exist" @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) def test_cross_file_symbol_resolution(self, language_server: SolidLanguageServer): """Test that symbols can be resolved across different files.""" # Test that User struct from models.ex can be found when referenced in services.ex services_file = os.path.join("lib", "services.ex") # Find where User is referenced in services.ex content = language_server.retrieve_full_file_content(services_file) lines = content.split("\n") user_reference_line = None for i, line in enumerate(lines): if "alias TestRepo.Models.{User" in line: user_reference_line = i break if user_reference_line is None: pytest.skip("Could not find User reference in services.ex") # Try to find the definition defining_symbol = language_server.request_defining_symbol(services_file, user_reference_line, 30) if defining_symbol and "location" in defining_symbol: # Should point to models.ex assert "models.ex" in defining_symbol["location"]["uri"] @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) def test_module_hierarchy_understanding(self, language_server: SolidLanguageServer): """Test that the language server understands Elixir module hierarchy.""" models_file = os.path.join("lib", "models.ex") symbols = language_server.request_document_symbols(models_file).get_all_symbols_and_roots() if symbols: # Flatten symbol structure all_symbols = [] for symbol_group in symbols: if isinstance(symbol_group, list): all_symbols.extend(symbol_group) else: all_symbols.append(symbol_group) symbol_names = [s.get("name", "") for s in all_symbols] # Should understand nested module structure expected_modules = ["TestRepo.Models", "User", "Item", "Order"] found_modules = [name for name in expected_modules if any(name in symbol_name for symbol_name in symbol_names)] assert len(found_modules) > 0, f"Expected modules {expected_modules}, found symbols {symbol_names}" def test_file_extension_matching(self): """Test that the Elixir language recognizes the correct file extensions.""" language = Language.ELIXIR matcher = language.get_source_fn_matcher() # Test Elixir file extensions assert matcher.is_relevant_filename("lib/test_repo.ex") assert matcher.is_relevant_filename("test/test_repo_test.exs") assert matcher.is_relevant_filename("config/config.exs") assert matcher.is_relevant_filename("mix.exs") assert matcher.is_relevant_filename("lib/models.ex") assert matcher.is_relevant_filename("lib/services.ex") # Test non-Elixir files assert not matcher.is_relevant_filename("README.md") assert not matcher.is_relevant_filename("lib/test_repo.py") assert not matcher.is_relevant_filename("package.json") assert not matcher.is_relevant_filename("Cargo.toml") class TestElixirProject: @pytest.mark.parametrize("project", [Language.ELIXIR], indirect=True) def test_comprehensive_symbol_search(self, project: Project): """Test comprehensive symbol search across the entire project.""" # Search for all function definitions function_pattern = r"def\s+\w+\s*[\(\s]" function_matches = project.search_source_files_for_pattern(function_pattern) # Should find functions across multiple files if function_matches: files_with_functions = set() for match in function_matches: if match.source_file_path: files_with_functions.add(os.path.basename(match.source_file_path)) # Should find functions in multiple files expected_files = {"models.ex", "services.ex", "examples.ex", "utils.ex", "test_repo.ex"} found_files = expected_files.intersection(files_with_functions) assert len(found_files) > 0, f"Expected functions in {expected_files}, found in {files_with_functions}" # Search for struct definitions struct_pattern = r"defstruct\s+\[" struct_matches = project.search_source_files_for_pattern(struct_pattern) if struct_matches: # Should find structs primarily in models.ex models_structs = [m for m in struct_matches if m.source_file_path and "models.ex" in m.source_file_path] assert len(models_structs) > 0, "Should find struct definitions in models.ex" @pytest.mark.parametrize("project", [Language.ELIXIR], indirect=True) def test_protocol_and_implementation_understanding(self, project: Project): """Test that the language server understands Elixir protocols and implementations.""" # Search for protocol definitions protocol_pattern = r"defprotocol\s+\w+" protocol_matches = project.search_source_files_for_pattern(protocol_pattern, paths_include_glob="**/models.ex") if protocol_matches: # Should find the Serializable protocol serializable_matches = [m for m in protocol_matches if "Serializable" in str(m)] assert len(serializable_matches) > 0, "Should find Serializable protocol definition" # Search for protocol implementations impl_pattern = r"defimpl\s+\w+" impl_matches = project.search_source_files_for_pattern(impl_pattern, paths_include_glob="**/models.ex") if impl_matches: # Should find multiple implementations assert len(impl_matches) >= 3, f"Should find at least 3 protocol implementations, found {len(impl_matches)}" ================================================ FILE: test/solidlsp/elixir/test_elixir_symbol_retrieval.py ================================================ """ Tests for the Elixir language server symbol-related functionality. These tests focus on the following methods: - request_containing_symbol - request_referencing_symbols - request_defining_symbol """ import os import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind from . import EXPERT_UNAVAILABLE, EXPERT_UNAVAILABLE_REASON # These marks will be applied to all tests in this module pytestmark = [pytest.mark.elixir, pytest.mark.skipif(EXPERT_UNAVAILABLE, reason=f"Next LS not available: {EXPERT_UNAVAILABLE_REASON}")] class TestElixirLanguageServerSymbols: """Test the Elixir language server's symbol-related functionality.""" @pytest.mark.xfail( reason="Expert 0.1.0 bug: document_symbols returns nil for some files (FunctionClauseError in XPExpert.EngineApi.document_symbols/2)" ) @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) def test_request_containing_symbol_function(self, language_server: SolidLanguageServer) -> None: """Test request_containing_symbol for a function.""" # Test for a position inside the create_user function file_path = os.path.join("lib", "services.ex") # Find the create_user function in the file content = language_server.retrieve_full_file_content(file_path) lines = content.split("\n") create_user_line = None for i, line in enumerate(lines): if "def create_user(" in line: create_user_line = i + 2 # Go inside the function body break if create_user_line is None: pytest.skip("Could not find create_user function") containing_symbol = language_server.request_containing_symbol(file_path, create_user_line, 10, include_body=True) # Verify that we found the containing symbol if containing_symbol: # Next LS returns the full function signature instead of just the function name assert containing_symbol["name"] == "def create_user(pid, id, name, email, roles \\\\ [])" assert containing_symbol["kind"] == SymbolKind.Method or containing_symbol["kind"] == SymbolKind.Function if "body" in containing_symbol: assert "def create_user" in containing_symbol["body"].get_text() @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) def test_request_containing_symbol_module(self, language_server: SolidLanguageServer) -> None: """Test request_containing_symbol for a module.""" # Test for a position inside the UserService module but outside any function file_path = os.path.join("lib", "services.ex") # Find the UserService module definition content = language_server.retrieve_full_file_content(file_path) lines = content.split("\n") user_service_line = None for i, line in enumerate(lines): if "defmodule UserService do" in line: user_service_line = i + 1 # Go inside the module break if user_service_line is None: pytest.skip("Could not find UserService module") containing_symbol = language_server.request_containing_symbol(file_path, user_service_line, 5) # Verify that we found the containing symbol if containing_symbol: assert "UserService" in containing_symbol["name"] assert containing_symbol["kind"] == SymbolKind.Module or containing_symbol["kind"] == SymbolKind.Class @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) def test_request_containing_symbol_nested(self, language_server: SolidLanguageServer) -> None: """Test request_containing_symbol with nested scopes.""" # Test for a position inside a function which is inside a module file_path = os.path.join("lib", "services.ex") # Find a function inside UserService content = language_server.retrieve_full_file_content(file_path) lines = content.split("\n") function_body_line = None for i, line in enumerate(lines): if "def create_user(" in line: function_body_line = i + 3 # Go deeper into the function body break if function_body_line is None: pytest.skip("Could not find function body") containing_symbol = language_server.request_containing_symbol(file_path, function_body_line, 15) # Verify that we found the innermost containing symbol (the function) if containing_symbol: expected_names = ["create_user", "UserService"] assert any(name in containing_symbol["name"] for name in expected_names) @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) def test_request_containing_symbol_none(self, language_server: SolidLanguageServer) -> None: """Test request_containing_symbol for a position with no containing symbol.""" # Test for a position outside any function/module (e.g., in module doc) file_path = os.path.join("lib", "services.ex") # Line 1-3 are likely in module documentation or imports containing_symbol = language_server.request_containing_symbol(file_path, 2, 10) # Should return None or an empty dictionary, or the top-level module # This is acceptable behavior for module-level positions assert containing_symbol is None or containing_symbol == {} or "TestRepo.Services" in str(containing_symbol) @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) def test_request_referencing_symbols_struct(self, language_server: SolidLanguageServer) -> None: """Test request_referencing_symbols for a struct.""" # Test referencing symbols for User struct file_path = os.path.join("lib", "models.ex") symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() user_symbol = None for symbol_group in symbols: user_symbol = next((s for s in symbol_group if "User" in s.get("name", "")), None) if user_symbol: break if not user_symbol or "selectionRange" not in user_symbol: pytest.skip("User symbol or its selectionRange not found") sel_start = user_symbol["selectionRange"]["start"] ref_symbols = [ ref.symbol for ref in language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"]) ] if ref_symbols: services_references = [ symbol for symbol in ref_symbols if "location" in symbol and "uri" in symbol["location"] and "services.ex" in symbol["location"]["uri"] ] # We expect some references from services.ex assert len(services_references) >= 0 # At least attempt to find references @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) def test_request_referencing_symbols_none(self, language_server: SolidLanguageServer) -> None: """Test request_referencing_symbols for a position with no symbol.""" file_path = os.path.join("lib", "services.ex") # Line 3 is likely a blank line or comment try: ref_symbols = [ref.symbol for ref in language_server.request_referencing_symbols(file_path, 3, 0)] # If we get here, make sure we got an empty result assert ref_symbols == [] or ref_symbols is None except Exception: # The method might raise an exception for invalid positions # which is acceptable behavior pass # Tests for request_defining_symbol @pytest.mark.xfail( reason="Expert 0.1.0 bug: definition request crashes (FunctionClauseError in XPExpert.Protocol.Conversions.to_elixir/2)" ) @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) def test_request_defining_symbol_function_call(self, language_server: SolidLanguageServer) -> None: """Test request_defining_symbol for a function call.""" # Find a place where User.new is called in services.ex file_path = os.path.join("lib", "services.ex") content = language_server.retrieve_full_file_content(file_path) lines = content.split("\n") user_new_call_line = None for i, line in enumerate(lines): if "User.new(" in line: user_new_call_line = i break if user_new_call_line is None: pytest.skip("Could not find User.new call") # Try to find the definition of User.new defining_symbol = language_server.request_defining_symbol(file_path, user_new_call_line, 15) if defining_symbol: assert defining_symbol.get("name") == "new" or "User" in defining_symbol.get("name", "") if "location" in defining_symbol and "uri" in defining_symbol["location"]: assert "models.ex" in defining_symbol["location"]["uri"] @pytest.mark.xfail( reason="Expert 0.1.0 bug: definition request crashes (FunctionClauseError in XPExpert.Protocol.Conversions.to_elixir/2)" ) @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) def test_request_defining_symbol_struct_usage(self, language_server: SolidLanguageServer) -> None: """Test request_defining_symbol for a struct usage.""" # Find a place where User struct is used in services.ex file_path = os.path.join("lib", "services.ex") content = language_server.retrieve_full_file_content(file_path) lines = content.split("\n") user_usage_line = None for i, line in enumerate(lines): if "alias TestRepo.Models.{User" in line: user_usage_line = i break if user_usage_line is None: pytest.skip("Could not find User struct usage") defining_symbol = language_server.request_defining_symbol(file_path, user_usage_line, 30) if defining_symbol: assert "User" in defining_symbol.get("name", "") @pytest.mark.xfail( reason="Expert 0.1.0 bug: definition request crashes (FunctionClauseError in XPExpert.Protocol.Conversions.to_elixir/2)" ) @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) def test_request_defining_symbol_none(self, language_server: SolidLanguageServer) -> None: """Test request_defining_symbol for a position with no symbol.""" # Test for a position with no symbol (e.g., whitespace or comment) file_path = os.path.join("lib", "services.ex") # Line 3 is likely a blank line defining_symbol = language_server.request_defining_symbol(file_path, 3, 0) # Should return None or empty assert defining_symbol is None or defining_symbol == {} @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) def test_symbol_methods_integration(self, language_server: SolidLanguageServer) -> None: """Test integration between different symbol methods.""" file_path = os.path.join("lib", "models.ex") # Find User struct definition content = language_server.retrieve_full_file_content(file_path) lines = content.split("\n") user_struct_line = None for i, line in enumerate(lines): if "defmodule User do" in line: user_struct_line = i break if user_struct_line is None: pytest.skip("Could not find User struct") # Test containing symbol containing = language_server.request_containing_symbol(file_path, user_struct_line + 5, 10) if containing: # Test that we can find references to this symbol if "location" in containing and "range" in containing["location"]: start_pos = containing["location"]["range"]["start"] refs = [ ref.symbol for ref in language_server.request_referencing_symbols(file_path, start_pos["line"], start_pos["character"]) ] # We should find some references or none (both are valid outcomes) assert isinstance(refs, list) @pytest.mark.xfail(reason="Flaky test, sometimes fails with an Expert-internal error") @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) def test_symbol_tree_structure(self, language_server: SolidLanguageServer) -> None: """Test that symbol tree structure is correctly built.""" symbol_tree = language_server.request_full_symbol_tree() # Should get a tree structure assert len(symbol_tree) > 0 # Should have our test repository structure root = symbol_tree[0] assert "children" in root # Look for lib directory lib_dir = None for child in root["children"]: if child["name"] == "lib": lib_dir = child break if lib_dir: # Expert returns module names instead of file names (e.g., 'services' instead of 'services.ex') file_names = [child["name"] for child in lib_dir.get("children", [])] expected_modules = ["models", "services", "examples", "utils", "test_repo"] found_modules = [name for name in expected_modules if name in file_names] assert len(found_modules) > 0, f"Expected to find some modules from {expected_modules}, but got {file_names}" @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) def test_request_dir_overview(self, language_server: SolidLanguageServer) -> None: """Test request_dir_overview functionality.""" lib_overview = language_server.request_dir_overview("lib") # Should get an overview of the lib directory assert lib_overview is not None # Expert returns keys like 'lib/services.ex' instead of just 'lib' overview_keys = list(lib_overview.keys()) if hasattr(lib_overview, "keys") else [] lib_files = [key for key in overview_keys if key.startswith("lib/")] assert len(lib_files) > 0, f"Expected to find lib/ files in overview keys: {overview_keys}" # Should contain information about our modules overview_text = str(lib_overview).lower() expected_terms = ["models", "services", "user", "item"] found_terms = [term for term in expected_terms if term in overview_text] assert len(found_terms) > 0, f"Expected to find some terms from {expected_terms} in overview" # @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) # def test_request_document_overview(self, language_server: SolidLanguageServer) -> None: # """Test request_document_overview functionality.""" # # COMMENTED OUT: Expert document overview doesn't contain expected terms # # Expert return value: [('TestRepo.Models', 2, 0, 0)] - only module info, no detailed content # # Expected terms like 'user', 'item', 'order', 'struct', 'defmodule' are not present # # This appears to be a limitation of Expert document overview functionality # # # file_path = os.path.join("lib", "models.ex") # doc_overview = language_server.request_document_overview(file_path) # # # Should get an overview of the models.ex file # assert doc_overview is not None # # # Should contain information about our structs and functions # overview_text = str(doc_overview).lower() # expected_terms = ["user", "item", "order", "struct", "defmodule"] # found_terms = [term for term in expected_terms if term in overview_text] # assert len(found_terms) > 0, f"Expected to find some terms from {expected_terms} in overview" @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) def test_containing_symbol_of_module_attribute(self, language_server: SolidLanguageServer) -> None: """Test containing symbol for module attributes.""" file_path = os.path.join("lib", "models.ex") # Find a module attribute like @type or @doc content = language_server.retrieve_full_file_content(file_path) lines = content.split("\n") attribute_line = None for i, line in enumerate(lines): if line.strip().startswith("@type") or line.strip().startswith("@doc"): attribute_line = i break if attribute_line is None: pytest.skip("Could not find module attribute") containing_symbol = language_server.request_containing_symbol(file_path, attribute_line, 5) if containing_symbol: # Should be contained within a module assert "name" in containing_symbol # The containing symbol should be a module expected_names = ["User", "Item", "Order", "TestRepo.Models"] assert any(name in containing_symbol["name"] for name in expected_names) ================================================ FILE: test/solidlsp/elm/test_elm_basic.py ================================================ import os import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils @pytest.mark.elm class TestElmLanguageServer: @pytest.mark.parametrize("language_server", [Language.ELM], indirect=True) def test_find_symbol(self, language_server: SolidLanguageServer) -> None: symbols = language_server.request_full_symbol_tree() assert SymbolUtils.symbol_tree_contains_name(symbols, "greet"), "greet function not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "calculateSum"), "calculateSum function not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "formatMessage"), "formatMessage function not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "addNumbers"), "addNumbers function not found in symbol tree" @pytest.mark.parametrize("language_server", [Language.ELM], indirect=True) def test_find_references_within_file(self, language_server: SolidLanguageServer) -> None: file_path = os.path.join("Main.elm") symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() greet_symbol = None for sym in symbols[0]: if sym.get("name") == "greet": greet_symbol = sym break assert greet_symbol is not None, "Could not find 'greet' symbol in Main.elm" sel_start = greet_symbol["selectionRange"]["start"] refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) assert any("Main.elm" in ref.get("relativePath", "") for ref in refs), "Main.elm should reference greet function" @pytest.mark.parametrize("language_server", [Language.ELM], indirect=True) def test_find_references_across_files(self, language_server: SolidLanguageServer) -> None: # Test formatMessage function which is defined in Utils.elm and used in Main.elm utils_path = os.path.join("Utils.elm") symbols = language_server.request_document_symbols(utils_path).get_all_symbols_and_roots() formatMessage_symbol = None for sym in symbols[0]: if sym.get("name") == "formatMessage": formatMessage_symbol = sym break assert formatMessage_symbol is not None, "Could not find 'formatMessage' symbol in Utils.elm" # Get references from the definition in Utils.elm sel_start = formatMessage_symbol["selectionRange"]["start"] refs = language_server.request_references(utils_path, sel_start["line"], sel_start["character"]) # Verify that we found references assert refs, "Expected to find references for formatMessage" # Verify that at least one reference is in Main.elm (where formatMessage is used) assert any("Main.elm" in ref.get("relativePath", "") for ref in refs), "Expected to find usage of formatMessage in Main.elm" ================================================ FILE: test/solidlsp/erlang/__init__.py ================================================ import platform def _test_erlang_ls_available() -> str: """Test if Erlang LS is available and return error reason if not.""" # Check if we're on Windows (Erlang LS doesn't support Windows) if platform.system() == "Windows": return "Erlang LS does not support Windows" # Try to import and check Erlang availability try: from solidlsp.language_servers.erlang_language_server import ErlangLanguageServer # Check if Erlang/OTP is installed erlang_version = ErlangLanguageServer._get_erlang_version() if not erlang_version: return "Erlang/OTP is not installed or not in PATH" # Check if rebar3 is available (commonly used build tool) rebar3_available = ErlangLanguageServer._check_rebar3_available() if not rebar3_available: return "rebar3 is not installed or not in PATH (required for project compilation)" return "" # No error, Erlang LS should be available except ImportError as e: return f"Failed to import ErlangLanguageServer: {e}" except Exception as e: return f"Error checking Erlang LS availability: {e}" ERLANG_LS_UNAVAILABLE_REASON = _test_erlang_ls_available() ERLANG_LS_UNAVAILABLE = bool(ERLANG_LS_UNAVAILABLE_REASON) ================================================ FILE: test/solidlsp/erlang/conftest.py ================================================ """ Erlang-specific test configuration and fixtures. """ import os import subprocess import time from pathlib import Path import pytest def ensure_erlang_test_repo_compiled(repo_path: str) -> None: """Ensure the Erlang test repository dependencies are installed and project is compiled. Erlang LS requires the project to be fully compiled and indexed before providing complete references and symbol resolution. This function: 1. Installs dependencies via 'rebar3 deps' 2. Compiles the project via 'rebar3 compile' This is essential in CI environments where dependencies aren't pre-installed. Args: repo_path: Path to the Erlang project root directory """ # Check if this looks like an Erlang project rebar_config = os.path.join(repo_path, "rebar.config") if not os.path.exists(rebar_config): return # Check if already compiled (optimization for repeated runs) build_path = os.path.join(repo_path, "_build") deps_path = os.path.join(repo_path, "deps") if os.path.exists(build_path) and os.path.exists(deps_path): print(f"Erlang test repository already compiled in {repo_path}") return try: print("Installing dependencies and compiling Erlang test repository for optimal Erlang LS performance...") # First, install dependencies with increased timeout for CI print("=" * 60) print("Step 1/2: Installing Erlang dependencies...") print("=" * 60) start_time = time.time() deps_result = subprocess.run( ["rebar3", "deps"], cwd=repo_path, capture_output=True, text=True, timeout=180, check=False, # 3 minutes for dependency installation (CI can be slow) ) deps_duration = time.time() - start_time print(f"Dependencies installation completed in {deps_duration:.2f} seconds") # Always log the output for transparency if deps_result.stdout.strip(): print("Dependencies stdout:") print("-" * 40) print(deps_result.stdout) print("-" * 40) if deps_result.stderr.strip(): print("Dependencies stderr:") print("-" * 40) print(deps_result.stderr) print("-" * 40) if deps_result.returncode != 0: print(f"⚠️ Warning: Dependencies installation failed with exit code {deps_result.returncode}") # Continue anyway - some projects might not have dependencies else: print("✓ Dependencies installed successfully") # Then compile the project with increased timeout for CI print("=" * 60) print("Step 2/2: Compiling Erlang project...") print("=" * 60) start_time = time.time() compile_result = subprocess.run( ["rebar3", "compile"], cwd=repo_path, capture_output=True, text=True, timeout=300, check=False, # 5 minutes for compilation (Dialyzer can be slow in CI) ) compile_duration = time.time() - start_time print(f"Compilation completed in {compile_duration:.2f} seconds") # Always log the output for transparency if compile_result.stdout.strip(): print("Compilation stdout:") print("-" * 40) print(compile_result.stdout) print("-" * 40) if compile_result.stderr.strip(): print("Compilation stderr:") print("-" * 40) print(compile_result.stderr) print("-" * 40) if compile_result.returncode == 0: print(f"✓ Erlang test repository compiled successfully in {repo_path}") else: print(f"⚠️ Warning: Compilation completed with exit code {compile_result.returncode}") # Still continue - warnings are often non-fatal print("=" * 60) print(f"Total setup time: {time.time() - (start_time - compile_duration - deps_duration):.2f} seconds") print("=" * 60) except subprocess.TimeoutExpired as e: print("=" * 60) print(f"❌ TIMEOUT: Erlang setup timed out after {e.timeout} seconds") print(f"Command: {' '.join(e.cmd)}") print("This may indicate slow CI environment - Erlang LS may still work but with reduced functionality") # Try to get partial output if available if hasattr(e, "stdout") and e.stdout: print("Partial stdout before timeout:") print("-" * 40) print(e.stdout) print("-" * 40) if hasattr(e, "stderr") and e.stderr: print("Partial stderr before timeout:") print("-" * 40) print(e.stderr) print("-" * 40) print("=" * 60) except FileNotFoundError: print("❌ ERROR: 'rebar3' command not found - Erlang test repository may not be compiled") print("Please ensure rebar3 is installed and available in PATH") except Exception as e: print(f"❌ ERROR: Failed to prepare Erlang test repository: {e}") @pytest.fixture(scope="session", autouse=True) def setup_erlang_test_environment(): """Automatically prepare Erlang test environment for all Erlang tests. This fixture runs once per test session and automatically: 1. Installs dependencies via 'rebar3 deps' 2. Compiles the Erlang test repository via 'rebar3 compile' It uses autouse=True so it runs automatically without needing to be explicitly requested by tests. This ensures Erlang LS has a fully prepared project to work with. Uses generous timeouts (3-5 minutes) to accommodate slow CI environments. All output is logged for transparency and debugging. """ # Get the test repo path relative to this conftest.py file test_repo_path = Path(__file__).parent.parent.parent / "resources" / "repos" / "erlang" / "test_repo" ensure_erlang_test_repo_compiled(str(test_repo_path)) return str(test_repo_path) @pytest.fixture(scope="session") def erlang_test_repo_path(setup_erlang_test_environment): """Get the path to the prepared Erlang test repository. This fixture depends on setup_erlang_test_environment to ensure dependencies are installed and compilation has completed before returning the path. """ return setup_erlang_test_environment ================================================ FILE: test/solidlsp/erlang/test_erlang_basic.py ================================================ """ Basic integration tests for the Erlang language server functionality. These tests validate the functionality of the language server APIs like request_references using the test repository. """ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from . import ERLANG_LS_UNAVAILABLE, ERLANG_LS_UNAVAILABLE_REASON @pytest.mark.erlang @pytest.mark.skipif(ERLANG_LS_UNAVAILABLE, reason=f"Erlang LS not available: {ERLANG_LS_UNAVAILABLE_REASON}") class TestErlangLanguageServerBasics: """Test basic functionality of the Erlang language server.""" @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) def test_language_server_initialization(self, language_server: SolidLanguageServer) -> None: """Test that the Erlang language server initializes properly.""" assert language_server is not None assert language_server.language == Language.ERLANG @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) def test_document_symbols(self, language_server: SolidLanguageServer) -> None: """Test document symbols retrieval for Erlang files.""" try: file_path = "hello.erl" symbols_tuple = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() assert isinstance(symbols_tuple, tuple) assert len(symbols_tuple) == 2 all_symbols, root_symbols = symbols_tuple assert isinstance(all_symbols, list) assert isinstance(root_symbols, list) except Exception as e: if "not fully initialized" in str(e): pytest.skip("Erlang language server not fully initialized") else: raise ================================================ FILE: test/solidlsp/erlang/test_erlang_ignored_dirs.py ================================================ from collections.abc import Generator from pathlib import Path import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from test.conftest import start_ls_context from . import ERLANG_LS_UNAVAILABLE, ERLANG_LS_UNAVAILABLE_REASON # These marks will be applied to all tests in this module pytestmark = [ pytest.mark.erlang, pytest.mark.skipif(ERLANG_LS_UNAVAILABLE, reason=f"Erlang LS not available: {ERLANG_LS_UNAVAILABLE_REASON}"), ] @pytest.fixture(scope="module") def ls_with_ignored_dirs() -> Generator[SolidLanguageServer, None, None]: """Fixture to set up an LS for the erlang test repo with the 'ignored_dir' directory ignored.""" ignored_paths = ["_build", "ignored_dir"] with start_ls_context(language=Language.ERLANG, ignored_paths=ignored_paths) as ls: yield ls @pytest.mark.timeout(60) # Add 60 second timeout @pytest.mark.xfail(reason="Known timeout issue on Ubuntu CI with Erlang LS server startup", strict=False) @pytest.mark.parametrize("ls_with_ignored_dirs", [Language.ERLANG], indirect=True) def test_symbol_tree_ignores_dir(ls_with_ignored_dirs: SolidLanguageServer): """Tests that request_full_symbol_tree ignores the configured directory.""" root = ls_with_ignored_dirs.request_full_symbol_tree()[0] root_children = root["children"] children_names = {child["name"] for child in root_children} # Should have src, include, and test directories, but not _build or ignored_dir expected_dirs = {"src", "include", "test"} found_expected = expected_dirs.intersection(children_names) assert len(found_expected) > 0, f"Expected some dirs from {expected_dirs} to be in {children_names}" assert "_build" not in children_names, f"_build should not be in {children_names}" assert "ignored_dir" not in children_names, f"ignored_dir should not be in {children_names}" @pytest.mark.timeout(60) # Add 60 second timeout @pytest.mark.xfail(reason="Known timeout issue on Ubuntu CI with Erlang LS server startup", strict=False) @pytest.mark.parametrize("ls_with_ignored_dirs", [Language.ERLANG], indirect=True) def test_find_references_ignores_dir(ls_with_ignored_dirs: SolidLanguageServer): """Tests that find_references ignores the configured directory.""" # Location of user record, which might be referenced in ignored_dir definition_file = "include/records.hrl" # Find the user record definition symbols = ls_with_ignored_dirs.request_document_symbols(definition_file).get_all_symbols_and_roots() user_symbol = None for symbol_group in symbols: user_symbol = next((s for s in symbol_group if "user" in s.get("name", "").lower()), None) if user_symbol: break if not user_symbol or "selectionRange" not in user_symbol: pytest.skip("User record symbol not found for reference testing") sel_start = user_symbol["selectionRange"]["start"] references = ls_with_ignored_dirs.request_references(definition_file, sel_start["line"], sel_start["character"]) # Assert that _build and ignored_dir do not appear in the references assert not any("_build" in ref["relativePath"] for ref in references), "_build should be ignored" assert not any("ignored_dir" in ref["relativePath"] for ref in references), "ignored_dir should be ignored" @pytest.mark.timeout(60) # Add 60 second timeout @pytest.mark.xfail(reason="Known timeout issue on Ubuntu CI with Erlang LS server startup", strict=False) @pytest.mark.parametrize("repo_path", [Language.ERLANG], indirect=True) def test_refs_and_symbols_with_glob_patterns(repo_path: Path) -> None: """Tests that refs and symbols with glob patterns are ignored.""" ignored_paths = ["_build*", "ignored_*", "*.tmp"] with start_ls_context(language=Language.ERLANG, repo_path=str(repo_path), ignored_paths=ignored_paths) as ls: # Same as in the above tests root = ls.request_full_symbol_tree()[0] root_children = root["children"] children_names = {child["name"] for child in root_children} # Should have src, include, and test directories, but not _build or ignored_dir expected_dirs = {"src", "include", "test"} found_expected = expected_dirs.intersection(children_names) assert len(found_expected) > 0, f"Expected some dirs from {expected_dirs} to be in {children_names}" assert "_build" not in children_names, f"_build should not be in {children_names} (glob pattern)" assert "ignored_dir" not in children_names, f"ignored_dir should not be in {children_names} (glob pattern)" # Test that the refs and symbols with glob patterns are ignored definition_file = "include/records.hrl" # Find the user record definition symbols = ls.request_document_symbols(definition_file).get_all_symbols_and_roots() user_symbol = None for symbol_group in symbols: user_symbol = next((s for s in symbol_group if "user" in s.get("name", "").lower()), None) if user_symbol: break if user_symbol and "selectionRange" in user_symbol: sel_start = user_symbol["selectionRange"]["start"] references = ls.request_references(definition_file, sel_start["line"], sel_start["character"]) # Assert that _build and ignored_dir do not appear in references assert not any("_build" in ref["relativePath"] for ref in references), "_build should be ignored (glob)" assert not any("ignored_dir" in ref["relativePath"] for ref in references), "ignored_dir should be ignored (glob)" @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) def test_default_ignored_directories(language_server: SolidLanguageServer): """Test that default Erlang directories are ignored.""" # Test that Erlang-specific directories are ignored by default assert language_server.is_ignored_dirname("_build"), "_build should be ignored" assert language_server.is_ignored_dirname("ebin"), "ebin should be ignored" assert language_server.is_ignored_dirname("deps"), "deps should be ignored" assert language_server.is_ignored_dirname(".rebar3"), ".rebar3 should be ignored" assert language_server.is_ignored_dirname("_checkouts"), "_checkouts should be ignored" assert language_server.is_ignored_dirname("node_modules"), "node_modules should be ignored" # Test that important directories are not ignored assert not language_server.is_ignored_dirname("src"), "src should not be ignored" assert not language_server.is_ignored_dirname("include"), "include should not be ignored" assert not language_server.is_ignored_dirname("test"), "test should not be ignored" assert not language_server.is_ignored_dirname("priv"), "priv should not be ignored" @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) def test_symbol_tree_excludes_build_dirs(language_server: SolidLanguageServer): """Test that symbol tree excludes build and dependency directories.""" symbol_tree = language_server.request_full_symbol_tree() if symbol_tree: root = symbol_tree[0] children_names = {child["name"] for child in root.get("children", [])} # Build and dependency directories should not appear ignored_dirs = {"_build", "ebin", "deps", ".rebar3", "_checkouts", "node_modules"} found_ignored = ignored_dirs.intersection(children_names) assert len(found_ignored) == 0, f"Found ignored directories in symbol tree: {found_ignored}" # Important directories should appear important_dirs = {"src", "include", "test"} found_important = important_dirs.intersection(children_names) assert len(found_important) > 0, f"Expected to find important directories: {important_dirs}, got: {children_names}" @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) def test_ignore_compiled_files(language_server: SolidLanguageServer): """Test that compiled Erlang files are ignored.""" # Test that beam files are ignored assert language_server.is_ignored_filename("module.beam"), "BEAM files should be ignored" assert language_server.is_ignored_filename("app.beam"), "BEAM files should be ignored" # Test that source files are not ignored assert not language_server.is_ignored_filename("module.erl"), "Erlang source files should not be ignored" assert not language_server.is_ignored_filename("records.hrl"), "Header files should not be ignored" @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) def test_rebar_directories_ignored(language_server: SolidLanguageServer): """Test that rebar-specific directories are ignored.""" # Test rebar3-specific directories assert language_server.is_ignored_dirname("_build"), "rebar3 _build should be ignored" assert language_server.is_ignored_dirname("_checkouts"), "rebar3 _checkouts should be ignored" assert language_server.is_ignored_dirname(".rebar3"), "rebar3 cache should be ignored" # Test that rebar.lock and rebar.config are not ignored (they are configuration files) assert not language_server.is_ignored_filename("rebar.config"), "rebar.config should not be ignored" assert not language_server.is_ignored_filename("rebar.lock"), "rebar.lock should not be ignored" @pytest.mark.parametrize("ls_with_ignored_dirs", [Language.ERLANG], indirect=True) def test_document_symbols_ignores_dirs(ls_with_ignored_dirs: SolidLanguageServer): """Test that document symbols from ignored directories are not included.""" # Try to get symbols from a file in ignored directory (should not find it) try: ignored_file = "ignored_dir/ignored_module.erl" symbols = ls_with_ignored_dirs.request_document_symbols(ignored_file).get_all_symbols_and_roots() # If we get here, the file was found - symbols should be empty or None if symbols: assert len(symbols) == 0, "Should not find symbols in ignored directory" except Exception: # This is expected - the file should not be accessible pass @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) def test_erlang_specific_ignore_patterns(language_server: SolidLanguageServer): """Test Erlang-specific ignore patterns work correctly.""" erlang_ignored_dirs = ["_build", "ebin", ".rebar3", "_checkouts", "cover"] # These should be ignored for dirname in erlang_ignored_dirs: assert language_server.is_ignored_dirname(dirname), f"{dirname} should be ignored" # These should not be ignored erlang_important_dirs = ["src", "include", "test", "priv"] for dirname in erlang_important_dirs: assert not language_server.is_ignored_dirname(dirname), f"{dirname} should not be ignored" ================================================ FILE: test/solidlsp/erlang/test_erlang_symbol_retrieval.py ================================================ """ Tests for the Erlang language server symbol-related functionality. These tests focus on the following methods: - request_containing_symbol - request_referencing_symbols - request_defining_symbol """ import os import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind from . import ERLANG_LS_UNAVAILABLE, ERLANG_LS_UNAVAILABLE_REASON # These marks will be applied to all tests in this module pytestmark = [ pytest.mark.erlang, pytest.mark.skipif(ERLANG_LS_UNAVAILABLE, reason=f"Erlang LS not available: {ERLANG_LS_UNAVAILABLE_REASON}"), ] class TestErlangLanguageServerSymbols: """Test the Erlang language server's symbol-related functionality.""" @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) def test_request_containing_symbol_function(self, language_server: SolidLanguageServer) -> None: """Test request_containing_symbol for a function.""" # Test for a position inside the create_user function file_path = os.path.join("src", "models.erl") # Find the create_user function in the file content = language_server.retrieve_full_file_content(file_path) lines = content.split("\n") create_user_line = None for i, line in enumerate(lines): if "create_user(" in line and "-spec" not in line: create_user_line = i + 1 # Go inside the function body break if create_user_line is None: pytest.skip("Could not find create_user function") containing_symbol = language_server.request_containing_symbol(file_path, create_user_line, 10, include_body=True) # Verify that we found the containing symbol if containing_symbol: assert "create_user" in containing_symbol["name"] assert containing_symbol["kind"] == SymbolKind.Method or containing_symbol["kind"] == SymbolKind.Function if "body" in containing_symbol: assert "create_user" in containing_symbol["body"].get_text() @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) def test_request_containing_symbol_module(self, language_server: SolidLanguageServer) -> None: """Test request_containing_symbol for a module.""" # Test for a position inside the models module but outside any function file_path = os.path.join("src", "models.erl") # Find the module definition content = language_server.retrieve_full_file_content(file_path) lines = content.split("\n") module_line = None for i, line in enumerate(lines): if "-module(models)" in line: module_line = i + 2 # Go inside the module break if module_line is None: pytest.skip("Could not find models module") containing_symbol = language_server.request_containing_symbol(file_path, module_line, 5) # Verify that we found the containing symbol if containing_symbol: assert "models" in containing_symbol["name"] or "module" in containing_symbol["name"].lower() assert containing_symbol["kind"] == SymbolKind.Module or containing_symbol["kind"] == SymbolKind.Class @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) def test_request_containing_symbol_nested(self, language_server: SolidLanguageServer) -> None: """Test request_containing_symbol with nested scopes.""" # Test for a position inside a function which is inside a module file_path = os.path.join("src", "models.erl") # Find a function inside models module content = language_server.retrieve_full_file_content(file_path) lines = content.split("\n") function_body_line = None for i, line in enumerate(lines): if "create_user(" in line and "-spec" not in line: # Go deeper into the function body where there might be case expressions for j in range(i + 1, min(i + 10, len(lines))): if lines[j].strip() and not lines[j].strip().startswith("%"): function_body_line = j break break if function_body_line is None: pytest.skip("Could not find function body") containing_symbol = language_server.request_containing_symbol(file_path, function_body_line, 15) # Verify that we found the innermost containing symbol (the function) if containing_symbol: expected_names = ["create_user", "models"] assert any(name in containing_symbol["name"] for name in expected_names) @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) def test_request_containing_symbol_none(self, language_server: SolidLanguageServer) -> None: """Test request_containing_symbol for a position with no containing symbol.""" # Test for a position outside any function/module (e.g., in comments) file_path = os.path.join("src", "models.erl") # Line 1-2 are likely module declaration or comments containing_symbol = language_server.request_containing_symbol(file_path, 2, 10) # Should return None or an empty dictionary, or the top-level module # This is acceptable behavior for module-level positions assert containing_symbol is None or containing_symbol == {} or "models" in str(containing_symbol) @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) def test_request_referencing_symbols_record(self, language_server: SolidLanguageServer) -> None: """Test request_referencing_symbols for a record.""" # Test referencing symbols for user record file_path = os.path.join("include", "records.hrl") symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() user_symbol = None for symbol_group in symbols: user_symbol = next((s for s in symbol_group if "user" in s.get("name", "")), None) if user_symbol: break if not user_symbol or "selectionRange" not in user_symbol: pytest.skip("User record symbol or its selectionRange not found") sel_start = user_symbol["selectionRange"]["start"] ref_symbols = [ ref.symbol for ref in language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"]) ] if ref_symbols: models_references = [ symbol for symbol in ref_symbols if "location" in symbol and "uri" in symbol["location"] and "models.erl" in symbol["location"]["uri"] ] # We expect some references from models.erl assert len(models_references) >= 0 # At least attempt to find references @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) def test_request_referencing_symbols_function(self, language_server: SolidLanguageServer) -> None: """Test request_referencing_symbols for a function.""" # Test referencing symbols for create_user function file_path = os.path.join("src", "models.erl") symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() create_user_symbol = None for symbol_group in symbols: create_user_symbol = next((s for s in symbol_group if "create_user" in s.get("name", "")), None) if create_user_symbol: break if not create_user_symbol or "selectionRange" not in create_user_symbol: pytest.skip("create_user function symbol or its selectionRange not found") sel_start = create_user_symbol["selectionRange"]["start"] ref_symbols = [ ref.symbol for ref in language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"]) ] if ref_symbols: # We might find references from services.erl or test files service_references = [ symbol for symbol in ref_symbols if "location" in symbol and "uri" in symbol["location"] and ("services.erl" in symbol["location"]["uri"] or "test" in symbol["location"]["uri"]) ] assert len(service_references) >= 0 # At least attempt to find references @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) def test_request_referencing_symbols_none(self, language_server: SolidLanguageServer) -> None: """Test request_referencing_symbols for a position with no symbol.""" file_path = os.path.join("src", "models.erl") # Line 3 is likely a blank line or comment try: ref_symbols = [ref.symbol for ref in language_server.request_referencing_symbols(file_path, 3, 0)] # If we get here, make sure we got an empty result assert ref_symbols == [] or ref_symbols is None except Exception: # The method might raise an exception for invalid positions # which is acceptable behavior pass # Tests for request_defining_symbol @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) def test_request_defining_symbol_function_call(self, language_server: SolidLanguageServer) -> None: """Test request_defining_symbol for a function call.""" # Find a place where models:create_user is called in services.erl file_path = os.path.join("src", "services.erl") content = language_server.retrieve_full_file_content(file_path) lines = content.split("\n") models_call_line = None for i, line in enumerate(lines): if "models:create_user(" in line: models_call_line = i break if models_call_line is None: pytest.skip("Could not find models:create_user call") # Try to find the definition of models:create_user defining_symbol = language_server.request_defining_symbol(file_path, models_call_line, 20) if defining_symbol: assert "create_user" in defining_symbol.get("name", "") or "models" in defining_symbol.get("name", "") if "location" in defining_symbol and "uri" in defining_symbol["location"]: assert "models.erl" in defining_symbol["location"]["uri"] @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) def test_request_defining_symbol_record_usage(self, language_server: SolidLanguageServer) -> None: """Test request_defining_symbol for a record usage.""" # Find a place where #user{} record is used in models.erl file_path = os.path.join("src", "models.erl") content = language_server.retrieve_full_file_content(file_path) lines = content.split("\n") record_usage_line = None for i, line in enumerate(lines): if "#user{" in line: record_usage_line = i break if record_usage_line is None: pytest.skip("Could not find #user{} record usage") defining_symbol = language_server.request_defining_symbol(file_path, record_usage_line, 10) if defining_symbol: assert "user" in defining_symbol.get("name", "").lower() if "location" in defining_symbol and "uri" in defining_symbol["location"]: assert "records.hrl" in defining_symbol["location"]["uri"] @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) def test_request_defining_symbol_module_call(self, language_server: SolidLanguageServer) -> None: """Test request_defining_symbol for a module function call.""" # Find a place where utils:validate_input is called file_path = os.path.join("src", "models.erl") content = language_server.retrieve_full_file_content(file_path) lines = content.split("\n") utils_call_line = None for i, line in enumerate(lines): if "validate_email(" in line: utils_call_line = i break if utils_call_line is None: pytest.skip("Could not find function call in models.erl") defining_symbol = language_server.request_defining_symbol(file_path, utils_call_line, 15) if defining_symbol: assert "validate" in defining_symbol.get("name", "") or "email" in defining_symbol.get("name", "") @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) def test_request_defining_symbol_none(self, language_server: SolidLanguageServer) -> None: """Test request_defining_symbol for a position with no symbol.""" # Test for a position with no symbol (e.g., whitespace or comment) file_path = os.path.join("src", "models.erl") # Line 3 is likely a blank line or comment defining_symbol = language_server.request_defining_symbol(file_path, 3, 0) # Should return None or empty assert defining_symbol is None or defining_symbol == {} @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) def test_symbol_methods_integration(self, language_server: SolidLanguageServer) -> None: """Test integration between different symbol methods.""" file_path = os.path.join("src", "models.erl") # Find create_user function definition content = language_server.retrieve_full_file_content(file_path) lines = content.split("\n") create_user_line = None for i, line in enumerate(lines): if "create_user(" in line and "-spec" not in line: create_user_line = i break if create_user_line is None: pytest.skip("Could not find create_user function") # Test containing symbol containing = language_server.request_containing_symbol(file_path, create_user_line + 2, 10) if containing: # Test that we can find references to this symbol if "location" in containing and "range" in containing["location"]: start_pos = containing["location"]["range"]["start"] refs = [ ref.symbol for ref in language_server.request_referencing_symbols(file_path, start_pos["line"], start_pos["character"]) ] # We should find some references or none (both are valid outcomes) assert isinstance(refs, list) @pytest.mark.timeout(60) # Add 60 second timeout @pytest.mark.xfail( reason="Known intermittent timeout issue in Erlang LS in CI environments. " "May pass locally but can timeout on slower CI systems.", strict=False, ) @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) def test_symbol_tree_structure(self, language_server: SolidLanguageServer) -> None: """Test that symbol tree structure is correctly built.""" symbol_tree = language_server.request_full_symbol_tree() # Should get a tree structure assert len(symbol_tree) > 0 # Should have our test repository structure root = symbol_tree[0] assert "children" in root # Look for src directory src_dir = None for child in root["children"]: if child["name"] == "src": src_dir = child break if src_dir: # Check for our Erlang modules file_names = [child["name"] for child in src_dir.get("children", [])] expected_modules = ["models", "services", "utils", "app"] found_modules = [name for name in expected_modules if any(name in fname for fname in file_names)] assert len(found_modules) > 0, f"Expected to find some modules from {expected_modules}, but got {file_names}" @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) def test_request_dir_overview(self, language_server: SolidLanguageServer) -> None: """Test request_dir_overview functionality.""" src_overview = language_server.request_dir_overview("src") # Should get an overview of the src directory assert src_overview is not None overview_keys = list(src_overview.keys()) if hasattr(src_overview, "keys") else [] src_files = [key for key in overview_keys if key.startswith("src/") or "src" in key] assert len(src_files) > 0, f"Expected to find src/ files in overview keys: {overview_keys}" # Should contain information about our modules overview_text = str(src_overview).lower() expected_terms = ["models", "services", "user", "create_user", "gen_server"] found_terms = [term for term in expected_terms if term in overview_text] assert len(found_terms) > 0, f"Expected to find some terms from {expected_terms} in overview" @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) def test_containing_symbol_of_record_field(self, language_server: SolidLanguageServer) -> None: """Test containing symbol for record field access.""" file_path = os.path.join("src", "models.erl") # Find a record field access like User#user.name content = language_server.retrieve_full_file_content(file_path) lines = content.split("\n") record_field_line = None for i, line in enumerate(lines): if "#user{" in line and ("name" in line or "email" in line or "id" in line): record_field_line = i break if record_field_line is None: pytest.skip("Could not find record field access") containing_symbol = language_server.request_containing_symbol(file_path, record_field_line, 10) if containing_symbol: # Should be contained within a function assert "name" in containing_symbol expected_names = ["create_user", "update_user", "format_user_info"] assert any(name in containing_symbol["name"] for name in expected_names) @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) def test_containing_symbol_of_spec(self, language_server: SolidLanguageServer) -> None: """Test containing symbol for function specs.""" file_path = os.path.join("src", "models.erl") # Find a -spec directive content = language_server.retrieve_full_file_content(file_path) lines = content.split("\n") spec_line = None for i, line in enumerate(lines): if line.strip().startswith("-spec") and "create_user" in line: spec_line = i break if spec_line is None: pytest.skip("Could not find -spec directive") containing_symbol = language_server.request_containing_symbol(file_path, spec_line, 5) if containing_symbol: # Should be contained within the module or the function it specifies assert "name" in containing_symbol expected_names = ["models", "create_user"] assert any(name in containing_symbol["name"] for name in expected_names) @pytest.mark.timeout(60) # Add 60 second timeout @pytest.mark.xfail( reason="Known intermittent timeout issue in Erlang LS in CI environments. " "May pass locally but can timeout on slower CI systems, especially macOS. " "Similar to known Next LS timeout issues.", strict=False, ) @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) def test_referencing_symbols_across_files(self, language_server: SolidLanguageServer) -> None: """Test finding references across different files.""" # Test that we can find references to models module functions in services.erl file_path = os.path.join("src", "models.erl") symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() create_user_symbol = None for symbol_group in symbols: create_user_symbol = next((s for s in symbol_group if "create_user" in s.get("name", "")), None) if create_user_symbol: break if not create_user_symbol or "selectionRange" not in create_user_symbol: pytest.skip("create_user function symbol not found") sel_start = create_user_symbol["selectionRange"]["start"] ref_symbols = [ ref.symbol for ref in language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"]) ] # Look for cross-file references cross_file_refs = [ symbol for symbol in ref_symbols if "location" in symbol and "uri" in symbol["location"] and not symbol["location"]["uri"].endswith("models.erl") ] # We might find references in services.erl or test files if cross_file_refs: assert len(cross_file_refs) > 0, "Should find some cross-file references" ================================================ FILE: test/solidlsp/fortran/__init__.py ================================================ # Fortran language server tests ================================================ FILE: test/solidlsp/fortran/test_fortran_basic.py ================================================ """ Basic tests for Fortran language server integration. These tests validate some low-level LSP functionality and high-level Serena APIs. Note: These tests require fortls to be installed: pip install fortls """ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind from solidlsp.ls_utils import SymbolUtils # Mark all tests in this module as fortran tests pytestmark = pytest.mark.fortran class TestFortranLanguageServer: """Test Fortran language server functionality.""" @pytest.mark.parametrize("language_server", [Language.FORTRAN], indirect=True) def test_find_symbol(self, language_server: SolidLanguageServer) -> None: """Test finding symbols using request_full_symbol_tree.""" symbols = language_server.request_full_symbol_tree() # Verify program symbol assert SymbolUtils.symbol_tree_contains_name(symbols, "test_program"), "test_program not found in symbol tree" # Verify module symbol assert SymbolUtils.symbol_tree_contains_name(symbols, "math_utils"), "math_utils module not found in symbol tree" # Verify function symbols assert SymbolUtils.symbol_tree_contains_name(symbols, "add_numbers"), "add_numbers function not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "multiply_numbers"), "multiply_numbers function not found in symbol tree" # Verify subroutine symbol assert SymbolUtils.symbol_tree_contains_name(symbols, "print_result"), "print_result subroutine not found in symbol tree" @pytest.mark.parametrize("language_server", [Language.FORTRAN], indirect=True) def test_request_document_symbols(self, language_server: SolidLanguageServer) -> None: """Test that document symbols can be retrieved from Fortran files.""" # Test main.f90 - should have a program symbol main_symbols, _ = language_server.request_document_symbols("main.f90").get_all_symbols_and_roots() program_names = [s.get("name") for s in main_symbols] assert "test_program" in program_names, f"Program 'test_program' not found in main.f90. Found: {program_names}" # Test modules/math_utils.f90 - should have module and function symbols module_symbols, _ = language_server.request_document_symbols("modules/math_utils.f90").get_all_symbols_and_roots() all_names = [s.get("name") for s in module_symbols] assert "math_utils" in all_names, f"Module 'math_utils' not found. Found: {all_names}" assert "add_numbers" in all_names, f"Function 'add_numbers' not found. Found: {all_names}" assert "multiply_numbers" in all_names, f"Function 'multiply_numbers' not found. Found: {all_names}" assert "print_result" in all_names, f"Subroutine 'print_result' not found. Found: {all_names}" @pytest.mark.parametrize("language_server", [Language.FORTRAN], indirect=True) def test_find_references_cross_file(self, language_server: SolidLanguageServer) -> None: """Test finding references across files using low-level request_references. This tests the LSP textDocument/references capability. """ file_path = "modules/math_utils.f90" symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() # Find the add_numbers function add_numbers_symbol = None for sym in symbols[0]: if sym.get("name") == "add_numbers": add_numbers_symbol = sym break assert add_numbers_symbol is not None, "Could not find 'add_numbers' function symbol in math_utils.f90" # Use selectionRange to query for references # Note: FortranLanguageServer automatically fixes fortls's incorrect selectionRange sel_start = add_numbers_symbol["selectionRange"]["start"] # Query from the function name position using corrected selectionRange refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) # Should find references (usage in main.f90 + definition in math_utils.f90) assert len(refs) > 0, "Should find references to add_numbers function" # Verify that main.f90 references the function main_refs = [ref for ref in refs if "main.f90" in ref.get("relativePath", "")] assert ( len(main_refs) > 0 ), f"Expected to find reference in main.f90, but found references in: {[ref.get('relativePath') for ref in refs]}" @pytest.mark.parametrize("language_server", [Language.FORTRAN], indirect=True) def test_find_definition_cross_file(self, language_server: SolidLanguageServer) -> None: """Test finding definition across files using request_definition.""" # In main.f90, line 7 (0-indexed: line 6) contains: result = add_numbers(5.0, 3.0) # We want to find the definition of add_numbers in modules/math_utils.f90 main_file = "main.f90" # Position on 'add_numbers' usage (approximately column 13) definition_location_list = language_server.request_definition(main_file, 6, 13) if not definition_location_list: pytest.skip("fortls does not support cross-file go-to-definition for this case") assert len(definition_location_list) >= 1, "Should find at least one definition" definition_location = definition_location_list[0] # The definition should be in modules/math_utils.f90 assert "math_utils.f90" in definition_location.get( "uri", "" ), f"Expected definition to be in math_utils.f90, but found in: {definition_location.get('uri')}" # Verify the definition is around the correct line (line 4, 0-indexed) assert ( definition_location["range"]["start"]["line"] == 4 ), f"Expected definition at line 4, but found at line {definition_location['range']['start']['line']}" @pytest.mark.parametrize("language_server", [Language.FORTRAN], indirect=True) def test_request_referencing_symbols(self, language_server: SolidLanguageServer) -> None: """Test finding symbols that reference a function - Serena's high-level API. This tests request_referencing_symbols which returns not just locations but also the containing symbols that have the references. This is different from test_find_references_cross_file which only returns locations. Note: FortranLanguageServer automatically fixes fortls's incorrect selectionRange. """ # Get the add_numbers function symbol from math_utils.f90 file_path = "modules/math_utils.f90" symbols, _ = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() # Find the add_numbers function add_numbers_symbol = None for sym in symbols: if sym.get("name") == "add_numbers": add_numbers_symbol = sym break assert add_numbers_symbol is not None, "Could not find 'add_numbers' function symbol" # Use selectionRange to query for referencing symbols # FortranLanguageServer automatically corrects fortls's incorrect selectionRange sel_start = add_numbers_symbol["selectionRange"]["start"] referencing_symbols = language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"]) # Should find referencing symbols (not just locations, but symbols containing the references) assert len(referencing_symbols) > 0, "Should find referencing symbols when querying from function name position" # Extract the symbols from ReferenceInSymbol objects # This is what makes this test different from test_find_references_cross_file: # we're testing that we get back SYMBOLS (with name, kind, location) not just locations ref_symbols = [ref.symbol for ref in referencing_symbols] # Verify we got valid symbol structures with all required fields for symbol in ref_symbols: assert "name" in symbol, f"Symbol should have a name: {symbol}" assert "kind" in symbol, f"Symbol should have a kind: {symbol}" # Each symbol should have location information assert "location" in symbol, f"Symbol should have location: {symbol}" # Note: fortls may not return all cross-file references through request_referencing_symbols # because it depends on finding containing symbols for each reference. We verify that # the API works and returns valid symbols with proper structure. @pytest.mark.parametrize("language_server", [Language.FORTRAN], indirect=True) def test_request_defining_symbol(self, language_server: SolidLanguageServer) -> None: """Test finding the defining symbol - Serena's high-level API. This is similar to test_find_definition_cross_file but uses the high-level request_defining_symbol which returns a full symbol with metadata, not just a location. """ # In main.f90, line 7 (0-indexed: line 6) contains: result = add_numbers(5.0, 3.0) # We want to find the definition of add_numbers main_file = "main.f90" # Get the position of add_numbers usage in main.f90 # Position on 'add_numbers' (approximately column 13) defining_symbol = language_server.request_defining_symbol(main_file, 6, 13) if defining_symbol is None: pytest.skip("fortls does not support cross-file go-to-definition for this case") # Should find the add_numbers function with full symbol information assert defining_symbol.get("name") == "add_numbers", f"Expected to find 'add_numbers' but got '{defining_symbol.get('name')}'" # Check if we have location information if "location" not in defining_symbol or "relativePath" not in defining_symbol["location"]: pytest.skip("fortls found the symbol but doesn't provide complete location information") # The definition should be in modules/math_utils.f90 defining_path = defining_symbol["location"]["relativePath"] assert "math_utils.f90" in defining_path, f"Expected definition to be in math_utils.f90, but found in: {defining_path}" @pytest.mark.parametrize("language_server", [Language.FORTRAN], indirect=True) def test_request_containing_symbol(self, language_server: SolidLanguageServer) -> None: """Test finding the containing symbol for a position in the code.""" # Test finding the containing symbol for a position inside the add_numbers function file_path = "modules/math_utils.f90" # Line 8 (0-indexed: line 7) is inside the add_numbers function: "sum = a + b" containing_symbol = language_server.request_containing_symbol(file_path, 7, 10, include_body=False) if containing_symbol is None: pytest.skip("fortls does not support request_containing_symbol or couldn't find the containing symbol") # Should find the add_numbers function as the containing symbol assert ( containing_symbol.get("name") == "add_numbers" ), f"Expected containing symbol 'add_numbers', got '{containing_symbol.get('name')}'" # Verify the symbol kind is Function assert ( containing_symbol.get("kind") == SymbolKind.Function.value ), f"Expected Function kind ({SymbolKind.Function.value}), got {containing_symbol.get('kind')}" # Verify location information exists assert "location" in containing_symbol, "Containing symbol should have location information" location = containing_symbol["location"] assert "range" in location, "Location should contain range information" assert "start" in location["range"] and "end" in location["range"], "Range should have start and end positions" @pytest.mark.parametrize("language_server", [Language.FORTRAN], indirect=True) def test_type_and_interface_symbols(self, language_server: SolidLanguageServer) -> None: """Test that type definitions and interfaces are properly recognized with corrected selectionRange. This verifies that the regex pattern correctly handles: - Simple type definitions (type Name) - Type with double colon (type :: Name) - Type with extends (type, extends(Base) :: Derived) - Named interfaces fortls returns these as SymbolKind.Class (11) for types and SymbolKind.Interface (5) for interfaces. """ file_path = "modules/geometry.f90" symbols, _ = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() # Find type and interface symbols type_names = [] interface_names = [] for sym in symbols: if sym.get("kind") == SymbolKind.Class.value: # Type definitions type_names.append(sym.get("name")) elif sym.get("kind") == SymbolKind.Interface.value: # Interfaces interface_names.append(sym.get("name")) # Verify type definitions are found assert "Point2D" in type_names, f"Simple type 'Point2D' not found. Found types: {type_names}" assert "Circle" in type_names, f"Type with :: syntax 'Circle' not found. Found types: {type_names}" assert "Point3D" in type_names, f"Type with extends 'Point3D' not found. Found types: {type_names}" # Verify interface is found assert "distance" in interface_names, f"Interface 'distance' not found. Found interfaces: {interface_names}" # Verify selectionRange is corrected for a type symbol point3d_symbol = None for sym in symbols: if sym.get("name") == "Point3D": point3d_symbol = sym break assert point3d_symbol is not None, "Could not find 'Point3D' type symbol" # Use corrected selectionRange to find references # This tests that the fix works for types (not just functions) sel_start = point3d_symbol["selectionRange"]["start"] # Verify selectionRange points to identifier name, not line start # Line for "type, extends(Point2D) :: Point3D" has Point3D at position > 0 assert ( sel_start["character"] > 0 ), f"selectionRange should point to identifier, not line start. Got character: {sel_start['character']}" # Test that we can find references using the corrected position _refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) # refs might be empty if Point3D isn't used elsewhere, but the call should not fail # The important thing is that it doesn't error due to wrong character position ================================================ FILE: test/solidlsp/fsharp/test_fsharp_basic.py ================================================ import os import threading from typing import Any import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils from test.conftest import is_ci @pytest.mark.fsharp class TestFSharpLanguageServer: @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) def test_find_symbol(self, language_server: SolidLanguageServer) -> None: """Test finding symbols in the full symbol tree.""" symbols = language_server.request_full_symbol_tree() # Check for main program module symbols assert SymbolUtils.symbol_tree_contains_name(symbols, "Program"), "Program module not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "main"), "main function not found in symbol tree" # Check for Calculator module symbols assert SymbolUtils.symbol_tree_contains_name(symbols, "Calculator"), "Calculator module not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "add"), "add function not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "CalculatorClass"), "CalculatorClass not found in symbol tree" @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) def test_get_document_symbols_program(self, language_server: SolidLanguageServer) -> None: """Test getting document symbols from the main Program.fs file.""" file_path = os.path.join("Program.fs") symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots()[0] # Look for expected functions and modules symbol_names = [s.get("name") for s in symbols] assert "main" in symbol_names, "main function not found in Program.fs symbols" @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) def test_get_document_symbols_calculator(self, language_server: SolidLanguageServer) -> None: """Test getting document symbols from Calculator.fs file.""" file_path = os.path.join("Calculator.fs") symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots()[0] # Look for expected functions symbol_names = [s.get("name") for s in symbols] expected_symbols = ["add", "subtract", "multiply", "divide", "square", "factorial", "CalculatorClass"] for expected in expected_symbols: assert expected in symbol_names, f"{expected} function not found in Calculator.fs symbols" @pytest.mark.xfail(is_ci, reason="Test is flaky") # TODO: Re-enable if the LS can be made more reliable #1040 @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) def test_find_referencing_symbols(self, language_server: SolidLanguageServer) -> None: """Test finding references using symbol selection range.""" file_path = os.path.join("Calculator.fs") symbols = language_server.request_document_symbols(file_path) # Find the 'add' function symbol add_symbol = None for sym in symbols.iter_symbols(): if sym.get("name") == "add": add_symbol = sym break assert add_symbol is not None, "Could not find 'add' function symbol in Calculator.fs" # Try to find references to the add function sel_start = add_symbol["selectionRange"]["start"] refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"] + 1) # The add function should be referenced in Program.fs assert any("Program.fs" in ref.get("relativePath", "") for ref in refs), "Program.fs should reference add function" @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) def test_nested_module_symbols(self, language_server: SolidLanguageServer) -> None: """Test getting symbols from nested Models namespace.""" file_path = os.path.join("Models", "Person.fs") symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots()[0] # Check for expected types and modules symbol_names = [s.get("name") for s in symbols] expected_symbols = ["Person", "PersonModule", "Address", "Employee"] for expected in expected_symbols: assert expected in symbol_names, f"{expected} not found in Person.fs symbols" @pytest.mark.xfail(is_ci, reason="Test is flaky") # TODO: Re-enable if the LS can be made more reliable #1040 @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) def test_find_referencing_symbols_across_files(self, language_server: SolidLanguageServer) -> None: """Test finding references to Calculator functions across files.""" # Find the subtract function in Calculator.fs file_path = os.path.join("Calculator.fs") symbols = language_server.request_document_symbols(file_path) subtract_symbol = None for sym in symbols.iter_symbols(): if sym.get("name") == "subtract": subtract_symbol = sym break assert subtract_symbol is not None, "Could not find 'subtract' function symbol" # Find references to subtract function sel_start = subtract_symbol["selectionRange"]["start"] refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"] + 1) # The subtract function should be referenced in Program.fs assert any("Program.fs" in ref.get("relativePath", "") for ref in refs), "Program.fs should reference subtract function" @pytest.mark.xfail(is_ci, reason="Test is flaky") # TODO: Re-enable if the LS can be made more reliable #1040 @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) def test_go_to_definition(self, language_server: SolidLanguageServer) -> None: """Test go-to-definition functionality.""" # Test going to definition of 'add' function from Program.fs program_file = os.path.join("Program.fs") # Try to find definition of 'add' function used in Program.fs # This would typically be at the line where 'add 5 3' is called definitions = language_server.request_definition(program_file, 10, 20) # Approximate position # We should get at least some definitions assert len(definitions) >= 0, "Should get definitions (even if empty for complex cases)" @pytest.mark.xfail(is_ci, reason="Test is flaky") # TODO: Re-enable if the LS can be made more reliable #1040 @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) def test_hover_information(self, language_server: SolidLanguageServer) -> None: """Test hover information functionality.""" file_path = os.path.join("Calculator.fs") # Try to get hover information for a function hover_info = language_server.request_hover(file_path, 5, 10) # Approximate position of a function # Hover info might be None or contain information # This is acceptable as it depends on the LSP server's capabilities and timing assert hover_info is None or isinstance(hover_info, dict), "Hover info should be None or dict" @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) def test_completion(self, language_server: SolidLanguageServer) -> None: """Test code completion functionality.""" file_path = os.path.join("Program.fs") # Use threading for cross-platform timeout (signal.SIGALRM is Unix-only) result: dict[str, Any] = dict(value=None) exception: dict[str, Any] = dict(value=None) def run_completion(): try: result["value"] = language_server.request_completions(file_path, 15, 10) except Exception as e: exception["value"] = e thread = threading.Thread(target=run_completion, daemon=True) thread.start() thread.join(timeout=5) # 5 second timeout if thread.is_alive(): # Completion timed out, but this is acceptable for F# in some cases # The important thing is that the language server doesn't crash return if exception["value"]: raise exception["value"] assert isinstance(result["value"], list), "Completions should be a list" @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) def test_diagnostics(self, language_server: SolidLanguageServer) -> None: """Test getting diagnostics (errors, warnings) from F# files.""" file_path = os.path.join("Program.fs") # FsAutoComplete uses publishDiagnostics notifications instead of textDocument/diagnostic requests # So we'll test that the language server can handle files without crashing # In real usage, diagnostics would come through the publishDiagnostics notification handler # Test that we can at least work with the file (open/close cycle) with language_server.open_file(file_path) as _: # If we can open and close the file without errors, basic diagnostics support is working pass # This is a successful test - FsAutoComplete is working with F# files assert True, "F# language server can handle files successfully" ================================================ FILE: test/solidlsp/go/test_go_basic.py ================================================ import os from pathlib import Path import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils @pytest.mark.go class TestGoLanguageServer: @pytest.mark.parametrize("language_server", [Language.GO], indirect=True) def test_find_symbol(self, language_server: SolidLanguageServer) -> None: symbols = language_server.request_full_symbol_tree() assert SymbolUtils.symbol_tree_contains_name(symbols, "main"), "main function not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "Helper"), "Helper function not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "DemoStruct"), "DemoStruct not found in symbol tree" @pytest.mark.parametrize("language_server", [Language.GO], indirect=True) def test_find_referencing_symbols(self, language_server: SolidLanguageServer) -> None: file_path = os.path.join("main.go") symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() helper_symbol = None for sym in symbols[0]: if sym.get("name") == "Helper": helper_symbol = sym break assert helper_symbol is not None, "Could not find 'Helper' function symbol in main.go" sel_start = helper_symbol["selectionRange"]["start"] refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) assert any("main.go" in ref.get("uri", "") for ref in refs), "Expected at least one reference result to point at main.go" def _filter_symbols_by_name_in_repo(symbols: list | None, target_name: str, repo_name: str = "test_repo") -> list: """Filter workspace symbols to exact name matches in the test repo.""" if symbols is None: return [] return [s for s in symbols if s.get("name") == target_name and repo_name in s.get("location", {}).get("uri", "")] @pytest.mark.go class TestGoBuildTags: """Tests for Go build tag/constraint support.""" def _copy_go_fixture(self, tmp_path: Path) -> Path: """Copy Go fixture repo into tmp_path.""" import shutil from test.conftest import get_repo_path fixture_path = get_repo_path(Language.GO) target_path = tmp_path / "test_repo" shutil.copytree(fixture_path, target_path) return target_path def test_default_context_contains_xnotfoo(self, tmp_path: Path) -> None: """Default build context should contain XNotFoo and not XFoo.""" from test.conftest import start_ls_context repo_path = self._copy_go_fixture(tmp_path) with start_ls_context(Language.GO, repo_path=str(repo_path), solidlsp_dir=tmp_path) as ls: xnotfoo_symbols = ls.request_workspace_symbol("XNotFoo") xfoo_symbols = ls.request_workspace_symbol("XFoo") xnotfoo_matches = _filter_symbols_by_name_in_repo(xnotfoo_symbols, "XNotFoo") xfoo_matches = _filter_symbols_by_name_in_repo(xfoo_symbols, "XFoo") assert len(xnotfoo_matches) > 0, "Default context should contain XNotFoo" assert len(xfoo_matches) == 0, "Default context should NOT contain XFoo" def test_foo_context_contains_xfoo(self, tmp_path: Path) -> None: """Build context with -tags=foo should contain XFoo and not XNotFoo.""" from test.conftest import start_ls_context repo_path = self._copy_go_fixture(tmp_path) ls_settings = { Language.GO: { "gopls_settings": { "buildFlags": ["-tags=foo"], }, }, } with start_ls_context(Language.GO, repo_path=str(repo_path), ls_specific_settings=ls_settings, solidlsp_dir=tmp_path) as ls: xfoo_symbols = ls.request_workspace_symbol("XFoo") xnotfoo_symbols = ls.request_workspace_symbol("XNotFoo") xfoo_matches = _filter_symbols_by_name_in_repo(xfoo_symbols, "XFoo") xnotfoo_matches = _filter_symbols_by_name_in_repo(xnotfoo_symbols, "XNotFoo") assert len(xfoo_matches) > 0, "Foo context should contain XFoo" assert len(xnotfoo_matches) == 0, "Foo context should NOT contain XNotFoo" def test_disk_cache_is_invalidated_on_build_context_switch(self, tmp_path: Path) -> None: """Go build context switches must not reuse persisted SolidLSP document-symbol caches.""" import pickle from test.conftest import start_ls_context repo_path = self._copy_go_fixture(tmp_path) ls_settings_foo = { Language.GO: { "gopls_settings": { "buildFlags": ["-tags=foo"], }, }, } main_go = os.path.join("main.go") def _assert_caches_loaded_and_clean(ls: SolidLanguageServer) -> None: # White-box assertions: SolidLanguageServer currently has no public API to verify that # caches were loaded from disk vs created lazily on first request. assert ls._raw_document_symbols_cache, "Expected raw document-symbol cache to load from disk" assert ls._document_symbols_cache, "Expected document-symbol cache to load from disk" assert not ls._raw_document_symbols_cache_is_modified assert not ls._document_symbols_cache_is_modified def _assert_caches_empty(ls: SolidLanguageServer) -> None: assert ls._raw_document_symbols_cache == {} assert ls._document_symbols_cache == {} def _assert_caches_modified(ls: SolidLanguageServer) -> None: assert ls._raw_document_symbols_cache_is_modified assert ls._document_symbols_cache_is_modified # Run 1 (default context): populate caches and persist them to disk. with start_ls_context(Language.GO, repo_path=str(repo_path), solidlsp_dir=tmp_path) as ls_default: _ = ls_default.request_document_symbols(main_go) default_raw_cache_version = ls_default._raw_document_symbols_cache_version() default_doc_cache_version = ls_default._document_symbols_cache_version() ls_default.save_cache() cache_dir = ls_default.cache_dir cache_files = [p for p in cache_dir.rglob("*") if p.is_file()] assert cache_files, f"Expected SolidLSP to create cache artifacts under {cache_dir}" versioned_cache_files: list[tuple[Path, object]] = [] for p in cache_files: try: with p.open("rb") as f: data = pickle.load(f) except Exception: continue if isinstance(data, dict) and "__cache_version" in data: versioned_cache_files.append((p, data["__cache_version"])) assert versioned_cache_files, f"Expected at least one SolidLSP cache file with a __cache_version under {cache_dir}" saved_versions = {v for _, v in versioned_cache_files} assert ( default_raw_cache_version in saved_versions or default_doc_cache_version in saved_versions ), "Expected at least one persisted cache to match the default-context cache version" # Run 2 (default context again): prove that persisted caches are actually loaded and used. with start_ls_context(Language.GO, repo_path=str(repo_path), solidlsp_dir=tmp_path) as ls_default_again: assert ls_default_again.cache_dir == cache_dir _assert_caches_loaded_and_clean(ls_default_again) _ = ls_default_again.request_document_symbols(main_go) # A cache hit should not mark caches as modified. assert not ls_default_again._raw_document_symbols_cache_is_modified assert not ls_default_again._document_symbols_cache_is_modified # Run 3 (foo context): the same on-disk cache directory exists, but MUST be treated as stale. with start_ls_context( Language.GO, repo_path=str(repo_path), ls_specific_settings=ls_settings_foo, solidlsp_dir=tmp_path, ) as ls_foo: assert ls_foo.cache_dir == cache_dir foo_raw_cache_version = ls_foo._raw_document_symbols_cache_version() foo_doc_cache_version = ls_foo._document_symbols_cache_version() assert foo_raw_cache_version != default_raw_cache_version assert foo_doc_cache_version != default_doc_cache_version # Different build context => persisted caches must not be loaded. _assert_caches_empty(ls_foo) _ = ls_foo.request_document_symbols(main_go) # A cache miss should repopulate and mark caches modified. _assert_caches_modified(ls_foo) ================================================ FILE: test/solidlsp/groovy/test_groovy_basic.py ================================================ import os from pathlib import Path import pytest from serena.constants import SERENA_MANAGED_DIR_NAME from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.ls_utils import SymbolUtils from solidlsp.settings import SolidLSPSettings @pytest.mark.groovy class TestGroovyLanguageServer: language_server: SolidLanguageServer | None = None test_repo_path: Path = Path(__file__).parent.parent.parent / "resources" / "repos" / "groovy" / "test_repo" @classmethod def setup_class(cls): """ Set up test class with Groovy test repository. """ if not cls.test_repo_path.exists(): pytest.skip("Groovy test repository not found") # Use JAR path from environment variable ls_jar_path = os.environ.get("GROOVY_LS_JAR_PATH") if not ls_jar_path or not os.path.exists(ls_jar_path): pytest.skip( "Groovy Language Server JAR not found. Set GROOVY_LS_JAR_PATH environment variable to run tests.", allow_module_level=True, ) # Get JAR options from environment variable ls_jar_options = os.environ.get("GROOVY_LS_JAR_OPTIONS", "") ls_java_home_path = os.environ.get("GROOVY_LS_JAVA_HOME_PATH") groovy_settings = {"ls_jar_path": ls_jar_path, "ls_jar_options": ls_jar_options} if ls_java_home_path: groovy_settings["ls_java_home_path"] = ls_java_home_path # Create language server directly with Groovy-specific settings repo_path = str(cls.test_repo_path) config = LanguageServerConfig(code_language=Language.GROOVY, ignored_paths=[], trace_lsp_communication=False) project_data_path = os.path.join(repo_path, SERENA_MANAGED_DIR_NAME) solidlsp_settings = SolidLSPSettings( solidlsp_dir=str(Path.home() / ".serena"), project_data_path=project_data_path, ls_specific_settings={Language.GROOVY: groovy_settings}, ) cls.language_server = SolidLanguageServer.create(config, repo_path, solidlsp_settings=solidlsp_settings) cls.language_server.start() @classmethod def teardown_class(cls): """ Clean up language server. """ if cls.language_server is not None: cls.language_server.stop() def test_find_symbol(self) -> None: assert self.language_server is not None symbols = self.language_server.request_full_symbol_tree() assert SymbolUtils.symbol_tree_contains_name(symbols, "Main"), "Main class not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "Utils"), "Utils class not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "Model"), "Model class not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "ModelUser"), "ModelUser class not found in symbol tree" def test_find_referencing_class_symbols(self) -> None: assert self.language_server is not None file_path = os.path.join("src", "main", "groovy", "com", "example", "Utils.groovy") refs = self.language_server.request_references(file_path, 3, 6) assert any("Main.groovy" in ref.get("relativePath", "") for ref in refs), "Utils should be referenced from Main.groovy" file_path = os.path.join("src", "main", "groovy", "com", "example", "Model.groovy") symbols = self.language_server.request_document_symbols(file_path).get_all_symbols_and_roots() model_symbol = None for sym in symbols[0]: if sym.get("name") == "com.example.Model" and sym.get("kind") == 5: model_symbol = sym break assert model_symbol is not None, "Could not find 'Model' class symbol in Model.groovy" if "selectionRange" in model_symbol: sel_start = model_symbol["selectionRange"]["start"] else: sel_start = model_symbol["range"]["start"] refs = self.language_server.request_references(file_path, sel_start["line"], sel_start["character"]) main_refs = [ref for ref in refs if "Main.groovy" in ref.get("relativePath", "")] assert len(main_refs) >= 2, f"Model should be referenced from Main.groovy at least 2 times, found {len(main_refs)}" model_user_refs = [ref for ref in refs if "ModelUser.groovy" in ref.get("relativePath", "")] assert len(model_user_refs) >= 1, f"Model should be referenced from ModelUser.groovy at least 1 time, found {len(model_user_refs)}" def test_overview_methods(self) -> None: assert self.language_server is not None symbols = self.language_server.request_full_symbol_tree() assert SymbolUtils.symbol_tree_contains_name(symbols, "Main"), "Main missing from overview" assert SymbolUtils.symbol_tree_contains_name(symbols, "Utils"), "Utils missing from overview" assert SymbolUtils.symbol_tree_contains_name(symbols, "Model"), "Model missing from overview" assert SymbolUtils.symbol_tree_contains_name(symbols, "ModelUser"), "ModelUser missing from overview" ================================================ FILE: test/solidlsp/haskell/__init__.py ================================================ # Haskell language server tests ================================================ FILE: test/solidlsp/haskell/test_haskell_basic.py ================================================ """ Rigorous tests for Haskell Language Server integration with Serena. Tests prove that Serena's symbol tools can: 1. Discover all expected symbols with precise matching 2. Track cross-file references accurately 3. Identify data type structures and record fields 4. Navigate between definitions and usages Test Repository Structure: - src/Calculator.hs: Calculator data type, arithmetic functions (add, subtract, multiply, divide, calculate) - src/Helper.hs: Helper functions (validateNumber, isPositive, isNegative, absolute) - app/Main.hs: Main entry point using Calculator and Helper modules """ import sys import pytest from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import Language @pytest.mark.haskell @pytest.mark.skipif(sys.platform == "win32", reason="HLS not installed on Windows CI") class TestHaskellLanguageServer: @pytest.mark.parametrize("language_server", [Language.HASKELL], indirect=True) def test_calculator_module_symbols(self, language_server: SolidLanguageServer): """ Test precise symbol discovery in Calculator.hs. Verifies that Serena can identify: - Data type definition (Calculator with record fields) - All exported functions with correct names - Module structure """ all_symbols, _ = language_server.request_document_symbols("src/Calculator.hs").get_all_symbols_and_roots() symbol_names = {s["name"] for s in all_symbols} # Verify exact set of expected top-level symbols expected_symbols = { "Calculator", # Data type "add", # Function: Int -> Int -> Int "subtract", # Function: Int -> Int -> Int "multiply", # Function: Int -> Int -> Int "divide", # Function: Int -> Int -> Maybe Int "calculate", # Function: Calculator -> String -> Int -> Int -> Maybe Int } # Verify all expected symbols are present missing = expected_symbols - symbol_names assert not missing, f"Missing expected symbols in Calculator.hs: {missing}" # Verify Calculator data type exists calculator_symbol = next((s for s in all_symbols if s["name"] == "Calculator"), None) assert calculator_symbol is not None, "Calculator data type not found" # The Calculator should be identified as a data type # HLS may use different SymbolKind values (1=File, 5=Class, 23=Struct) assert calculator_symbol["kind"] in [ 1, 5, 23, ], f"Calculator should be a data type (kind 1, 5, or 23), got kind {calculator_symbol['kind']}" @pytest.mark.parametrize("language_server", [Language.HASKELL], indirect=True) def test_helper_module_symbols(self, language_server: SolidLanguageServer): """ Test precise symbol discovery in Helper.hs. Verifies Serena identifies all helper functions that are imported and used by Calculator module. """ all_symbols, _ = language_server.request_document_symbols("src/Helper.hs").get_all_symbols_and_roots() symbol_names = {s["name"] for s in all_symbols} # Verify expected helper functions (module name may also appear) expected_symbols = { "validateNumber", # Function used by Calculator.add and Calculator.subtract "isPositive", # Predicate function "isNegative", # Predicate function used by absolute "absolute", # Function that uses isNegative } # All expected symbols should be present (module name is optional) missing = expected_symbols - symbol_names assert not missing, f"Missing expected symbols in Helper.hs: {missing}" # Verify no unexpected symbols beyond the module name extra = symbol_names - expected_symbols - {"Helper"} assert not extra, f"Unexpected symbols in Helper.hs: {extra}" @pytest.mark.parametrize("language_server", [Language.HASKELL], indirect=True) def test_main_module_imports(self, language_server: SolidLanguageServer): """ Test that Main.hs properly references both Calculator and Helper modules. Verifies Serena can identify cross-module dependencies. """ all_symbols, _ = language_server.request_document_symbols("app/Main.hs").get_all_symbols_and_roots() symbol_names = {s["name"] for s in all_symbols} # Main.hs should have the main function assert "main" in symbol_names, "Main.hs should contain 'main' function" @pytest.mark.parametrize("language_server", [Language.HASKELL], indirect=True) def test_cross_file_references_validateNumber(self, language_server: SolidLanguageServer): """ Test cross-file reference tracking for validateNumber function. validateNumber is defined in Helper.hs:9 and used in: - Calculator.hs:21 (in add function) - Calculator.hs:25 (in subtract function) This proves Serena can track function usage across module boundaries. """ # Get references to validateNumber (defined at line 9, 0-indexed = line 8) references = language_server.request_references("src/Helper.hs", line=8, column=0) # Should find at least: definition in Helper.hs + 2 usages in Calculator.hs assert len(references) >= 2, f"Expected at least 2 references to validateNumber (used in add and subtract), got {len(references)}" # Verify we have references in Calculator.hs reference_paths = [ref["relativePath"] for ref in references] calculator_refs = [path for path in reference_paths if "Calculator.hs" in path] assert len(calculator_refs) >= 2, ( f"Expected at least 2 references in Calculator.hs (add and subtract functions), " f"got {len(calculator_refs)} references in Calculator.hs" ) @pytest.mark.parametrize("language_server", [Language.HASKELL], indirect=True) def test_within_file_references_isNegative(self, language_server: SolidLanguageServer): """ Test within-file reference tracking for isNegative function. isNegative is defined in Helper.hs:17 and used in Helper.hs:22 (absolute function). This proves Serena can track intra-module function calls. """ # isNegative defined at line 17 (0-indexed = line 16) references = language_server.request_references("src/Helper.hs", line=16, column=0) # Should find: definition + usage in absolute function assert len(references) >= 1, f"Expected at least 1 reference to isNegative (used in absolute), got {len(references)}" # All references should be in Helper.hs reference_paths = [ref["relativePath"] for ref in references] assert all( "Helper.hs" in path for path in reference_paths ), f"All isNegative references should be in Helper.hs, got: {reference_paths}" @pytest.mark.parametrize("language_server", [Language.HASKELL], indirect=True) def test_function_references_from_main(self, language_server: SolidLanguageServer): """ Test that functions used in Main.hs can be traced back to their definitions. Main.hs:12 calls 'add' from Calculator module. Main.hs:25 calls 'isPositive' from Helper module. Main.hs:26 calls 'absolute' from Helper module. This proves Serena can track cross-module function calls from executable code. """ # Test 'add' function references (defined in Calculator.hs:20, 0-indexed = line 19) add_refs = language_server.request_references("src/Calculator.hs", line=19, column=0) # Should find references in Main.hs and possibly Calculator.hs (calculate function uses it) assert len(add_refs) >= 1, f"Expected at least 1 reference to 'add', got {len(add_refs)}" add_ref_paths = [ref["relativePath"] for ref in add_refs] # Should have at least one reference in Main.hs or Calculator.hs assert any( "Main.hs" in path or "Calculator.hs" in path for path in add_ref_paths ), f"Expected 'add' to be referenced in Main.hs or Calculator.hs, got: {add_ref_paths}" @pytest.mark.parametrize("language_server", [Language.HASKELL], indirect=True) def test_multiply_function_usage_in_calculate(self, language_server: SolidLanguageServer): """ Test that multiply function usage is tracked within Calculator module. multiply is defined in Calculator.hs:28 and used in: - Calculator.hs:41 (in calculate function via pattern matching) - Main.hs:20 (via calculate call with "multiply" operator) This proves Serena can track function references even when called indirectly. """ # multiply defined at line 28 (0-indexed = line 27) multiply_refs = language_server.request_references("src/Calculator.hs", line=27, column=0) # Should find at least the usage in calculate function assert len(multiply_refs) >= 1, f"Expected at least 1 reference to 'multiply', got {len(multiply_refs)}" # Should have reference in Calculator.hs (calculate function) multiply_ref_paths = [ref["relativePath"] for ref in multiply_refs] assert any( "Calculator.hs" in path for path in multiply_ref_paths ), f"Expected 'multiply' to be referenced in Calculator.hs, got: {multiply_ref_paths}" @pytest.mark.parametrize("language_server", [Language.HASKELL], indirect=True) def test_data_type_constructor_references(self, language_server: SolidLanguageServer): """ Test that Calculator data type constructor usage is tracked. Calculator is defined in Calculator.hs:14 and used in: - Main.hs:8 (constructor call: Calculator "TestCalc" 1) - Calculator.hs:37 (type signature for calculate function) This proves Serena can track data type constructor references. """ # Calculator data type defined at line 14 (0-indexed = line 13) calculator_refs = language_server.request_references("src/Calculator.hs", line=13, column=5) # Should find usage in Main.hs assert len(calculator_refs) >= 1, f"Expected at least 1 reference to Calculator constructor, got {len(calculator_refs)}" # Should have at least one reference in Main.hs or Calculator.hs calc_ref_paths = [ref["relativePath"] for ref in calculator_refs] assert any( "Main.hs" in path or "Calculator.hs" in path for path in calc_ref_paths ), f"Expected Calculator to be referenced in Main.hs or Calculator.hs, got: {calc_ref_paths}" ================================================ FILE: test/solidlsp/hlsl/__init__.py ================================================ ================================================ FILE: test/solidlsp/hlsl/test_hlsl_basic.py ================================================ """ Basic tests for HLSL language server integration (shader-language-server). This module tests Language.HLSL using shader-language-server from antaalt/shader-sense. Tests are skipped if the language server is not available. """ from typing import Any import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_exceptions import SolidLSPException from solidlsp.ls_utils import SymbolUtils def _find_symbol_by_name(language_server: SolidLanguageServer, file_path: str, name: str) -> dict[str, Any] | None: """Find a top-level symbol by name in a file's document symbols.""" symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() return next((s for s in symbols[0] if s.get("name") == name), None) # ── Symbol Discovery ───────────────────────────────────────────── @pytest.mark.hlsl class TestHlslSymbols: """Tests for document symbol extraction.""" @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) def test_find_struct(self, language_server: SolidLanguageServer) -> None: """VertexInput struct should appear in common.hlsl symbols.""" symbol = _find_symbol_by_name(language_server, "common.hlsl", "VertexInput") assert symbol is not None, "Expected 'VertexInput' struct in document symbols" @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) def test_find_function(self, language_server: SolidLanguageServer) -> None: """SafeNormalize function should appear in common.hlsl.""" symbol = _find_symbol_by_name(language_server, "common.hlsl", "SafeNormalize") assert symbol is not None, "Expected 'SafeNormalize' function in document symbols" @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) def test_find_cbuffer_members(self, language_server: SolidLanguageServer) -> None: """Cbuffer members should appear as variables in compute_test.hlsl. Note: shader-language-server reports cbuffer members as individual variables (kind 13), not the cbuffer name itself as a symbol. """ symbol = _find_symbol_by_name(language_server, "compute_test.hlsl", "TextureSize") assert symbol is not None, "Expected 'TextureSize' cbuffer member in document symbols" @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) def test_find_compute_kernel(self, language_server: SolidLanguageServer) -> None: """CSMain kernel should appear in compute_test.hlsl.""" symbol = _find_symbol_by_name(language_server, "compute_test.hlsl", "CSMain") assert symbol is not None, "Expected 'CSMain' compute kernel in document symbols" @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) def test_full_symbol_tree(self, language_server: SolidLanguageServer) -> None: """Full symbol tree should contain symbols from multiple files.""" symbols = language_server.request_full_symbol_tree() assert SymbolUtils.symbol_tree_contains_name(symbols, "VertexInput"), "VertexInput not in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "CalculateDiffuse"), "CalculateDiffuse not in symbol tree" # ── Go-to-Definition ───────────────────────────────────────────── @pytest.mark.hlsl class TestHlslDefinition: """Tests for go-to-definition capability.""" @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) def test_goto_definition_cross_file(self, language_server: SolidLanguageServer) -> None: """Navigating to SafeNormalize call in lighting.hlsl should resolve to common.hlsl. lighting.hlsl line 22 (0-indexed): " float3 halfVec = SafeNormalize(-lightDir + viewDir);" SafeNormalize starts at column 21. """ definitions = language_server.request_definition("lighting.hlsl", 22, 21) assert len(definitions) >= 1, f"Expected at least 1 definition, got {len(definitions)}" def_paths = [d.get("relativePath", d.get("uri", "")) for d in definitions] assert any("common.hlsl" in p for p in def_paths), f"Expected definition in common.hlsl, got: {def_paths}" @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) def test_goto_definition_cross_file_remap(self, language_server: SolidLanguageServer) -> None: """Navigating to Remap call in compute_test.hlsl should resolve to common.hlsl. compute_test.hlsl line 20 (0-indexed): " Remap(color.r, 0.0, 1.0, 0.2, 0.8)," Remap starts at column 8. """ definitions = language_server.request_definition("compute_test.hlsl", 20, 8) assert len(definitions) >= 1, f"Expected at least 1 definition, got {len(definitions)}" def_paths = [d.get("relativePath", d.get("uri", "")) for d in definitions] assert any("common.hlsl" in p for p in def_paths), f"Expected definition in common.hlsl, got: {def_paths}" # ── References ──────────────────────────────────────────────────── @pytest.mark.hlsl class TestHlslReferences: """Tests for find-references capability. shader-language-server does not advertise referencesProvider, so request_references is expected to return an empty list. """ @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) def test_references_not_supported(self, language_server: SolidLanguageServer) -> None: """References request should raise because shader-language-server does not support it. common.hlsl line 17 (0-indexed): "float3 SafeNormalize(float3 v)" SafeNormalize starts at column 7. """ with pytest.raises(SolidLSPException, match="Method not found"): language_server.request_references("common.hlsl", 17, 7) # ── Hover ───────────────────────────────────────────────────────── def _extract_hover_text(hover_info: dict[str, Any]) -> str: """Extract the text content from an LSP hover response.""" contents = hover_info["contents"] if isinstance(contents, dict): return contents.get("value", "") elif isinstance(contents, str): return contents return str(contents) @pytest.mark.hlsl class TestHlslHover: """Tests for hover information.""" @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) def test_hover_on_function(self, language_server: SolidLanguageServer) -> None: """Hovering over SafeNormalize definition should return info. common.hlsl line 17 (0-indexed): "float3 SafeNormalize(float3 v)" SafeNormalize starts at column 7. """ hover_info = language_server.request_hover("common.hlsl", 17, 7) assert hover_info is not None, "Hover should return information for SafeNormalize" assert "contents" in hover_info, "Hover should have contents" hover_text = _extract_hover_text(hover_info) assert len(hover_text) > 0, "Hover text should not be empty" @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) def test_hover_on_struct(self, language_server: SolidLanguageServer) -> None: """Hovering over VertexInput should return struct info. common.hlsl line 3 (0-indexed): "struct VertexInput" VertexInput starts at column 7. """ hover_info = language_server.request_hover("common.hlsl", 3, 7) assert hover_info is not None, "Hover should return information for VertexInput" assert "contents" in hover_info, "Hover should have contents" ================================================ FILE: test/solidlsp/hlsl/test_hlsl_full_index.py ================================================ """ Regression tests for HLSL full symbol tree indexing. These tests verify that request_full_symbol_tree() correctly indexes all files, including .hlsl includes in subdirectories. This catches bugs where files are silently dropped during workspace-wide indexing. """ from typing import Any import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind from solidlsp.ls_utils import SymbolUtils def _collect_file_names(symbols: list[dict[str, Any]]) -> set[str]: """Recursively collect the names of all File-kind symbols in the tree.""" names: set[str] = set() for sym in symbols: if sym.get("kind") == SymbolKind.File: names.add(sym["name"]) if "children" in sym: names.update(_collect_file_names(sym["children"])) return names EXPECTED_FILES = {"common", "lighting", "compute_test", "terrain_sdf"} TERRAIN_SDF_UNIQUE_SYMBOLS = {"SampleSDF", "CalculateGradient", "SDFBrickData"} @pytest.mark.hlsl class TestHlslFullIndex: """Tests for full symbol tree indexing completeness.""" @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) def test_all_files_indexed_in_symbol_tree(self, language_server: SolidLanguageServer) -> None: """Every .hlsl file in the test repo must appear as a File symbol in the tree.""" symbols = language_server.request_full_symbol_tree() file_names = _collect_file_names(symbols) missing = EXPECTED_FILES - file_names assert not missing, f"Files missing from full symbol tree: {missing}. Found: {file_names}" @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) def test_subdirectory_file_symbols_present(self, language_server: SolidLanguageServer) -> None: """Symbols unique to terrain/terrain_sdf.hlsl must appear in the full tree.""" symbols = language_server.request_full_symbol_tree() for name in TERRAIN_SDF_UNIQUE_SYMBOLS: assert SymbolUtils.symbol_tree_contains_name( symbols, name ), f"Expected '{name}' from terrain/terrain_sdf.hlsl in full symbol tree" @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) def test_include_file_document_symbols_directly(self, language_server: SolidLanguageServer) -> None: """request_document_symbols on terrain/terrain_sdf.hlsl should return its symbols.""" doc_symbols = language_server.request_document_symbols("terrain/terrain_sdf.hlsl") all_symbols = doc_symbols.get_all_symbols_and_roots() symbol_names = {s.get("name") for s in all_symbols[0]} for name in TERRAIN_SDF_UNIQUE_SYMBOLS: assert name in symbol_names, f"Expected '{name}' in document symbols for terrain/terrain_sdf.hlsl, got: {symbol_names}" ================================================ FILE: test/solidlsp/java/test_java_basic.py ================================================ import os import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils from test.conftest import language_tests_enabled pytestmark = [pytest.mark.java, pytest.mark.skipif(not language_tests_enabled(Language.JAVA), reason="Java tests disabled")] class TestJavaLanguageServer: @pytest.mark.parametrize("language_server", [Language.JAVA], indirect=True) def test_find_symbol(self, language_server: SolidLanguageServer) -> None: symbols = language_server.request_full_symbol_tree() assert SymbolUtils.symbol_tree_contains_name(symbols, "Main"), "Main class not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "Utils"), "Utils class not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "Model"), "Model class not found in symbol tree" @pytest.mark.parametrize("language_server", [Language.JAVA], indirect=True) def test_find_referencing_symbols(self, language_server: SolidLanguageServer) -> None: # Use correct Maven/Java file paths file_path = os.path.join("src", "main", "java", "test_repo", "Utils.java") refs = language_server.request_references(file_path, 4, 20) assert any("Main.java" in ref.get("relativePath", "") for ref in refs), "Main should reference Utils.printHello" # Dynamically determine the correct line/column for the 'Model' class name file_path = os.path.join("src", "main", "java", "test_repo", "Model.java") symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() model_symbol = None for sym in symbols[0]: if sym.get("name") == "Model" and sym.get("kind") == 5: # 5 = Class model_symbol = sym break assert model_symbol is not None, "Could not find 'Model' class symbol in Model.java" # Use selectionRange if present, otherwise fall back to range if "selectionRange" in model_symbol: sel_start = model_symbol["selectionRange"]["start"] else: sel_start = model_symbol["range"]["start"] refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) assert any( "Main.java" in ref.get("relativePath", "") for ref in refs ), "Main should reference Model (tried all positions in selectionRange)" @pytest.mark.parametrize("language_server", [Language.JAVA], indirect=True) def test_overview_methods(self, language_server: SolidLanguageServer) -> None: symbols = language_server.request_full_symbol_tree() assert SymbolUtils.symbol_tree_contains_name(symbols, "Main"), "Main missing from overview" assert SymbolUtils.symbol_tree_contains_name(symbols, "Utils"), "Utils missing from overview" assert SymbolUtils.symbol_tree_contains_name(symbols, "Model"), "Model missing from overview" ================================================ FILE: test/solidlsp/julia/test_julia_basic.py ================================================ import pytest from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import Language @pytest.mark.julia class TestJuliaLanguageServer: @pytest.mark.parametrize("language_server", [Language.JULIA], indirect=True) def test_julia_symbols(self, language_server: SolidLanguageServer): """ Test if we can find the top-level symbols in the main.jl file. """ all_symbols, _ = language_server.request_document_symbols("main.jl").get_all_symbols_and_roots() symbol_names = {s["name"] for s in all_symbols} assert "calculate_sum" in symbol_names assert "main" in symbol_names @pytest.mark.parametrize("language_server", [Language.JULIA], indirect=True) def test_julia_within_file_references(self, language_server: SolidLanguageServer): """ Test finding references to a function within the same file. """ # Find references to 'calculate_sum' - the function name starts at line 2, column 9 # LSP uses 0-based indexing references = language_server.request_references("main.jl", line=2, column=9) # Should find at least the definition and the call site assert len(references) >= 1, f"Expected at least 1 reference, got {len(references)}" # Verify at least one reference is in main.jl reference_paths = [ref["relativePath"] for ref in references] assert "main.jl" in reference_paths @pytest.mark.parametrize("language_server", [Language.JULIA], indirect=True) def test_julia_cross_file_references(self, language_server: SolidLanguageServer): """ Test finding references to a function defined in another file. """ # The 'say_hello' function name starts at line 1, column 13 in lib/helper.jl # LSP uses 0-based indexing references = language_server.request_references("lib/helper.jl", line=1, column=13) # Should find at least the call site in main.jl assert len(references) >= 1, f"Expected at least 1 reference, got {len(references)}" # Verify at least one reference points to the usage reference_paths = [ref["relativePath"] for ref in references] # The reference might be in either file (definition or usage) assert "main.jl" in reference_paths or "lib/helper.jl" in reference_paths ================================================ FILE: test/solidlsp/kotlin/test_kotlin_basic.py ================================================ import os import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils from test.conftest import is_ci # Kotlin LSP (IntelliJ-based, pre-alpha v261) crashes on JVM restart under CI resource constraints # (2 CPUs, 7GB RAM). First start succeeds but subsequent starts fail with cancelled (-32800). # Tests pass reliably on developer machines. See PR #1061 for investigation details. @pytest.mark.skipif(is_ci, reason="Kotlin LSP JVM restart is unstable on CI runners") @pytest.mark.kotlin class TestKotlinLanguageServer: @pytest.mark.parametrize("language_server", [Language.KOTLIN], indirect=True) def test_find_symbol(self, language_server: SolidLanguageServer) -> None: symbols = language_server.request_full_symbol_tree() assert SymbolUtils.symbol_tree_contains_name(symbols, "Main"), "Main class not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "Utils"), "Utils class not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "Model"), "Model class not found in symbol tree" @pytest.mark.parametrize("language_server", [Language.KOTLIN], indirect=True) def test_find_referencing_symbols(self, language_server: SolidLanguageServer) -> None: # Use correct Kotlin file paths file_path = os.path.join("src", "main", "kotlin", "test_repo", "Utils.kt") refs = language_server.request_references(file_path, 3, 12) assert any("Main.kt" in ref.get("relativePath", "") for ref in refs), "Main should reference Utils.printHello" # Dynamically determine the correct line/column for the 'Model' class name file_path = os.path.join("src", "main", "kotlin", "test_repo", "Model.kt") symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() model_symbol = None for sym in symbols[0]: print(sym) print("\n") if sym.get("name") == "Model" and sym.get("kind") == 23: # 23 = Class model_symbol = sym break assert model_symbol is not None, "Could not find 'Model' class symbol in Model.kt" # Use selectionRange if present, otherwise fall back to range if "selectionRange" in model_symbol: sel_start = model_symbol["selectionRange"]["start"] else: sel_start = model_symbol["range"]["start"] refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) assert any( "Main.kt" in ref.get("relativePath", "") for ref in refs ), "Main should reference Model (tried all positions in selectionRange)" @pytest.mark.parametrize("language_server", [Language.KOTLIN], indirect=True) def test_overview_methods(self, language_server: SolidLanguageServer) -> None: symbols = language_server.request_full_symbol_tree() assert SymbolUtils.symbol_tree_contains_name(symbols, "Main"), "Main missing from overview" assert SymbolUtils.symbol_tree_contains_name(symbols, "Utils"), "Utils missing from overview" assert SymbolUtils.symbol_tree_contains_name(symbols, "Model"), "Model missing from overview" ================================================ FILE: test/solidlsp/lean4/test_lean4_basic.py ================================================ """ Tests for Lean 4 Language Server integration with Serena. Tests prove that Serena's symbol tools can: 1. Start the Lean 4 language server 2. Discover all expected symbols with precise matching 3. Track within-file references 4. Track cross-file references Test Repository Structure: - Helper.lean: Calculator structure, arithmetic functions (add, subtract), predicates (isPositive, absolute) - Main.lean: Main entry point using Helper, plus multiply and calculate functions """ import pytest from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import Language @pytest.mark.lean4 class TestLean4LanguageServer: @pytest.mark.parametrize("language_server", [Language.LEAN4], indirect=True) def test_ls_is_running(self, language_server: SolidLanguageServer) -> None: """Test that the Lean 4 language server starts successfully.""" assert language_server.is_running() @pytest.mark.parametrize("language_server", [Language.LEAN4], indirect=True) def test_helper_symbols(self, language_server: SolidLanguageServer) -> None: """ Test symbol discovery in Helper.lean. Verifies that Serena can identify: - Structure definition (Calculator) - All functions (add, subtract, isPositive, absolute) """ all_symbols, _ = language_server.request_document_symbols("Helper.lean").get_all_symbols_and_roots() symbol_names = {s["name"] for s in all_symbols} expected_symbols = { "Calculator", "add", "subtract", "isPositive", "absolute", } missing = expected_symbols - symbol_names assert not missing, f"Missing expected symbols in Helper.lean: {missing}" @pytest.mark.parametrize("language_server", [Language.LEAN4], indirect=True) def test_main_symbols(self, language_server: SolidLanguageServer) -> None: """ Test symbol discovery in Main.lean. Verifies that Serena can identify locally defined functions. """ all_symbols, _ = language_server.request_document_symbols("Main.lean").get_all_symbols_and_roots() symbol_names = {s["name"] for s in all_symbols} expected_symbols = { "multiply", "calculate", "main", } missing = expected_symbols - symbol_names assert not missing, f"Missing expected symbols in Main.lean: {missing}" @pytest.mark.parametrize("language_server", [Language.LEAN4], indirect=True) def test_within_file_references(self, language_server: SolidLanguageServer) -> None: """ Test within-file reference tracking for isPositive. isPositive is defined in Helper.lean line 11 (0-indexed) and used by absolute on line 15. """ # isPositive defined at line 11, column 4 references = language_server.request_references("Helper.lean", line=11, column=4) assert len(references) >= 1, f"Expected at least 1 reference to isPositive (used in absolute), got {len(references)}" # Check that isPositive is referenced within Helper.lean at line 15 (absolute calls isPositive) ref_locations = [(ref["relativePath"], ref["range"]["start"]["line"]) for ref in references] helper_refs = [(path, line) for path, line in ref_locations if "Helper.lean" in path] assert any( line == 15 for _, line in helper_refs ), f"Expected isPositive reference at Helper.lean:15 (in absolute), got: {ref_locations}" @pytest.mark.parametrize("language_server", [Language.LEAN4], indirect=True) def test_cross_file_references_add(self, language_server: SolidLanguageServer) -> None: """ Test cross-file reference tracking for add function. add is defined in Helper.lean line 5 (0-indexed) and used in Main.lean on lines 7 and 15. """ # add defined at line 5, column 4 references = language_server.request_references("Helper.lean", line=5, column=4) assert len(references) >= 1, f"Expected at least 1 reference to add in Main.lean, got {len(references)}" # Check for references in Main.lean with specific lines ref_locations = [(ref["relativePath"], ref["range"]["start"]["line"]) for ref in references] main_refs = [(path, line) for path, line in ref_locations if "Main.lean" in path] assert len(main_refs) >= 1, f"Expected at least 1 reference to add in Main.lean, got: {ref_locations}" main_ref_lines = {line for _, line in main_refs} # add is used in Main.lean line 7 (in calculate) and line 15 (in main) assert ( 7 in main_ref_lines or 15 in main_ref_lines ), f"Expected add references at Main.lean lines 7 or 15, got lines: {main_ref_lines}" @pytest.mark.parametrize("language_server", [Language.LEAN4], indirect=True) def test_cross_file_references_calculator(self, language_server: SolidLanguageServer) -> None: """ Test cross-file reference tracking for Calculator structure. Calculator is defined in Helper.lean line 0 (0-indexed) and used in Main.lean lines 5 and 13. """ # Calculator defined at line 0, column 10 references = language_server.request_references("Helper.lean", line=0, column=10) assert len(references) >= 1, f"Expected at least 1 reference to Calculator in Main.lean, got {len(references)}" ref_locations = [(ref["relativePath"], ref["range"]["start"]["line"]) for ref in references] main_refs = [(path, line) for path, line in ref_locations if "Main.lean" in path] assert len(main_refs) >= 1, f"Expected at least 1 reference to Calculator in Main.lean, got: {ref_locations}" main_ref_lines = {line for _, line in main_refs} # Calculator is used in Main.lean line 5 (calculate signature) and line 13 (let c : Calculator) assert ( 5 in main_ref_lines or 13 in main_ref_lines ), f"Expected Calculator references at Main.lean lines 5 or 13, got lines: {main_ref_lines}" @pytest.mark.parametrize("language_server", [Language.LEAN4], indirect=True) def test_go_to_definition_within_file(self, language_server: SolidLanguageServer) -> None: """ Test go-to-definition within a file. In Main.lean line 19, calculate is called: 'match calculate c "multiply" 6 7 with'. calculate is defined at Main.lean line 5. """ # calculate usage in Main.lean line 19, 'calculate' starts at col 8 definitions = language_server.request_definition("Main.lean", line=19, column=8) assert len(definitions) >= 1, f"Expected at least 1 definition for calculate, got {len(definitions)}" def_location = definitions[0] assert def_location["uri"].endswith("Main.lean"), f"Expected definition in Main.lean, got: {def_location['uri']}" assert def_location["range"]["start"]["line"] == 5, f"Expected definition at line 5, got: {def_location['range']['start']['line']}" @pytest.mark.parametrize("language_server", [Language.LEAN4], indirect=True) def test_go_to_definition_across_files(self, language_server: SolidLanguageServer) -> None: """ Test go-to-definition across files. In Main.lean line 15, add is called: 'add 5 3'. add is defined in Helper.lean line 5. """ # add usage in Main.lean line 15, 'add' starts at col 19 definitions = language_server.request_definition("Main.lean", line=15, column=19) assert len(definitions) >= 1, f"Expected at least 1 definition for add, got {len(definitions)}" def_location = definitions[0] assert def_location["uri"].endswith("Helper.lean"), f"Expected definition in Helper.lean, got: {def_location['uri']}" assert def_location["range"]["start"]["line"] == 5, f"Expected definition at line 5, got: {def_location['range']['start']['line']}" ================================================ FILE: test/solidlsp/lua/test_lua_basic.py ================================================ """ Tests for the Lua language server implementation. These tests validate symbol finding and cross-file reference capabilities for Lua modules and functions. """ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind @pytest.mark.lua class TestLuaLanguageServer: """Test Lua language server symbol finding and cross-file references.""" @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) def test_find_symbols_in_calculator(self, language_server: SolidLanguageServer) -> None: """Test finding specific functions in calculator.lua.""" symbols = language_server.request_document_symbols("src/calculator.lua").get_all_symbols_and_roots() assert symbols is not None assert len(symbols) > 0 # Extract function names from the returned structure symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols function_names = set() for symbol in symbol_list: if isinstance(symbol, dict): name = symbol.get("name", "") # Handle both plain names and module-prefixed names if "." in name: name = name.split(".")[-1] if symbol.get("kind") == SymbolKind.Function: function_names.add(name) # Verify exact calculator functions exist expected_functions = {"add", "subtract", "multiply", "divide", "factorial"} found_functions = function_names & expected_functions assert found_functions == expected_functions, f"Expected exactly {expected_functions}, found {found_functions}" # Verify specific functions assert "add" in function_names, "add function not found" assert "multiply" in function_names, "multiply function not found" assert "factorial" in function_names, "factorial function not found" @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) def test_find_symbols_in_utils(self, language_server: SolidLanguageServer) -> None: """Test finding specific functions in utils.lua.""" symbols = language_server.request_document_symbols("src/utils.lua").get_all_symbols_and_roots() assert symbols is not None assert len(symbols) > 0 symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols function_names = set() all_symbols = set() for symbol in symbol_list: if isinstance(symbol, dict): name = symbol.get("name", "") all_symbols.add(name) # Handle both plain names and module-prefixed names if "." in name: name = name.split(".")[-1] if symbol.get("kind") == SymbolKind.Function: function_names.add(name) # Verify exact string utility functions expected_utils = {"trim", "split", "starts_with", "ends_with"} found_utils = function_names & expected_utils assert found_utils == expected_utils, f"Expected exactly {expected_utils}, found {found_utils}" # Verify exact table utility functions table_utils = {"deep_copy", "table_contains", "table_merge"} found_table_utils = function_names & table_utils assert found_table_utils == table_utils, f"Expected exactly {table_utils}, found {found_table_utils}" # Check for Logger class/table assert "Logger" in all_symbols or any("Logger" in s for s in all_symbols), "Logger not found in symbols" @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) def test_find_symbols_in_main(self, language_server: SolidLanguageServer) -> None: """Test finding functions in main.lua.""" symbols = language_server.request_document_symbols("main.lua").get_all_symbols_and_roots() assert symbols is not None assert len(symbols) > 0 symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols function_names = set() for symbol in symbol_list: if isinstance(symbol, dict) and symbol.get("kind") == SymbolKind.Function: function_names.add(symbol.get("name", "")) # Verify exact main functions exist expected_funcs = {"print_banner", "test_calculator", "test_utils"} found_funcs = function_names & expected_funcs assert found_funcs == expected_funcs, f"Expected exactly {expected_funcs}, found {found_funcs}" assert "test_calculator" in function_names, "test_calculator function not found" assert "test_utils" in function_names, "test_utils function not found" @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) def test_cross_file_references_calculator_add(self, language_server: SolidLanguageServer) -> None: """Test finding cross-file references to calculator.add function.""" symbols = language_server.request_document_symbols("src/calculator.lua").get_all_symbols_and_roots() assert symbols is not None symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols # Find the add function add_symbol = None for sym in symbol_list: if isinstance(sym, dict): name = sym.get("name", "") if "add" in name or name == "add": add_symbol = sym break assert add_symbol is not None, "add function not found in calculator.lua" # Get references to the add function range_info = add_symbol.get("selectionRange", add_symbol.get("range")) assert range_info is not None, "add function has no range information" range_start = range_info["start"] refs = language_server.request_references("src/calculator.lua", range_start["line"], range_start["character"]) assert refs is not None assert isinstance(refs, list) # add function appears in: main.lua (lines 16, 71), test_calculator.lua (lines 22, 23, 24) # Note: The declaration itself may or may not be included as a reference assert len(refs) >= 5, f"Should find at least 5 references to calculator.add, found {len(refs)}" # Verify exact reference locations ref_files: dict[str, list[int]] = {} for ref in refs: filename = ref.get("uri", "").split("/")[-1] if filename not in ref_files: ref_files[filename] = [] ref_files[filename].append(ref["range"]["start"]["line"]) # The declaration may or may not be included if "calculator.lua" in ref_files: assert ( 5 in ref_files["calculator.lua"] ), f"If declaration is included, it should be at line 6 (0-indexed: 5), found at {ref_files['calculator.lua']}" # Check main.lua has usages assert "main.lua" in ref_files, "Should find add usages in main.lua" assert ( 15 in ref_files["main.lua"] or 70 in ref_files["main.lua"] ), f"Should find add usage in main.lua, found at lines {ref_files.get('main.lua', [])}" # Check for cross-file references from main.lua main_refs = [ref for ref in refs if "main.lua" in ref.get("uri", "")] assert len(main_refs) > 0, "calculator.add should be called in main.lua" @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) def test_cross_file_references_utils_trim(self, language_server: SolidLanguageServer) -> None: """Test finding cross-file references to utils.trim function.""" symbols = language_server.request_document_symbols("src/utils.lua").get_all_symbols_and_roots() assert symbols is not None symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols # Find the trim function trim_symbol = None for sym in symbol_list: if isinstance(sym, dict): name = sym.get("name", "") if "trim" in name or name == "trim": trim_symbol = sym break assert trim_symbol is not None, "trim function not found in utils.lua" # Get references to the trim function range_info = trim_symbol.get("selectionRange", trim_symbol.get("range")) assert range_info is not None, "trim function has no range information" range_start = range_info["start"] refs = language_server.request_references("src/utils.lua", range_start["line"], range_start["character"]) assert refs is not None assert isinstance(refs, list) # trim function appears in: usage (line 32 in main.lua) # Note: The declaration itself may or may not be included as a reference assert len(refs) >= 1, f"Should find at least 1 reference to utils.trim, found {len(refs)}" # Verify exact reference locations ref_files: dict[str, list[int]] = {} for ref in refs: filename = ref.get("uri", "").split("/")[-1] if filename not in ref_files: ref_files[filename] = [] ref_files[filename].append(ref["range"]["start"]["line"]) # The declaration may or may not be included if "utils.lua" in ref_files: assert ( 5 in ref_files["utils.lua"] ), f"If declaration is included, it should be at line 6 (0-indexed: 5), found at {ref_files['utils.lua']}" # Check main.lua has usage assert "main.lua" in ref_files, "Should find trim usage in main.lua" assert ( 31 in ref_files["main.lua"] ), f"Should find trim usage at line 32 (0-indexed: 31) in main.lua, found at lines {ref_files.get('main.lua', [])}" # Check for cross-file references from main.lua main_refs = [ref for ref in refs if "main.lua" in ref.get("uri", "")] assert len(main_refs) > 0, "utils.trim should be called in main.lua" @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) def test_hover_information(self, language_server: SolidLanguageServer) -> None: """Test hover information for symbols.""" # Get hover info for a function hover_info = language_server.request_hover("src/calculator.lua", 5, 10) # Position near add function assert hover_info is not None, "Should provide hover information" # Hover info could be a dict with 'contents' or a string if isinstance(hover_info, dict): assert "contents" in hover_info or "value" in hover_info, "Hover should have contents" @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) def test_full_symbol_tree(self, language_server: SolidLanguageServer) -> None: """Test that full symbol tree is not empty.""" symbols = language_server.request_full_symbol_tree() assert symbols is not None assert len(symbols) > 0, "Symbol tree should not be empty" # The tree should have at least one root node root = symbols[0] assert isinstance(root, dict), "Root should be a dict" assert "name" in root, "Root should have a name" @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) def test_references_between_test_and_source(self, language_server: SolidLanguageServer) -> None: """Test finding references from test files to source files.""" # Check if test_calculator.lua references calculator module test_symbols = language_server.request_document_symbols("tests/test_calculator.lua").get_all_symbols_and_roots() assert test_symbols is not None assert len(test_symbols) > 0 # The test file should have some content that references calculator symbol_list = test_symbols[0] if isinstance(test_symbols, tuple) else test_symbols assert len(symbol_list) > 0, "test_calculator.lua should have symbols" ================================================ FILE: test/solidlsp/luau/__init__.py ================================================ ================================================ FILE: test/solidlsp/luau/test_luau_basic.py ================================================ """ Tests for the Luau language server implementation. These tests validate symbol finding, within-file references, and cross-file reference capabilities for Luau modules and functions. """ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language @pytest.mark.luau class TestLuauLanguageServer: """Test Luau language server symbol finding and cross-file references.""" @pytest.mark.parametrize("language_server", [Language.LUAU], indirect=True) def test_find_symbols_in_init(self, language_server: SolidLanguageServer) -> None: """Test finding specific functions in init.luau.""" symbols = language_server.request_document_symbols("src/init.luau").get_all_symbols_and_roots() assert symbols is not None assert len(symbols) > 0 symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols symbol_names = set() for symbol in symbol_list: if isinstance(symbol, dict): name = symbol.get("name", "") symbol_names.add(name) assert "createConfig" in symbol_names, f"createConfig not found in symbols: {symbol_names}" assert "main" in symbol_names, f"main not found in symbols: {symbol_names}" @pytest.mark.parametrize("language_server", [Language.LUAU], indirect=True) def test_find_symbols_in_module(self, language_server: SolidLanguageServer) -> None: """Test finding specific functions in module.luau.""" symbols = language_server.request_document_symbols("src/module.luau").get_all_symbols_and_roots() assert symbols is not None assert len(symbols) > 0 symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols symbol_names = set() for symbol in symbol_list: if isinstance(symbol, dict): name = symbol.get("name", "") symbol_names.add(name) assert "process" in symbol_names, f"process not found in symbols: {symbol_names}" assert "helper" in symbol_names, f"helper not found in symbols: {symbol_names}" @pytest.mark.parametrize("language_server", [Language.LUAU], indirect=True) def test_find_references_within_file(self, language_server: SolidLanguageServer) -> None: """Test finding within-file references to createConfig in init.luau. createConfig is defined at line 8 (0-indexed) and referenced at lines 17 and 23. """ symbols = language_server.request_document_symbols("src/init.luau").get_all_symbols_and_roots() assert symbols is not None symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols # Find the createConfig function symbol create_config_symbol = None for sym in symbol_list: if isinstance(sym, dict) and sym.get("name") == "createConfig": create_config_symbol = sym break assert create_config_symbol is not None, "createConfig function not found in init.luau" range_info = create_config_symbol.get("selectionRange", create_config_symbol.get("range")) assert range_info is not None, "createConfig has no range information" range_start = range_info["start"] refs = language_server.request_references("src/init.luau", range_start["line"], range_start["character"]) assert refs is not None assert isinstance(refs, list) # createConfig appears multiple times within init.luau: # definition (line 8), usage in main (line 17), and return table (line 23) assert len(refs) >= 2, f"Should find at least 2 references to createConfig, found {len(refs)}" # Verify that references are in init.luau ref_files = set() for ref in refs: filename = ref.get("uri", "").split("/")[-1] ref_files.add(filename) assert "init.luau" in ref_files, f"Expected references in init.luau, found in: {ref_files}" @pytest.mark.parametrize("language_server", [Language.LUAU], indirect=True) def test_find_references_across_files(self, language_server: SolidLanguageServer) -> None: """Test finding cross-file references to process function. process is defined in module.luau and used in init.luau via module.process(). """ symbols = language_server.request_document_symbols("src/module.luau").get_all_symbols_and_roots() assert symbols is not None symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols # Find the process function symbol process_symbol = None for sym in symbol_list: if isinstance(sym, dict) and sym.get("name") == "process": process_symbol = sym break assert process_symbol is not None, "process function not found in module.luau" range_info = process_symbol.get("selectionRange", process_symbol.get("range")) assert range_info is not None, "process function has no range information" range_start = range_info["start"] refs = language_server.request_references("src/module.luau", range_start["line"], range_start["character"]) assert refs is not None assert isinstance(refs, list) assert len(refs) >= 1, f"Should find at least 1 reference to process, found {len(refs)}" # Collect reference files and lines ref_info: dict[str, list[int]] = {} for ref in refs: filename = ref.get("uri", "").split("/")[-1] if filename not in ref_info: ref_info[filename] = [] ref_info[filename].append(ref["range"]["start"]["line"]) # The definition in module.luau may or may not be included # We expect at least the reference in module.luau return table (line 9) assert "module.luau" in ref_info, f"Expected references in module.luau, found in: {set(ref_info.keys())}" @pytest.mark.parametrize("language_server", [Language.LUAU], indirect=True) def test_find_definition(self, language_server: SolidLanguageServer) -> None: """Test finding definition of createConfig from its usage in main(). createConfig is used at line 17, column 20 (0-indexed) in init.luau. Its definition should be at line 8 in init.luau. """ # Line 17 (0-indexed): ` local config = createConfig("test", 42)` # createConfig starts at column 20 definition_locations = language_server.request_definition("src/init.luau", 17, 20) assert definition_locations, f"Expected non-empty definition list but got {definition_locations}" assert len(definition_locations) >= 1 definition = definition_locations[0] assert definition["uri"].endswith("init.luau"), f"Definition should be in init.luau, got: {definition['uri']}" # createConfig is defined at line 8 (0-indexed): `local function createConfig(...)` assert definition["range"]["start"]["line"] == 8, f"Definition should be at line 8, got line {definition['range']['start']['line']}" ================================================ FILE: test/solidlsp/luau/test_luau_dependency_provider.py ================================================ """Tests for the Luau language server dependency provider.""" import io import zipfile from pathlib import Path from unittest.mock import patch import pytest from solidlsp.language_servers.luau_lsp import LuauLanguageServer from solidlsp.settings import SolidLSPSettings def _make_provider( tmp_path: Path, custom_settings: dict[str, str] | None = None, ) -> LuauLanguageServer.DependencyProvider: return LuauLanguageServer.DependencyProvider( custom_settings=SolidLSPSettings.CustomLSSettings(custom_settings or {}), ls_resources_dir=str(tmp_path), ) class _FakeResponse: def __init__(self, content: bytes) -> None: self.content = content def raise_for_status(self) -> None: return def iter_content(self, chunk_size: int = 8192): yield self.content def __enter__(self) -> "_FakeResponse": return self def __exit__(self, exc_type, exc, tb) -> None: return None @pytest.mark.luau class TestLuauDependencyProvider: def test_create_launch_command_uses_ls_path_override_and_adds_assets(self, tmp_path: Path) -> None: provider = _make_provider(tmp_path, {"ls_path": "/custom/luau-lsp"}) with patch.object( provider, "_get_or_install_core_dependency", side_effect=AssertionError("_get_or_install_core_dependency should not be called when ls_path is set"), ): with patch.object( provider, "_resolve_support_files", return_value=("/tmp/globalTypes.d.luau", "/tmp/en-us.json"), ): assert provider.create_launch_command() == [ "/custom/luau-lsp", "lsp", "--definitions:@roblox=/tmp/globalTypes.d.luau", "--docs=/tmp/en-us.json", ] def test_resolve_support_files_defaults_to_roblox_mode(self, tmp_path: Path) -> None: provider = _make_provider(tmp_path) with patch.object( provider, "_download_roblox_support_files", return_value=("/tmp/globalTypes.PluginSecurity.d.luau", "/tmp/en-us.json"), ) as download_roblox_support_files: with patch.object( provider, "_download_standard_docs", side_effect=AssertionError("_download_standard_docs should not be called in roblox mode"), ): assert provider._resolve_support_files() == ( "/tmp/globalTypes.PluginSecurity.d.luau", "/tmp/en-us.json", ) download_roblox_support_files.assert_called_once_with("PluginSecurity") def test_resolve_support_files_uses_standard_mode_docs_only(self, tmp_path: Path) -> None: provider = _make_provider(tmp_path, {"platform": "standard"}) with patch.object(provider, "_download_standard_docs", return_value="/tmp/luau-en-us.json") as download_standard_docs: with patch.object( provider, "_download_roblox_support_files", side_effect=AssertionError("_download_roblox_support_files should not be called in standard mode"), ): assert provider._resolve_support_files() == ( None, "/tmp/luau-en-us.json", ) download_standard_docs.assert_called_once_with() def test_get_or_install_core_dependency_uses_system_binary(self, tmp_path: Path) -> None: provider = _make_provider(tmp_path) with patch("solidlsp.language_servers.luau_lsp.shutil.which", return_value="/usr/bin/luau-lsp"): with patch.object( provider, "_download_luau_lsp", side_effect=AssertionError("_download_luau_lsp should not be called when luau-lsp is on PATH"), ): assert provider._get_or_install_core_dependency() == "/usr/bin/luau-lsp" def test_download_luau_lsp_extracts_binary_into_ls_resources_dir(self, tmp_path: Path) -> None: provider = _make_provider(tmp_path) archive = io.BytesIO() with zipfile.ZipFile(archive, "w") as zip_file: zip_file.writestr("nested/luau-lsp", "#!/bin/sh\n") with patch("solidlsp.language_servers.luau_lsp.platform.system", return_value="Linux"): with patch("solidlsp.language_servers.luau_lsp.platform.machine", return_value="aarch64"): with patch("solidlsp.language_servers.luau_lsp.requests.get", return_value=_FakeResponse(archive.getvalue())): binary_path = provider._download_luau_lsp() resolved_binary = Path(binary_path) assert resolved_binary.exists() assert resolved_binary.name == "luau-lsp" assert str(resolved_binary).startswith(str(tmp_path)) def test_download_roblox_support_files_writes_into_ls_resources_dir(self, tmp_path: Path) -> None: provider = _make_provider(tmp_path) with patch( "solidlsp.language_servers.luau_lsp.requests.get", side_effect=[_FakeResponse(b"types"), _FakeResponse(b"docs")], ): definitions_path, docs_path = provider._download_roblox_support_files("LocalUserSecurity") assert definitions_path == str(tmp_path / "globalTypes.LocalUserSecurity.d.luau") assert docs_path == str(tmp_path / "en-us.json") assert (tmp_path / "globalTypes.LocalUserSecurity.d.luau").read_bytes() == b"types" assert (tmp_path / "en-us.json").read_bytes() == b"docs" def test_download_standard_docs_writes_into_ls_resources_dir(self, tmp_path: Path) -> None: provider = _make_provider(tmp_path, {"platform": "standard"}) with patch("solidlsp.language_servers.luau_lsp.requests.get", return_value=_FakeResponse(b"docs")): docs_path = provider._download_standard_docs() assert docs_path == str(tmp_path / "luau-en-us.json") assert (tmp_path / "luau-en-us.json").read_bytes() == b"docs" def test_workspace_configuration_uses_configured_platform(self) -> None: config = LuauLanguageServer._get_workspace_configuration(SolidLSPSettings.CustomLSSettings({"platform": "standard"})) assert config == {"platform": {"type": "standard"}} def test_invalid_platform_raises(self, tmp_path: Path) -> None: provider = _make_provider(tmp_path, {"platform": "invalid"}) with pytest.raises(ValueError, match="Unsupported Luau platform"): provider._resolve_support_files() def test_invalid_roblox_security_level_raises(self, tmp_path: Path) -> None: provider = _make_provider(tmp_path, {"roblox_security_level": "invalid"}) with pytest.raises(ValueError, match="Unsupported Luau Roblox security level"): provider._resolve_support_files() ================================================ FILE: test/solidlsp/markdown/__init__.py ================================================ """Tests for markdown language server functionality.""" ================================================ FILE: test/solidlsp/markdown/test_markdown_basic.py ================================================ """ Basic integration tests for the markdown language server functionality. These tests validate the functionality of the language server APIs like request_document_symbols using the markdown test repository. """ import pytest from serena.symbol import LanguageServerSymbol from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind @pytest.mark.markdown class TestMarkdownLanguageServerBasics: """Test basic functionality of the markdown language server.""" @pytest.mark.parametrize("language_server", [Language.MARKDOWN], indirect=True) def test_markdown_language_server_initialization(self, language_server: SolidLanguageServer) -> None: """Test that markdown language server can be initialized successfully.""" assert language_server is not None assert language_server.language == Language.MARKDOWN @pytest.mark.parametrize("language_server", [Language.MARKDOWN], indirect=True) def test_markdown_request_document_symbols(self, language_server: SolidLanguageServer) -> None: """Test request_document_symbols for markdown files.""" all_symbols, _root_symbols = language_server.request_document_symbols("README.md").get_all_symbols_and_roots() heading_names = [symbol["name"] for symbol in all_symbols] # Should detect headings from README.md assert "Test Repository" in heading_names or len(all_symbols) > 0, "Should find at least one heading" # Verify that markdown headings are remapped from String to Namespace for symbol in all_symbols: assert ( symbol["kind"] == SymbolKind.Namespace ), f"Heading '{symbol['name']}' should have kind Namespace, got {SymbolKind(symbol['kind']).name}" @pytest.mark.parametrize("language_server", [Language.MARKDOWN], indirect=True) def test_markdown_request_symbols_from_guide(self, language_server: SolidLanguageServer) -> None: """Test symbol detection in guide.md file.""" all_symbols, _root_symbols = language_server.request_document_symbols("guide.md").get_all_symbols_and_roots() # At least some headings should be found assert len(all_symbols) > 0, f"Should find headings in guide.md, found {len(all_symbols)}" @pytest.mark.parametrize("language_server", [Language.MARKDOWN], indirect=True) def test_markdown_request_symbols_from_api(self, language_server: SolidLanguageServer) -> None: """Test symbol detection in api.md file.""" all_symbols, _root_symbols = language_server.request_document_symbols("api.md").get_all_symbols_and_roots() # Should detect headings from api.md assert len(all_symbols) > 0, f"Should find headings in api.md, found {len(all_symbols)}" @pytest.mark.parametrize("language_server", [Language.MARKDOWN], indirect=True) def test_markdown_request_document_symbols_with_body(self, language_server: SolidLanguageServer) -> None: """Test request_document_symbols with body extraction.""" all_symbols, _root_symbols = language_server.request_document_symbols("README.md").get_all_symbols_and_roots() # Should have found some symbols assert len(all_symbols) > 0, "Should find symbols in README.md" # Note: Not all markdown LSPs provide body information for symbols # This test is more lenient and just verifies the API works assert all_symbols is not None, "Should return symbols even if body extraction is limited" @pytest.mark.parametrize("language_server", [Language.MARKDOWN], indirect=True) def test_markdown_headings_not_low_level(self, language_server: SolidLanguageServer) -> None: """Test that markdown headings are not classified as low-level symbols. Verifies the fix for the issue where Marksman's SymbolKind.String (15) caused all headings to be filtered out of get_symbols_overview. """ all_symbols, _root_symbols = language_server.request_document_symbols("README.md").get_all_symbols_and_roots() assert len(all_symbols) > 0, "Should find headings in README.md" for symbol in all_symbols: ls_symbol = LanguageServerSymbol(symbol) assert ( not ls_symbol.is_low_level() ), f"Heading '{symbol['name']}' should not be low-level (kind={SymbolKind(symbol['kind']).name})" @pytest.mark.parametrize("language_server", [Language.MARKDOWN], indirect=True) def test_markdown_nested_headings_remapped(self, language_server: SolidLanguageServer) -> None: """Test that nested headings (h1-h5) are all remapped from String to Namespace.""" all_symbols, _root_symbols = language_server.request_document_symbols("api.md").get_all_symbols_and_roots() # api.md has deeply nested headings (h1 through h5) assert len(all_symbols) > 5, "api.md should have many headings" for symbol in all_symbols: assert symbol["kind"] == SymbolKind.Namespace, f"Nested heading '{symbol['name']}' should be remapped to Namespace" ================================================ FILE: test/solidlsp/matlab/__init__.py ================================================ # MATLAB language server tests ================================================ FILE: test/solidlsp/matlab/test_matlab_basic.py ================================================ """ Basic integration tests for the MATLAB language server functionality. These tests validate the functionality of the language server APIs like request_document_symbols using the MATLAB test repository. Requirements: - MATLAB R2021b or later must be installed - MATLAB_PATH environment variable should be set to MATLAB installation directory - Node.js and npm must be installed """ import os import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language # Skip all tests if MATLAB is not available pytestmark = pytest.mark.matlab # Check if MATLAB is available MATLAB_AVAILABLE = os.environ.get("MATLAB_PATH") is not None or any( os.path.exists(p) for p in [ "/Applications/MATLAB_R2024b.app", "/Applications/MATLAB_R2025b.app", "/Volumes/S1/Applications/MATLAB_R2024b.app", "/Volumes/S1/Applications/MATLAB_R2025b.app", ] ) @pytest.mark.skipif(not MATLAB_AVAILABLE, reason="MATLAB installation not found") class TestMatlabLanguageServerBasics: """Test basic functionality of the MATLAB language server.""" @pytest.mark.parametrize("language_server", [Language.MATLAB], indirect=True) def test_matlab_language_server_initialization(self, language_server: SolidLanguageServer) -> None: """Test that MATLAB language server can be initialized successfully.""" assert language_server is not None assert language_server.language == Language.MATLAB @pytest.mark.parametrize("language_server", [Language.MATLAB], indirect=True) def test_matlab_request_document_symbols_class(self, language_server: SolidLanguageServer) -> None: """Test request_document_symbols for MATLAB class file.""" # Test getting symbols from Calculator.m (class file) all_symbols, _root_symbols = language_server.request_document_symbols("Calculator.m").get_all_symbols_and_roots() # Extract class symbols (LSP Symbol Kind 5 for class) class_symbols = [symbol for symbol in all_symbols if symbol.get("kind") == 5] class_names = [symbol["name"] for symbol in class_symbols] # Should find the Calculator class assert "Calculator" in class_names, "Should find Calculator class" # Extract method symbols (LSP Symbol Kind 6 for method or 12 for function) method_symbols = [symbol for symbol in all_symbols if symbol.get("kind") in [6, 12]] method_names = [symbol["name"] for symbol in method_symbols] # Should find key methods expected_methods = ["add", "subtract", "multiply", "divide"] for method in expected_methods: assert method in method_names, f"Should find {method} method in Calculator class" @pytest.mark.parametrize("language_server", [Language.MATLAB], indirect=True) def test_matlab_request_document_symbols_function(self, language_server: SolidLanguageServer) -> None: """Test request_document_symbols for MATLAB function file.""" # Test getting symbols from lib/mathUtils.m (function file) all_symbols, _root_symbols = language_server.request_document_symbols("lib/mathUtils.m").get_all_symbols_and_roots() # Extract function symbols (LSP Symbol Kind 12 for function) function_symbols = [symbol for symbol in all_symbols if symbol.get("kind") == 12] function_names = [symbol["name"] for symbol in function_symbols] # Should find the main mathUtils function assert "mathUtils" in function_names, "Should find mathUtils function" # Should also find nested/local functions expected_local_functions = ["computeFactorial", "computeFibonacci", "checkPrime", "computeStats"] for func in expected_local_functions: assert func in function_names, f"Should find {func} local function" @pytest.mark.parametrize("language_server", [Language.MATLAB], indirect=True) def test_matlab_request_document_symbols_script(self, language_server: SolidLanguageServer) -> None: """Test request_document_symbols for MATLAB script file.""" # Test getting symbols from main.m (script file) all_symbols, _root_symbols = language_server.request_document_symbols("main.m").get_all_symbols_and_roots() # Scripts may have variables and sections, but less structured symbols # Just verify we can get symbols without errors assert all_symbols is not None @pytest.mark.skipif(not MATLAB_AVAILABLE, reason="MATLAB installation not found") class TestMatlabLanguageServerReferences: """Test find references functionality of the MATLAB language server.""" @pytest.mark.parametrize("language_server", [Language.MATLAB], indirect=True) def test_matlab_find_references_within_file(self, language_server: SolidLanguageServer) -> None: """Test finding references within a single MATLAB file.""" # Find references to 'result' variable in Calculator.m # This is a basic test to verify references work references = language_server.request_references("Calculator.m", 25, 12) # 'result' in add method # Should find at least the definition assert references is not None @pytest.mark.parametrize("language_server", [Language.MATLAB], indirect=True) def test_matlab_find_references_cross_file(self, language_server: SolidLanguageServer) -> None: """Test finding references across MATLAB files.""" # Find references to Calculator class used in main.m references = language_server.request_references("main.m", 11, 8) # 'Calculator' reference # Should find references in both main.m and Calculator.m assert references is not None ================================================ FILE: test/solidlsp/nix/test_nix_basic.py ================================================ """ Tests for the Nix language server implementation using nixd. These tests validate symbol finding and cross-file reference capabilities for Nix expressions. """ import platform import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from test.conftest import is_ci # Skip all Nix tests on Windows as Nix doesn't support Windows pytestmark = pytest.mark.skipif(platform.system() == "Windows", reason="Nix and nil are not available on Windows") @pytest.mark.nix class TestNixLanguageServer: """Test Nix language server symbol finding capabilities.""" @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) def test_find_symbols_in_default_nix(self, language_server: SolidLanguageServer) -> None: """Test finding specific symbols in default.nix.""" symbols = language_server.request_document_symbols("default.nix").get_all_symbols_and_roots() assert symbols is not None assert len(symbols) > 0 # Extract symbol names from the returned structure symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols symbol_names = {sym.get("name") for sym in symbol_list if isinstance(sym, dict)} # Verify specific function exists assert "makeGreeting" in symbol_names, "makeGreeting function not found" # Verify exact attribute sets are found expected_attrs = {"listUtils", "stringUtils"} found_attrs = symbol_names & expected_attrs assert found_attrs == expected_attrs, f"Expected exactly {expected_attrs}, found {found_attrs}" @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) def test_find_symbols_in_utils(self, language_server: SolidLanguageServer) -> None: """Test finding symbols in lib/utils.nix.""" symbols = language_server.request_document_symbols("lib/utils.nix").get_all_symbols_and_roots() assert symbols is not None assert len(symbols) > 0 symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols symbol_names = {sym.get("name") for sym in symbol_list if isinstance(sym, dict)} # Verify exact utility modules are found expected_modules = {"math", "strings", "lists", "attrs"} found_modules = symbol_names & expected_modules assert found_modules == expected_modules, f"Expected exactly {expected_modules}, found {found_modules}" @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) def test_find_symbols_in_flake(self, language_server: SolidLanguageServer) -> None: """Test finding symbols in flake.nix.""" symbols = language_server.request_document_symbols("flake.nix").get_all_symbols_and_roots() assert symbols is not None assert len(symbols) > 0 symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols symbol_names = {sym.get("name") for sym in symbol_list if isinstance(sym, dict)} # Flakes must have either inputs or outputs assert "inputs" in symbol_names or "outputs" in symbol_names, "Flake must have inputs or outputs" @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) def test_find_symbols_in_module(self, language_server: SolidLanguageServer) -> None: """Test finding symbols in a NixOS module.""" symbols = language_server.request_document_symbols("modules/example.nix").get_all_symbols_and_roots() assert symbols is not None assert len(symbols) > 0 symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols symbol_names = {sym.get("name") for sym in symbol_list if isinstance(sym, dict)} # NixOS modules must have either options or config assert "options" in symbol_names or "config" in symbol_names, "Module must have options or config" @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) def test_find_references_within_file(self, language_server: SolidLanguageServer) -> None: """Test finding references within the same file.""" symbols = language_server.request_document_symbols("default.nix").get_all_symbols_and_roots() assert symbols is not None symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols # Find makeGreeting function greeting_symbol = None for sym in symbol_list: if sym.get("name") == "makeGreeting": greeting_symbol = sym break assert greeting_symbol is not None, "makeGreeting function not found" assert "range" in greeting_symbol, "Symbol must have range information" range_start = greeting_symbol["range"]["start"] refs = language_server.request_references("default.nix", range_start["line"], range_start["character"]) assert refs is not None assert isinstance(refs, list) # nixd finds at least the inherit statement (line 67) assert len(refs) >= 1, f"Should find at least 1 reference to makeGreeting, found {len(refs)}" # Verify makeGreeting is referenced at expected locations if refs: ref_lines = sorted([ref["range"]["start"]["line"] for ref in refs]) # Check if we found the inherit (line 67, 0-indexed: 66) assert 66 in ref_lines, f"Should find makeGreeting inherit at line 67, found at lines {[l+1 for l in ref_lines]}" @pytest.mark.xfail(is_ci, reason="Test is flaky") # TODO: Re-enable if the hover test becomes more stable (#1040) @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) def test_hover_information(self, language_server: SolidLanguageServer) -> None: """Test hover information for symbols.""" # Get hover info for makeGreeting function hover_info = language_server.request_hover("default.nix", 12, 5) # Position at makeGreeting assert hover_info is not None, "Should provide hover information" if isinstance(hover_info, dict) and len(hover_info) > 0: # If hover info is provided, it should have proper structure assert "contents" in hover_info or "value" in hover_info, "Hover should have contents or value" @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) def test_cross_file_references_utils_import(self, language_server: SolidLanguageServer) -> None: """Test finding cross-file references for imported utils.""" # Find references to 'utils' which is imported in default.nix from lib/utils.nix # Line 10 in default.nix: utils = import ./lib/utils.nix { inherit lib; }; refs = language_server.request_references("default.nix", 9, 2) # Position of 'utils' assert refs is not None assert isinstance(refs, list) # Should find references within default.nix where utils is used default_refs = [ref for ref in refs if "default.nix" in ref.get("uri", "")] # utils is: imported (line 10), used in listUtils.unique (line 24), inherited in exports (line 69) assert len(default_refs) >= 2, f"Should find at least 2 references to utils in default.nix, found {len(default_refs)}" # Verify utils is referenced at expected locations (0-indexed) if default_refs: ref_lines = sorted([ref["range"]["start"]["line"] for ref in default_refs]) # Check for key references - at least the import (line 10) or usage (line 24) assert ( 9 in ref_lines or 23 in ref_lines ), f"Should find utils import or usage, found references at lines {[l+1 for l in ref_lines]}" @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) def test_verify_imports_exist(self, language_server: SolidLanguageServer) -> None: """Verify that our test files have proper imports set up.""" # Verify that default.nix imports utils from lib/utils.nix symbols = language_server.request_document_symbols("default.nix").get_all_symbols_and_roots() assert symbols is not None symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols # Check that makeGreeting exists (defined in default.nix) symbol_names = {sym.get("name") for sym in symbol_list if isinstance(sym, dict)} assert "makeGreeting" in symbol_names, "makeGreeting should be found in default.nix" # Verify lib/utils.nix has the expected structure utils_symbols = language_server.request_document_symbols("lib/utils.nix").get_all_symbols_and_roots() assert utils_symbols is not None utils_list = utils_symbols[0] if isinstance(utils_symbols, tuple) else utils_symbols utils_names = {sym.get("name") for sym in utils_list if isinstance(sym, dict)} # Verify key functions exist in utils assert "math" in utils_names, "math should be found in lib/utils.nix" assert "strings" in utils_names, "strings should be found in lib/utils.nix" @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) def test_go_to_definition_cross_file(self, language_server: SolidLanguageServer) -> None: """Test go-to-definition from default.nix to lib/utils.nix.""" # Line 24 in default.nix: unique = utils.lists.unique; # Test go-to-definition for 'utils' definitions = language_server.request_definition("default.nix", 23, 14) # Position of 'utils' assert definitions is not None assert isinstance(definitions, list) if len(definitions) > 0: # Should point to the import statement or utils.nix assert any( "utils" in def_item.get("uri", "") or "default.nix" in def_item.get("uri", "") for def_item in definitions ), "Definition should relate to utils import or utils.nix file" @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) def test_definition_navigation_in_flake(self, language_server: SolidLanguageServer) -> None: """Test definition navigation in flake.nix.""" # Test that we can navigate to definitions within flake.nix # Line 69: default = hello-custom; definitions = language_server.request_definition("flake.nix", 68, 20) # Position of 'hello-custom' assert definitions is not None assert isinstance(definitions, list) # nixd should find the definition of hello-custom in the same file if len(definitions) > 0: assert any( "flake.nix" in def_item.get("uri", "") for def_item in definitions ), "Should find hello-custom definition in flake.nix" @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) def test_full_symbol_tree(self, language_server: SolidLanguageServer) -> None: """Test that full symbol tree is not empty.""" symbols = language_server.request_full_symbol_tree() assert symbols is not None assert len(symbols) > 0, "Symbol tree should not be empty" # The tree should have at least one root node root = symbols[0] assert isinstance(root, dict), "Root should be a dict" assert "name" in root, "Root should have a name" ================================================ FILE: test/solidlsp/ocaml/test_cross_file_refs.py ================================================ """ Test cross-file references for OCaml. Cross-file references require OCaml >= 5.2 and ocaml-lsp-server >= 1.23.0. On environments without these (e.g. Windows CI with OCaml 4.14), only same-file references are asserted. """ import logging import os import pytest from solidlsp import SolidLanguageServer from solidlsp.language_servers.ocaml_lsp_server import OcamlLanguageServer from solidlsp.ls_config import Language log = logging.getLogger(__name__) @pytest.mark.ocaml class TestCrossFileReferences: @pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True) def test_fib_has_cross_file_references(self, language_server: SolidLanguageServer) -> None: """Test that fib function references are found across multiple files. The `fib` function is defined in lib/test_repo.ml and used in: - lib/test_repo.ml (definition + 2 recursive calls) - bin/main.ml (1 call) - test/test_test_repo.ml (5 references) Total: 9 references across 3 files. """ file_path = os.path.join("lib", "test_repo.ml") fib_line = 7 fib_char = 8 refs = language_server.request_references(file_path, fib_line, fib_char) lib_refs = [ref for ref in refs if "lib/test_repo.ml" in ref.get("uri", "")] bin_refs = [ref for ref in refs if "bin/main.ml" in ref.get("uri", "")] test_refs = [ref for ref in refs if "test/test_test_repo.ml" in ref.get("uri", "")] log.info("Cross-file references result:") log.info(f"Total references found: {len(refs)}") log.info(f" lib/test_repo.ml: {len(lib_refs)}") log.info(f" bin/main.ml: {len(bin_refs)}") log.info(f" test/test_test_repo.ml: {len(test_refs)}") for ref in refs: uri = ref.get("uri", "") filename = uri.split("/")[-1] line = ref.get("range", {}).get("start", {}).get("line", -1) log.info(f" {filename}:{line}") # Same-file references always work assert len(lib_refs) >= 3, f"Expected at least 3 references in lib/test_repo.ml (definition + 2 recursive), but got {len(lib_refs)}" # Cross-file references require OCaml >= 5.2 and ocaml-lsp-server >= 1.23.0 if isinstance(language_server, OcamlLanguageServer) and language_server.supports_cross_file_references: assert len(refs) >= 9, ( f"Expected at least 9 total references (3 in lib + 1 in bin + 5 in test), " f"but got {len(refs)}. Cross-file references are NOT working!" ) assert len(bin_refs) >= 1, ( f"Expected at least 1 reference in bin/main.ml, but got {len(bin_refs)}. " "Cross-file references are NOT working - bin/main.ml not found!" ) assert len(test_refs) >= 1, ( f"Expected at least 1 reference in test/test_test_repo.ml, but got {len(test_refs)}. " "Cross-file references are NOT working - test file not found!" ) ================================================ FILE: test/solidlsp/ocaml/test_ocaml_basic.py ================================================ import os import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils @pytest.mark.ocaml class TestOCamlLanguageServer: @pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True) def test_find_symbol(self, language_server: SolidLanguageServer) -> None: symbols = language_server.request_full_symbol_tree() assert SymbolUtils.symbol_tree_contains_name(symbols, "DemoModule"), "DemoModule not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "fib"), "fib not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "someFunction"), "someFunction function not found in symbol tree" @pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True) def test_find_referencing_symbols(self, language_server: SolidLanguageServer) -> None: file_path = os.path.join("lib", "test_repo.ml") # Use the correct character position for 'fib' function name # Line 8: "let rec fib n =" - 'fib' starts at character 8 (0-indexed) fib_line = 7 # 0-indexed line number fib_char = 8 # 0-indexed character position refs = language_server.request_references(file_path, fib_line, fib_char) # Should find at least 3 references: definition + 2 recursive calls in same file assert len(refs) >= 3, f"Expected at least 3 references to fib (definition + 2 recursive), found {len(refs)}" # All references should be in lib/test_repo.ml (same file as definition) # Use forward slashes for URI matching (URIs always use /) lib_refs = [ref for ref in refs if "lib/test_repo.ml" in ref.get("uri", "")] assert len(lib_refs) >= 3, f"Expected at least 3 references in lib/test_repo.ml, found {len(lib_refs)}" @pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True) def test_mixed_ocaml_modules(self, language_server: SolidLanguageServer) -> None: """Test that the language server can find symbols from OCaml modules""" # Test that full symbol tree includes symbols from various file types all_symbols = language_server.request_full_symbol_tree() # Should find symbols from main OCaml files assert SymbolUtils.symbol_tree_contains_name(all_symbols, "fib"), "Should find fib from .ml file" assert SymbolUtils.symbol_tree_contains_name(all_symbols, "DemoModule"), "Should find DemoModule from .ml file" assert SymbolUtils.symbol_tree_contains_name(all_symbols, "someFunction"), "Should find someFunction from DemoModule" assert SymbolUtils.symbol_tree_contains_name(all_symbols, "num_domains"), "Should find num_domains constant" def test_reason_file_patterns(self) -> None: """Test that OCaml language configuration recognizes Reason file extensions""" from solidlsp.ls_config import Language ocaml_lang = Language.OCAML file_matcher = ocaml_lang.get_source_fn_matcher() # Test OCaml extensions assert file_matcher.is_relevant_filename("test.ml"), "Should match .ml files" assert file_matcher.is_relevant_filename("test.mli"), "Should match .mli files" # Test Reason extensions assert file_matcher.is_relevant_filename("test.re"), "Should match .re files" assert file_matcher.is_relevant_filename("test.rei"), "Should match .rei files" # Test non-matching extensions assert not file_matcher.is_relevant_filename("test.py"), "Should not match .py files" assert not file_matcher.is_relevant_filename("test.js"), "Should not match .js files" @pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True) def test_module_hierarchy_navigation(self, language_server: SolidLanguageServer) -> None: """Test navigation within module hierarchy including DemoModule.""" file_path = os.path.join("lib", "test_repo.ml") # Use correct position for 'DemoModule' (line 1, char 7) # Line 1: "module DemoModule = struct" - 'DemoModule' starts around char 7 module_line = 0 # 0-indexed module_char = 7 # 0-indexed refs = language_server.request_references(file_path, module_line, module_char) # Should find at least 1 reference (the definition) assert len(refs) >= 1, f"Expected at least 1 reference to DemoModule, found {len(refs)}" # Check that references are found - use forward slashes for URI matching lib_refs = [ref for ref in refs if "lib/test_repo.ml" in ref.get("uri", "")] assert len(lib_refs) >= 1, f"Expected at least 1 reference in lib/test_repo.ml, found {len(lib_refs)}" @pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True) def test_let_binding_references(self, language_server: SolidLanguageServer) -> None: """Test finding references to let-bound values across files.""" file_path = os.path.join("lib", "test_repo.ml") # Use correct position for 'num_domains' (line 12, char 4) # Line 12: "let num_domains = 2" - 'num_domains' starts around char 4 num_domains_line = 11 # 0-indexed num_domains_char = 4 # 0-indexed refs = language_server.request_references(file_path, num_domains_line, num_domains_char) # Should find at least 1 reference (the definition) assert len(refs) >= 1, f"Expected at least 1 reference to num_domains, found {len(refs)}" # Check that reference is found in the definition file - use forward slashes ml_refs = [ref for ref in refs if "lib/test_repo.ml" in ref.get("uri", "")] assert len(ml_refs) >= 1, f"Expected at least 1 reference in lib/test_repo.ml, found {len(ml_refs)}" @pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True) def test_recursive_function_analysis(self, language_server: SolidLanguageServer) -> None: """Test that recursive function calls are properly identified within the definition file.""" file_path = os.path.join("lib", "test_repo.ml") # Use correct position for 'fib' function name (line 8, char 8) fib_line = 7 # 0-indexed fib_char = 8 # 0-indexed refs = language_server.request_references(file_path, fib_line, fib_char) # Filter to references within the definition file only - use forward slashes same_file_refs = [ref for ref in refs if "lib/test_repo.ml" in ref.get("uri", "")] # Should find at least 3 references in test_repo.ml: definition + 2 recursive calls # On OCaml 5.2+ with cross-file refs, there may be more total refs but same-file count stays the same assert ( len(same_file_refs) >= 3 ), f"Expected at least 3 references in test_repo.ml (definition + 2 recursive), found {len(same_file_refs)}" # Verify references are on different lines (definition + recursive calls) ref_lines = [ref.get("range", {}).get("start", {}).get("line", -1) for ref in same_file_refs] unique_lines = len(set(ref_lines)) assert unique_lines >= 2, f"Recursive calls should appear on multiple lines, found {unique_lines} unique lines" @pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True) def test_open_statement_resolution(self, language_server: SolidLanguageServer) -> None: """Test that open statements allow unqualified access to module contents.""" # In bin/main.ml, fib is called without Test_repo prefix due to 'open Test_repo' all_symbols = language_server.request_full_symbol_tree() # Should be able to find fib through symbol tree fib_accessible = SymbolUtils.symbol_tree_contains_name(all_symbols, "fib") assert fib_accessible, "fib should be accessible through open statement" # DemoModule should also be accessible demo_module_accessible = SymbolUtils.symbol_tree_contains_name(all_symbols, "DemoModule") assert demo_module_accessible, "DemoModule should be accessible" # Verify we have access to both qualified and unqualified symbols assert len(all_symbols) > 0, "Should find symbols from OCaml files" # Test that the language server recognizes the open statement context file_path = os.path.join("bin", "main.ml") symbols, _roots = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() assert len(symbols) > 0, "Should find symbols in main.ml that use opened modules" ================================================ FILE: test/solidlsp/pascal/__init__.py ================================================ def _check_pascal_available() -> bool: """Check if Pascal language server (pasls) is available. Note: pasls will be auto-downloaded if not present, so Pascal support is always available. """ return True PASCAL_AVAILABLE = _check_pascal_available() def is_pascal_available() -> bool: """Return True if Pascal language server can be used.""" return PASCAL_AVAILABLE ================================================ FILE: test/solidlsp/pascal/test_pascal_auto_update.py ================================================ """ Unit tests for the Pascal language server auto-update functionality. These tests validate the version comparison, checksum verification, and other helper methods without requiring network access or the actual Pascal language server. """ from __future__ import annotations import hashlib import os import tarfile import tempfile import time import pytest from solidlsp.language_servers.pascal_server import PascalLanguageServer pytestmark = [pytest.mark.pascal] class TestVersionNormalization: """Test version string normalization.""" def test_normalize_version_with_v_prefix(self) -> None: """Test that 'v' prefix is stripped.""" assert PascalLanguageServer._normalize_version("v1.0.0") == "1.0.0" def test_normalize_version_with_capital_v_prefix(self) -> None: """Test that 'V' prefix is stripped.""" assert PascalLanguageServer._normalize_version("V1.0.0") == "1.0.0" def test_normalize_version_without_prefix(self) -> None: """Test version without prefix is unchanged.""" assert PascalLanguageServer._normalize_version("1.0.0") == "1.0.0" def test_normalize_version_with_whitespace(self) -> None: """Test that whitespace is stripped.""" assert PascalLanguageServer._normalize_version(" v1.0.0 ") == "1.0.0" def test_normalize_version_empty(self) -> None: """Test empty version returns empty string.""" assert PascalLanguageServer._normalize_version("") == "" def test_normalize_version_none(self) -> None: """Test None returns empty string.""" assert PascalLanguageServer._normalize_version(None) == "" class TestVersionComparison: """Test version comparison logic.""" def test_newer_version_major(self) -> None: """Test detection of newer major version.""" assert PascalLanguageServer._is_newer_version("v2.0.0", "v1.0.0") is True def test_newer_version_minor(self) -> None: """Test detection of newer minor version.""" assert PascalLanguageServer._is_newer_version("v1.1.0", "v1.0.0") is True def test_newer_version_patch(self) -> None: """Test detection of newer patch version.""" assert PascalLanguageServer._is_newer_version("v1.0.1", "v1.0.0") is True def test_same_version(self) -> None: """Test same version returns False.""" assert PascalLanguageServer._is_newer_version("v1.0.0", "v1.0.0") is False def test_older_version(self) -> None: """Test older version returns False.""" assert PascalLanguageServer._is_newer_version("v1.0.0", "v2.0.0") is False def test_latest_none_returns_false(self) -> None: """Test None latest version returns False.""" assert PascalLanguageServer._is_newer_version(None, "v1.0.0") is False def test_local_none_returns_true(self) -> None: """Test None local version returns True (first install).""" assert PascalLanguageServer._is_newer_version("v1.0.0", None) is True def test_both_none_returns_false(self) -> None: """Test both None returns False.""" assert PascalLanguageServer._is_newer_version(None, None) is False def test_version_with_different_lengths(self) -> None: """Test versions with different number of parts.""" assert PascalLanguageServer._is_newer_version("v1.0.1", "v1.0") is True assert PascalLanguageServer._is_newer_version("v1.0", "v1.0.1") is False def test_version_with_prerelease(self) -> None: """Test versions with prerelease suffixes.""" # Prerelease suffix is ignored, only numeric parts are compared assert PascalLanguageServer._is_newer_version("v1.1.0-beta", "v1.0.0") is True class TestSHA256Checksum: """Test SHA256 checksum calculation and verification.""" def test_calculate_sha256(self) -> None: """Test SHA256 calculation for a known content.""" with tempfile.NamedTemporaryFile(mode="wb", delete=False) as f: f.write(b"test content") temp_path = f.name try: result = PascalLanguageServer._calculate_sha256(temp_path) expected = hashlib.sha256(b"test content").hexdigest() assert result == expected finally: os.unlink(temp_path) def test_verify_checksum_correct(self) -> None: """Test checksum verification with correct checksum.""" with tempfile.NamedTemporaryFile(mode="wb", delete=False) as f: f.write(b"test content") temp_path = f.name try: expected = hashlib.sha256(b"test content").hexdigest() assert PascalLanguageServer._verify_checksum(temp_path, expected) is True finally: os.unlink(temp_path) def test_verify_checksum_incorrect(self) -> None: """Test checksum verification with incorrect checksum.""" with tempfile.NamedTemporaryFile(mode="wb", delete=False) as f: f.write(b"test content") temp_path = f.name try: wrong_checksum = "0" * 64 assert PascalLanguageServer._verify_checksum(temp_path, wrong_checksum) is False finally: os.unlink(temp_path) def test_verify_checksum_case_insensitive(self) -> None: """Test checksum verification is case insensitive.""" with tempfile.NamedTemporaryFile(mode="wb", delete=False) as f: f.write(b"test content") temp_path = f.name try: expected = hashlib.sha256(b"test content").hexdigest().upper() assert PascalLanguageServer._verify_checksum(temp_path, expected) is True finally: os.unlink(temp_path) class TestTarfileSafety: """Test tarfile path traversal protection.""" def test_safe_tar_member_normal_path(self) -> None: """Test normal path is considered safe.""" member = tarfile.TarInfo(name="pasls") assert PascalLanguageServer._is_safe_tar_member(member, "/tmp/target") is True def test_safe_tar_member_nested_path(self) -> None: """Test nested path is considered safe.""" member = tarfile.TarInfo(name="subdir/pasls") assert PascalLanguageServer._is_safe_tar_member(member, "/tmp/target") is True def test_unsafe_tar_member_path_traversal(self) -> None: """Test path traversal is detected.""" member = tarfile.TarInfo(name="../etc/passwd") assert PascalLanguageServer._is_safe_tar_member(member, "/tmp/target") is False def test_unsafe_tar_member_hidden_traversal(self) -> None: """Test hidden path traversal in nested path.""" member = tarfile.TarInfo(name="subdir/../../etc/passwd") assert PascalLanguageServer._is_safe_tar_member(member, "/tmp/target") is False def test_safe_tar_member_similar_name(self) -> None: """Test path containing '..' in filename (not directory) is safe.""" member = tarfile.TarInfo(name="file..name") assert PascalLanguageServer._is_safe_tar_member(member, "/tmp/target") is True class TestMetadataManagement: """Test metadata directory and file management.""" def test_meta_dir_creates_directory(self) -> None: """Test _meta_dir creates directory if not exists.""" with tempfile.TemporaryDirectory() as temp_dir: meta_path = PascalLanguageServer._meta_dir(temp_dir) assert os.path.exists(meta_path) assert meta_path == os.path.join(temp_dir, PascalLanguageServer.META_DIR) def test_meta_file_returns_correct_path(self) -> None: """Test _meta_file returns correct path.""" with tempfile.TemporaryDirectory() as temp_dir: meta_file = PascalLanguageServer._meta_file(temp_dir, "version") expected = os.path.join(temp_dir, PascalLanguageServer.META_DIR, "version") assert meta_file == expected class TestUpdateCheckTiming: """Test update check timing logic.""" def test_should_check_update_no_last_check(self) -> None: """Test should check when no last_check file exists.""" with tempfile.TemporaryDirectory() as temp_dir: assert PascalLanguageServer._should_check_update(temp_dir) is True def test_should_check_update_recent_check(self) -> None: """Test should not check when recently checked.""" with tempfile.TemporaryDirectory() as temp_dir: # Create meta dir and last_check file with current time meta_dir = PascalLanguageServer._meta_dir(temp_dir) last_check_file = os.path.join(meta_dir, "last_check") with open(last_check_file, "w") as f: f.write(str(time.time())) assert PascalLanguageServer._should_check_update(temp_dir) is False def test_should_check_update_old_check(self) -> None: """Test should check when last check was > 24 hours ago.""" with tempfile.TemporaryDirectory() as temp_dir: # Create meta dir and last_check file with old time meta_dir = PascalLanguageServer._meta_dir(temp_dir) last_check_file = os.path.join(meta_dir, "last_check") old_time = time.time() - (PascalLanguageServer.UPDATE_CHECK_INTERVAL + 3600) with open(last_check_file, "w") as f: f.write(str(old_time)) assert PascalLanguageServer._should_check_update(temp_dir) is True def test_update_last_check_creates_file(self) -> None: """Test _update_last_check creates timestamp file.""" with tempfile.TemporaryDirectory() as temp_dir: PascalLanguageServer._update_last_check(temp_dir) last_check_file = PascalLanguageServer._meta_file(temp_dir, "last_check") assert os.path.exists(last_check_file) with open(last_check_file) as f: timestamp = float(f.read().strip()) assert abs(timestamp - time.time()) < 5 # within 5 seconds class TestVersionPersistence: """Test local version persistence.""" def test_save_and_get_local_version(self) -> None: """Test saving and retrieving local version.""" with tempfile.TemporaryDirectory() as temp_dir: PascalLanguageServer._save_local_version(temp_dir, "v1.0.0") version = PascalLanguageServer._get_local_version(temp_dir) assert version == "v1.0.0" def test_get_local_version_not_exists(self) -> None: """Test getting version when file doesn't exist.""" with tempfile.TemporaryDirectory() as temp_dir: version = PascalLanguageServer._get_local_version(temp_dir) assert version is None ================================================ FILE: test/solidlsp/pascal/test_pascal_basic.py ================================================ """ Basic integration tests for the Pascal language server functionality. These tests validate the functionality of the language server APIs like request_document_symbols using the Pascal test repository. Uses genericptr/pascal-language-server which returns SymbolInformation[] format: - Returns classes, structs, enums, typedefs, functions/procedures - Uses correct SymbolKind values: Class=5, Function=12, Method=6, Struct=23 - Method names don't include parent class prefix; uses containerName instead """ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind from test.conftest import language_tests_enabled pytestmark = [ pytest.mark.pascal, pytest.mark.skipif(not language_tests_enabled(Language.PASCAL), reason="Pascal tests are disabled (pasls/fpc not available)"), ] @pytest.mark.pascal class TestPascalLanguageServerBasics: """Test basic functionality of the Pascal language server.""" @pytest.mark.parametrize("language_server", [Language.PASCAL], indirect=True) def test_pascal_language_server_initialization(self, language_server: SolidLanguageServer) -> None: """Test that Pascal language server can be initialized successfully.""" assert language_server is not None assert language_server.language == Language.PASCAL @pytest.mark.parametrize("language_server", [Language.PASCAL], indirect=True) def test_pascal_request_document_symbols(self, language_server: SolidLanguageServer) -> None: """Test request_document_symbols for Pascal files. genericptr pasls returns proper SymbolKind values: - Standalone functions: kind=12 (Function) - Classes: kind=5 (Class) """ # Test getting symbols from main.pas all_symbols, _root_symbols = language_server.request_document_symbols("main.pas").get_all_symbols_and_roots() # Should have symbols assert len(all_symbols) > 0, "Should have symbols in main.pas" # Should detect standalone functions (SymbolKind.Function = 12) function_symbols = [s for s in all_symbols if s.get("kind") == SymbolKind.Function] function_names = [s["name"] for s in function_symbols] assert "CalculateSum" in function_names, "Should find CalculateSum function" assert "PrintMessage" in function_names, "Should find PrintMessage procedure" # Should detect classes (SymbolKind.Class = 5) class_symbols = [s for s in all_symbols if s.get("kind") == SymbolKind.Class] class_names = [s["name"] for s in class_symbols] assert "TUser" in class_names, "Should find TUser class" assert "TUserManager" in class_names, "Should find TUserManager class" @pytest.mark.parametrize("language_server", [Language.PASCAL], indirect=True) def test_pascal_class_methods(self, language_server: SolidLanguageServer) -> None: """Test detection of class methods in Pascal files. pasls returns class methods with SymbolKind.Method (kind 6), not Function (kind 12). """ all_symbols, _root_symbols = language_server.request_document_symbols("main.pas").get_all_symbols_and_roots() # Get all method symbols (pasls returns class methods as SymbolKind.Method = 6) method_symbols = [s for s in all_symbols if s.get("kind") == SymbolKind.Method] method_names = [s["name"] for s in method_symbols] # Should detect TUser methods expected_tuser_methods = ["Create", "Destroy", "GetInfo", "UpdateAge"] for method in expected_tuser_methods: found = method in method_names assert found, f"Should find method '{method}'" # Should detect TUserManager methods expected_manager_methods = ["Create", "Destroy", "AddUser", "GetUserCount", "FindUserByName"] for method in expected_manager_methods: found = method in method_names assert found, f"Should find method '{method}'" @pytest.mark.parametrize("language_server", [Language.PASCAL], indirect=True) def test_pascal_helper_unit_symbols(self, language_server: SolidLanguageServer) -> None: """Test function detection in Helper unit.""" # Test with lib/helper.pas helper_all_symbols, _helper_root_symbols = language_server.request_document_symbols("lib/helper.pas").get_all_symbols_and_roots() # Should have symbols assert len(helper_all_symbols) > 0, "Helper unit should have symbols" # Extract function symbols function_symbols = [s for s in helper_all_symbols if s.get("kind") == SymbolKind.Function] function_names = [s["name"] for s in function_symbols] # Should detect standalone functions expected_functions = ["GetHelperMessage", "MultiplyNumbers", "LogMessage"] for func_name in expected_functions: assert func_name in function_names, f"Should find {func_name} function in Helper unit" # Should also detect THelper class methods (returned as SymbolKind.Method = 6) method_symbols = [s for s in helper_all_symbols if s.get("kind") == SymbolKind.Method] method_names = [s["name"] for s in method_symbols] assert "FormatString" in method_names, "Should find FormatString method" assert "IsEven" in method_names, "Should find IsEven method" @pytest.mark.parametrize("language_server", [Language.PASCAL], indirect=True) def test_pascal_cross_file_references(self, language_server: SolidLanguageServer) -> None: """Test that Pascal LSP can handle cross-file references.""" # main.pas uses Helper unit main_symbols, _main_roots = language_server.request_document_symbols("main.pas").get_all_symbols_and_roots() helper_symbols, _helper_roots = language_server.request_document_symbols("lib/helper.pas").get_all_symbols_and_roots() # Verify both files have symbols assert len(main_symbols) > 0, "main.pas should have symbols" assert len(helper_symbols) > 0, "helper.pas should have symbols" # Verify GetHelperMessage is in Helper unit helper_function_names = [s["name"] for s in helper_symbols if s.get("kind") == SymbolKind.Function] assert "GetHelperMessage" in helper_function_names, "Helper unit should export GetHelperMessage" @pytest.mark.parametrize("language_server", [Language.PASCAL], indirect=True) def test_pascal_symbol_locations(self, language_server: SolidLanguageServer) -> None: """Test that symbols have correct location information. Note: genericptr pasls returns the interface declaration location (line ~41), not the implementation location (line ~115). """ all_symbols, _root_symbols = language_server.request_document_symbols("main.pas").get_all_symbols_and_roots() # Find CalculateSum function calc_symbols = [s for s in all_symbols if s.get("name") == "CalculateSum"] assert len(calc_symbols) > 0, "Should find CalculateSum" calc_symbol = calc_symbols[0] # Verify it has location information (SymbolInformation format uses location.range) if "location" in calc_symbol: location = calc_symbol["location"] assert "range" in location, "Location should have range" assert "start" in location["range"], "Range should have start" assert "line" in location["range"]["start"], "Start should have line" line = location["range"]["start"]["line"] else: # DocumentSymbol format uses range directly assert "range" in calc_symbol, "Symbol should have range" assert "start" in calc_symbol["range"], "Range should have start" line = calc_symbol["range"]["start"]["line"] # CalculateSum is declared at line 41 in main.pas (0-indexed would be 40) # genericptr pasls returns interface declaration location assert 35 <= line <= 45, f"CalculateSum should be around line 41 (interface), got {line}" @pytest.mark.parametrize("language_server", [Language.PASCAL], indirect=True) def test_pascal_namespace_symbol(self, language_server: SolidLanguageServer) -> None: """Test that genericptr pasls returns Interface namespace symbol.""" all_symbols, _root_symbols = language_server.request_document_symbols("main.pas").get_all_symbols_and_roots() # genericptr pasls adds an "Interface" namespace symbol symbol_names = [s["name"] for s in all_symbols] # The Interface section should be represented # Note: This depends on pasls configuration assert len(all_symbols) > 0, "Should have symbols" # Interface namespace may or may not be present depending on pasls configuration _ = symbol_names # used for potential future assertions @pytest.mark.parametrize("language_server", [Language.PASCAL], indirect=True) def test_pascal_hover_with_doc_comments(self, language_server: SolidLanguageServer) -> None: """Test that hover returns documentation comments. CalculateSum has /// style doc comments that should appear in hover. """ # CalculateSum is declared at line 46 (1-indexed), so line 45 (0-indexed) hover = language_server.request_hover("main.pas", 45, 12) assert hover is not None, "Hover should return a result" # Extract hover content - handle both dict and object formats if isinstance(hover, dict): contents = hover.get("contents", {}) value = contents.get("value", "") if isinstance(contents, dict) else str(contents) else: value = hover.contents.value if hasattr(hover.contents, "value") else str(hover.contents) # Should contain the function signature assert "CalculateSum" in value, f"Hover should show function name. Got: {value[:500]}" # Should contain the doc comment assert "Calculates the sum" in value, f"Hover should include doc comment. Got: {value[:500]}" ================================================ FILE: test/solidlsp/perl/test_perl_basic.py ================================================ import platform from pathlib import Path import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language @pytest.mark.perl @pytest.mark.skipif(platform.system() == "Windows", reason="Perl::LanguageServer does not support native Windows operation") class TestPerlLanguageServer: """ Tests for Perl::LanguageServer integration. Perl::LanguageServer provides comprehensive LSP support for Perl including: - Document symbols (functions, variables) - Go to definition (including cross-file) - Find references (including cross-file) - this was not available in PLS """ @pytest.mark.parametrize("language_server", [Language.PERL], indirect=True) @pytest.mark.parametrize("repo_path", [Language.PERL], indirect=True) def test_ls_is_running(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test that the language server starts and stops successfully.""" # The fixture already handles start and stop assert language_server.is_running() assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve() @pytest.mark.parametrize("language_server", [Language.PERL], indirect=True) def test_document_symbols(self, language_server: SolidLanguageServer) -> None: """Test that document symbols are correctly identified.""" # Request document symbols all_symbols, _ = language_server.request_document_symbols("main.pl").get_all_symbols_and_roots() assert all_symbols, "Expected to find symbols in main.pl" assert len(all_symbols) > 0, "Expected at least one symbol" # DEBUG: Print all symbols print("\n=== All symbols in main.pl ===") for s in all_symbols: line = s.get("range", {}).get("start", {}).get("line", "?") print(f"Line {line}: {s.get('name')} (kind={s.get('kind')})") # Check that we can find function symbols function_symbols = [s for s in all_symbols if s.get("kind") == 12] # 12 = Function/Method assert len(function_symbols) >= 2, f"Expected at least 2 functions (greet, use_helper_function), found {len(function_symbols)}" function_names = [s.get("name") for s in function_symbols] assert "greet" in function_names, f"Expected 'greet' function in symbols, found: {function_names}" assert "use_helper_function" in function_names, f"Expected 'use_helper_function' in symbols, found: {function_names}" # @pytest.mark.skip(reason="Perl::LanguageServer cross-file definition tracking needs configuration") @pytest.mark.parametrize("language_server", [Language.PERL], indirect=True) def test_find_definition_across_files(self, language_server: SolidLanguageServer) -> None: definition_location_list = language_server.request_definition("main.pl", 17, 0) assert len(definition_location_list) == 1 definition_location = definition_location_list[0] print(f"Found definition: {definition_location}") assert definition_location["uri"].endswith("helper.pl") assert definition_location["range"]["start"]["line"] == 4 # add method on line 2 (0-indexed 1) @pytest.mark.parametrize("language_server", [Language.PERL], indirect=True) def test_find_references_across_files(self, language_server: SolidLanguageServer) -> None: """Test finding references to a function across multiple files.""" reference_locations = language_server.request_references("helper.pl", 4, 5) assert len(reference_locations) >= 2, f"Expected at least 2 references to helper_function, found {len(reference_locations)}" main_pl_refs = [ref for ref in reference_locations if ref["uri"].endswith("main.pl")] assert len(main_pl_refs) >= 2, f"Expected at least 2 references in main.pl, found {len(main_pl_refs)}" main_pl_lines = sorted([ref["range"]["start"]["line"] for ref in main_pl_refs]) assert 17 in main_pl_lines, f"Expected reference at line 18 (0-indexed 17), found: {main_pl_lines}" assert 20 in main_pl_lines, f"Expected reference at line 21 (0-indexed 20), found: {main_pl_lines}" ================================================ FILE: test/solidlsp/php/test_php_basic.py ================================================ from pathlib import Path import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from test.conftest import is_ci, is_windows, language_tests_enabled _php_servers: list[Language] = [Language.PHP] if language_tests_enabled(Language.PHP_PHPACTOR): if not (is_windows and is_ci): # TODO: Phpactor tests are flaky in Windows CI and can even cause hangs #1040 _php_servers.append(Language.PHP_PHPACTOR) @pytest.mark.php class TestPhpLanguageServers: @pytest.mark.parametrize("language_server", _php_servers, indirect=True) @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) def test_ls_is_running(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test that the language server starts and stops successfully.""" assert language_server.is_running() assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve() @pytest.mark.parametrize("language_server", _php_servers, indirect=True) @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) def test_find_definition_within_file(self, language_server: SolidLanguageServer, repo_path: Path) -> None: # In index.php: # Line 9 (1-indexed): $greeting = greet($userName); # Line 11 (1-indexed): echo $greeting; # We want to find the definition of $greeting (defined on line 9) # from its usage in echo $greeting; on line 11. # LSP is 0-indexed: definition on line 8, usage on line 10. # $greeting in echo $greeting; (e c h o $ g r e e t i n g) # ^ char 5 # Intelephense uses line 10 (0-indexed), Phpactor uses line 11 (0-indexed) if language_server.language_server.language == Language.PHP_PHPACTOR: definition_location_list = language_server.request_definition(str(repo_path / "index.php"), 11, 6) else: definition_location_list = language_server.request_definition(str(repo_path / "index.php"), 10, 6) assert definition_location_list, f"Expected non-empty definition_location_list but got {definition_location_list=}" if language_server.language_server.language == Language.PHP_PHPACTOR: assert len(definition_location_list) >= 1 else: assert len(definition_location_list) == 1 definition_location = definition_location_list[0] assert definition_location["uri"].endswith("index.php") # Definition of $greeting is on line 10 (1-indexed) / line 9 (0-indexed), char 0 assert definition_location["range"]["start"]["line"] == 9 if language_server.language_server.language != Language.PHP_PHPACTOR: assert definition_location["range"]["start"]["character"] == 0 @pytest.mark.parametrize("language_server", _php_servers, indirect=True) @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) def test_find_definition_across_files(self, language_server: SolidLanguageServer, repo_path: Path) -> None: # Intelephense uses line 12 (0-indexed), Phpactor uses line 13 (0-indexed) if language_server.language_server.language == Language.PHP_PHPACTOR: definition_location_list = language_server.request_definition(str(repo_path / "index.php"), 13, 5) else: definition_location_list = language_server.request_definition(str(repo_path / "index.php"), 12, 5) assert definition_location_list, f"Expected non-empty definition_location_list but got {definition_location_list=}" if language_server.language_server.language == Language.PHP_PHPACTOR: assert len(definition_location_list) >= 1 else: assert len(definition_location_list) == 1 definition_location = definition_location_list[0] assert definition_location["uri"].endswith("helper.php") assert definition_location["range"]["start"]["line"] == 2 if language_server.language_server.language != Language.PHP_PHPACTOR: assert definition_location["range"]["start"]["character"] == 0 @pytest.mark.parametrize("language_server", _php_servers, indirect=True) @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) def test_find_definition_simple_variable(self, language_server: SolidLanguageServer, repo_path: Path) -> None: file_path = str(repo_path / "simple_var.php") # In simple_var.php: # Line 2 (1-indexed): $localVar = "test"; # Line 3 (1-indexed): echo $localVar; # LSP is 0-indexed: definition on line 1, usage on line 2 # Find definition of $localVar (char 5 on line 3 / 0-indexed: line 2, char 5) # $localVar in echo $localVar; (e c h o $ l o c a l V a r) # ^ char 5 definition_location_list = language_server.request_definition(file_path, 2, 6) # cursor on 'l' in $localVar assert definition_location_list, f"Expected non-empty definition_location_list but got {definition_location_list=}" if language_server.language_server.language == Language.PHP_PHPACTOR: assert len(definition_location_list) >= 1 else: assert len(definition_location_list) == 1 definition_location = definition_location_list[0] assert definition_location["uri"].endswith("simple_var.php") assert definition_location["range"]["start"]["line"] == 1 # Definition of $localVar (0-indexed) if language_server.language_server.language != Language.PHP_PHPACTOR: assert definition_location["range"]["start"]["character"] == 0 # $localVar (0-indexed) @pytest.mark.parametrize("language_server", _php_servers, indirect=True) @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) def test_find_references_within_file(self, language_server: SolidLanguageServer, repo_path: Path) -> None: index_php_path = str(repo_path / "index.php") # In index.php (0-indexed lines): # Line 9: $greeting = greet($userName); // Definition of $greeting # Line 11: echo $greeting; // Usage of $greeting # Find references for $greeting from its usage in "echo $greeting;" (line 11, char 6 for 'g') references = language_server.request_references(index_php_path, 11, 6) assert references, f"Expected non-empty references for $greeting but got {references=}" if language_server.language_server.language == Language.PHP_PHPACTOR: actual_locations = [ { "uri_suffix": loc["uri"].split("/")[-1], "line": loc["range"]["start"]["line"], } for loc in references ] # Check that at least one reference points to $greeting usage in index.php matching = [loc for loc in actual_locations if loc["uri_suffix"] == "index.php" and loc["line"] == 11] assert matching, f"Expected reference to $greeting on line 11 of index.php, got {actual_locations}" else: # Intelephense, when asked for references from usage, seems to only return the usage itself. assert len(references) == 1, "Expected to find 1 reference for $greeting (the usage itself)" expected_locations = [{"uri_suffix": "index.php", "line": 11, "character": 5}] # Usage: echo $greeting (points to $) # Convert actual references to a comparable format and sort actual_locations = sorted( [ { "uri_suffix": loc["uri"].split("/")[-1], "line": loc["range"]["start"]["line"], "character": loc["range"]["start"]["character"], } for loc in references ], key=lambda x: (x["uri_suffix"], x["line"], x["character"]), ) expected_locations = sorted(expected_locations, key=lambda x: (x["uri_suffix"], x["line"], x["character"])) assert actual_locations == expected_locations @pytest.mark.parametrize("language_server", _php_servers, indirect=True) @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) def test_find_references_across_files(self, language_server: SolidLanguageServer, repo_path: Path) -> None: helper_php_path = str(repo_path / "helper.php") # In index.php (0-indexed lines): # Line 13: helperFunction(); // Usage of helperFunction # Find references for helperFunction from its definition references = language_server.request_references(helper_php_path, 2, len("function ")) assert references, f"Expected non-empty references for helperFunction but got {references=}" if language_server.language_server.language == Language.PHP_PHPACTOR: actual_locations_comparable = [] for loc in references: actual_locations_comparable.append( { "uri_suffix": loc["uri"].split("/")[-1], "line": loc["range"]["start"]["line"], } ) # Check that helperFunction usage in index.php line 13 is found matching = [loc for loc in actual_locations_comparable if loc["uri_suffix"] == "index.php" and loc["line"] == 13] assert matching, f"Usage of helperFunction in index.php (line 13) not found in {actual_locations_comparable}" else: # Intelephense might return 1 (usage) or 2 (usage + definition) references. # Let's check for at least the usage in index.php # Definition is in helper.php, line 2, char 0 (based on previous findings) # Usage is in index.php, line 13, char 0 actual_locations_comparable = [] for loc in references: actual_locations_comparable.append( { "uri_suffix": loc["uri"].split("/")[-1], "line": loc["range"]["start"]["line"], "character": loc["range"]["start"]["character"], } ) usage_in_index_php = {"uri_suffix": "index.php", "line": 13, "character": 0} assert usage_in_index_php in actual_locations_comparable, "Usage of helperFunction in index.php not found" @pytest.mark.parametrize("language_server", [Language.PHP], indirect=True) def test_find_symbol(self, language_server: SolidLanguageServer) -> None: """Test that document symbols are properly retrieved after Intelephense capability fix.""" from solidlsp.ls_utils import SymbolUtils symbols = language_server.request_full_symbol_tree() assert SymbolUtils.symbol_tree_contains_name(symbols, "helperFunction"), "helperFunction not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "greet"), "greet function not found in symbol tree" @pytest.mark.parametrize("language_server", [Language.PHP], indirect=True) def test_document_symbols(self, language_server: SolidLanguageServer) -> None: """Test that document symbols are properly retrieved for a specific file.""" doc_symbols = language_server.request_document_symbols("helper.php") all_symbols = doc_symbols.get_all_symbols_and_roots() symbol_names = [sym.get("name") for sym in all_symbols[0] if sym.get("name")] assert "helperFunction" in symbol_names, f"helperFunction not found in document symbols. Found: {symbol_names}" @pytest.mark.parametrize("language_server", [Language.PHP], indirect=True) def test_document_symbols_hierarchical_structure(self, language_server: SolidLanguageServer) -> None: """Verify Intelephense returns hierarchical DocumentSymbol format. When hierarchicalDocumentSymbolSupport is declared in client capabilities, Intelephense returns DocumentSymbol[] where class methods appear as children of their parent class. Without this declaration, it falls back to a flat SymbolInformation[] list where all symbols appear at root level with no parent-child relationships. """ all_symbols, root_symbols = language_server.request_document_symbols("sample.php").get_all_symbols_and_roots() root_names = [s.get("name") for s in root_symbols] assert "Animal" in root_names, f"Animal class not found at root level. Roots: {root_names}" assert "Dog" in root_names, f"Dog class not found at root level. Roots: {root_names}" assert "Cat" in root_names, f"Cat class not found at root level. Roots: {root_names}" # Verify Dog has method children — this is the key assertion for hierarchical support. # With a flat response, Dog would have no children and all methods would be at root level. dog_symbol = next((s for s in root_symbols if s.get("name") == "Dog"), None) assert dog_symbol is not None, "Dog class not found in root symbols" dog_children = dog_symbol.get("children", []) dog_child_names = [c.get("name") for c in dog_children] assert ( len(dog_child_names) > 0 ), f"Dog class has no children — hierarchicalDocumentSymbolSupport is not working. All root symbols: {root_names}" expected_methods = {"greet", "fetch", "getBreed", "describe"} missing = expected_methods - set(dog_child_names) assert not missing, f"Dog class missing expected methods: {missing}. Children found: {dog_child_names}" # Methods must NOT appear at root level (that would indicate the flat fallback format). assert "greet" not in root_names, f"greet should be a child of Dog, not at root level. Roots: {root_names}" assert "fetch" not in root_names, f"fetch should be a child of Dog, not at root level. Roots: {root_names}" @pytest.mark.parametrize("language_server", [Language.PHP], indirect=True) def test_full_symbol_tree_within_file(self, language_server: SolidLanguageServer) -> None: """Verify request_full_symbol_tree scoped to a PHP file returns correct symbols. This validates that Intelephense responds correctly when symbols are requested for a single file, including class/method hierarchy in sample.php. """ from solidlsp.ls_utils import SymbolUtils symbols = language_server.request_full_symbol_tree(within_relative_path="sample.php") assert SymbolUtils.symbol_tree_contains_name(symbols, "Dog"), "Dog not found in sample.php symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "Animal"), "Animal not found in sample.php symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "greet"), "greet method not found in sample.php symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "fetch"), "fetch method not found in sample.php symbol tree" # Methods must appear as children of Dog, not as root-level symbols dog_root = next((s for s in symbols if s.get("name") == "Dog"), None) if dog_root is not None: assert SymbolUtils.symbol_tree_contains_name([dog_root], "greet"), "greet should be nested under Dog in symbol tree" ================================================ FILE: test/solidlsp/powershell/__init__.py ================================================ # PowerShell language server tests ================================================ FILE: test/solidlsp/powershell/test_powershell_basic.py ================================================ """ Basic integration tests for the PowerShell language server functionality. These tests validate the functionality of the language server APIs like request_document_symbols using the PowerShell test repository. """ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language @pytest.mark.powershell class TestPowerShellLanguageServerBasics: """Test basic functionality of the PowerShell language server.""" @pytest.mark.parametrize("language_server", [Language.POWERSHELL], indirect=True) def test_powershell_language_server_initialization(self, language_server: SolidLanguageServer) -> None: """Test that PowerShell language server can be initialized successfully.""" assert language_server is not None assert language_server.language == Language.POWERSHELL @pytest.mark.parametrize("language_server", [Language.POWERSHELL], indirect=True) def test_powershell_request_document_symbols(self, language_server: SolidLanguageServer) -> None: """Test request_document_symbols for PowerShell files.""" # Test getting symbols from main.ps1 all_symbols, _root_symbols = language_server.request_document_symbols("main.ps1").get_all_symbols_and_roots() # Extract function symbols (LSP Symbol Kind 12) function_symbols = [symbol for symbol in all_symbols if symbol.get("kind") == 12] function_names = [symbol["name"] for symbol in function_symbols] # PSES returns function names in format "function FuncName ()" - check for function name substring def has_function(name: str) -> bool: return any(name in fn for fn in function_names) # Should detect the main functions from main.ps1 assert has_function("Greet-User"), f"Should find Greet-User function in {function_names}" assert has_function("Process-Items"), f"Should find Process-Items function in {function_names}" assert has_function("Main"), f"Should find Main function in {function_names}" assert len(function_symbols) >= 3, f"Should find at least 3 functions, found {len(function_symbols)}" @pytest.mark.parametrize("language_server", [Language.POWERSHELL], indirect=True) def test_powershell_utils_functions(self, language_server: SolidLanguageServer) -> None: """Test function detection in utils.ps1 file.""" # Test with utils.ps1 utils_all_symbols, _utils_root_symbols = language_server.request_document_symbols("utils.ps1").get_all_symbols_and_roots() utils_function_symbols = [symbol for symbol in utils_all_symbols if symbol.get("kind") == 12] utils_function_names = [symbol["name"] for symbol in utils_function_symbols] # PSES returns function names in format "function FuncName ()" - check for function name substring def has_function(name: str) -> bool: return any(name in fn for fn in utils_function_names) # Should detect functions from utils.ps1 expected_utils_functions = [ "Convert-ToUpperCase", "Convert-ToLowerCase", "Remove-Whitespace", "Backup-File", "Test-ArrayContains", "Write-LogMessage", "Test-ValidEmail", "Test-IsNumber", ] for func_name in expected_utils_functions: assert has_function(func_name), f"Should find {func_name} function in utils.ps1, got {utils_function_names}" assert len(utils_function_symbols) >= 8, f"Should find at least 8 functions in utils.ps1, found {len(utils_function_symbols)}" @pytest.mark.parametrize("language_server", [Language.POWERSHELL], indirect=True) def test_powershell_function_with_parameters(self, language_server: SolidLanguageServer) -> None: """Test that functions with CmdletBinding and parameters are detected correctly.""" all_symbols, _root_symbols = language_server.request_document_symbols("main.ps1").get_all_symbols_and_roots() function_symbols = [symbol for symbol in all_symbols if symbol.get("kind") == 12] # Find Greet-User function which has parameters # PSES returns function names in format "function FuncName ()" greet_user_symbol = next((sym for sym in function_symbols if "Greet-User" in sym["name"]), None) assert greet_user_symbol is not None, f"Should find Greet-User function in {[s['name'] for s in function_symbols]}" # Find Process-Items function which has array parameter process_items_symbol = next((sym for sym in function_symbols if "Process-Items" in sym["name"]), None) assert process_items_symbol is not None, f"Should find Process-Items function in {[s['name'] for s in function_symbols]}" @pytest.mark.parametrize("language_server", [Language.POWERSHELL], indirect=True) def test_powershell_all_function_detection(self, language_server: SolidLanguageServer) -> None: """Test that all expected functions are detected across both files.""" # Get symbols from main.ps1 main_all_symbols, _main_root_symbols = language_server.request_document_symbols("main.ps1").get_all_symbols_and_roots() main_functions = [symbol for symbol in main_all_symbols if symbol.get("kind") == 12] main_function_names = [func["name"] for func in main_functions] # Get symbols from utils.ps1 utils_all_symbols, _utils_root_symbols = language_server.request_document_symbols("utils.ps1").get_all_symbols_and_roots() utils_functions = [symbol for symbol in utils_all_symbols if symbol.get("kind") == 12] utils_function_names = [func["name"] for func in utils_functions] # PSES returns function names in format "function FuncName ()" - check for function name substring def has_main_function(name: str) -> bool: return any(name in fn for fn in main_function_names) def has_utils_function(name: str) -> bool: return any(name in fn for fn in utils_function_names) # Verify main.ps1 functions expected_main = ["Greet-User", "Process-Items", "Main"] for expected_func in expected_main: assert has_main_function(expected_func), f"Should detect {expected_func} function in main.ps1, got {main_function_names}" # Verify utils.ps1 functions expected_utils = [ "Convert-ToUpperCase", "Convert-ToLowerCase", "Remove-Whitespace", "Backup-File", "Test-ArrayContains", "Write-LogMessage", "Test-ValidEmail", "Test-IsNumber", ] for expected_func in expected_utils: assert has_utils_function(expected_func), f"Should detect {expected_func} function in utils.ps1, got {utils_function_names}" # Verify total counts assert len(main_functions) >= 3, f"Should find at least 3 functions in main.ps1, found {len(main_functions)}" assert len(utils_functions) >= 8, f"Should find at least 8 functions in utils.ps1, found {len(utils_functions)}" @pytest.mark.parametrize("language_server", [Language.POWERSHELL], indirect=True) def test_powershell_find_references_within_file(self, language_server: SolidLanguageServer) -> None: """Test finding references to a function within the same file.""" main_path = "main.ps1" # Get symbols to find the Greet-User function which is called from Main all_symbols, _root_symbols = language_server.request_document_symbols(main_path).get_all_symbols_and_roots() # Find Greet-User function definition function_symbols = [s for s in all_symbols if s.get("kind") == 12] greet_user_symbol = next((s for s in function_symbols if "Greet-User" in s["name"]), None) assert greet_user_symbol is not None, f"Should find Greet-User function in {[s['name'] for s in function_symbols]}" # Find references to Greet-User (should be called from Main function at line 91) sel_start = greet_user_symbol["selectionRange"]["start"] refs = language_server.request_references(main_path, sel_start["line"], sel_start["character"]) # Should find at least the call site in Main function assert refs is not None and len(refs) >= 1, f"Should find references to Greet-User, got {refs}" assert any( "main.ps1" in ref.get("uri", ref.get("relativePath", "")) for ref in refs ), f"Should find reference in main.ps1, got {refs}" @pytest.mark.parametrize("language_server", [Language.POWERSHELL], indirect=True) def test_powershell_find_definition_across_files(self, language_server: SolidLanguageServer) -> None: """Test finding definition of functions across files (main.ps1 -> utils.ps1).""" # main.ps1 calls Convert-ToUpperCase from utils.ps1 at line 99 (0-indexed: 98) # The call is: $upperName = Convert-ToUpperCase -InputString $User # We'll request definition from the call site in main.ps1 main_path = "main.ps1" # Find definition of Convert-ToUpperCase from its usage in main.ps1 # Line 99 (1-indexed) = line 98 (0-indexed), character position ~16 for "Convert-ToUpperCase" definition_locations = language_server.request_definition(main_path, 98, 18) # Should find the definition in utils.ps1 assert ( definition_locations is not None and len(definition_locations) >= 1 ), f"Should find definition of Convert-ToUpperCase, got {definition_locations}" assert any( "utils.ps1" in loc.get("uri", "") for loc in definition_locations ), f"Should find definition in utils.ps1, got {definition_locations}" ================================================ FILE: test/solidlsp/python/test_python_basic.py ================================================ """ Basic integration tests for the language server functionality. These tests validate the functionality of the language server APIs like request_references using the test repository. """ import os import pytest from serena.project import Project from serena.util.text_utils import LineType from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language @pytest.mark.python class TestPythonLanguageServerBasics: """Test basic functionality of the language server.""" @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) def test_request_references_user_class(self, language_server: SolidLanguageServer) -> None: """Test request_references on the User class.""" # Get references to the User class in models.py file_path = os.path.join("test_repo", "models.py") # Line 31 contains the User class definition # Use selectionRange only symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() user_symbol = next((s for s in symbols[0] if s.get("name") == "User"), None) if not user_symbol or "selectionRange" not in user_symbol: raise AssertionError("User symbol or its selectionRange not found") sel_start = user_symbol["selectionRange"]["start"] references = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) assert len(references) > 1, "User class should be referenced in multiple files (using selectionRange if present)" @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) def test_request_references_item_class(self, language_server: SolidLanguageServer) -> None: """Test request_references on the Item class.""" # Get references to the Item class in models.py file_path = os.path.join("test_repo", "models.py") # Line 56 contains the Item class definition # Use selectionRange only symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() item_symbol = next((s for s in symbols[0] if s.get("name") == "Item"), None) if not item_symbol or "selectionRange" not in item_symbol: raise AssertionError("Item symbol or its selectionRange not found") sel_start = item_symbol["selectionRange"]["start"] references = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) services_references = [ref for ref in references if "services.py" in ref["uri"]] assert len(services_references) > 0, "At least one reference should be in services.py (using selectionRange if present)" @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) def test_request_references_function_parameter(self, language_server: SolidLanguageServer) -> None: """Test request_references on a function parameter.""" # Get references to the id parameter in get_user method file_path = os.path.join("test_repo", "services.py") # Line 24 contains the get_user method with id parameter # Use selectionRange only symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() get_user_symbol = next((s for s in symbols[0] if s.get("name") == "get_user"), None) if not get_user_symbol or "selectionRange" not in get_user_symbol: raise AssertionError("get_user symbol or its selectionRange not found") sel_start = get_user_symbol["selectionRange"]["start"] references = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) assert len(references) > 0, "id parameter should be referenced within the method (using selectionRange if present)" @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) def test_request_references_create_user_method(self, language_server: SolidLanguageServer) -> None: # Get references to the create_user method in UserService file_path = os.path.join("test_repo", "services.py") # Line 15 contains the create_user method definition # Use selectionRange only symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() create_user_symbol = next((s for s in symbols[0] if s.get("name") == "create_user"), None) if not create_user_symbol or "selectionRange" not in create_user_symbol: raise AssertionError("create_user symbol or its selectionRange not found") sel_start = create_user_symbol["selectionRange"]["start"] references = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) assert len(references) > 1, "Should get valid references for create_user (using selectionRange if present)" class TestProjectBasics: @pytest.mark.parametrize("project", [Language.PYTHON], indirect=True) def test_retrieve_content_around_line(self, project: Project) -> None: """Test retrieve_content_around_line functionality with various scenarios.""" file_path = os.path.join("test_repo", "models.py") # Scenario 1: Just a single line (User class definition) line_31 = project.retrieve_content_around_line(file_path, 31) assert len(line_31.lines) == 1 assert "class User(BaseModel):" in line_31.lines[0].line_content assert line_31.lines[0].line_number == 31 assert line_31.lines[0].match_type == LineType.MATCH # Scenario 2: Context above and below with_context_around_user = project.retrieve_content_around_line(file_path, 31, 2, 2) assert len(with_context_around_user.lines) == 5 # Check line content assert "class User(BaseModel):" in with_context_around_user.matched_lines[0].line_content assert with_context_around_user.num_matched_lines == 1 assert " User model representing a system user." in with_context_around_user.lines[4].line_content # Check line numbers assert with_context_around_user.lines[0].line_number == 29 assert with_context_around_user.lines[1].line_number == 30 assert with_context_around_user.lines[2].line_number == 31 assert with_context_around_user.lines[3].line_number == 32 assert with_context_around_user.lines[4].line_number == 33 # Check match types assert with_context_around_user.lines[0].match_type == LineType.BEFORE_MATCH assert with_context_around_user.lines[1].match_type == LineType.BEFORE_MATCH assert with_context_around_user.lines[2].match_type == LineType.MATCH assert with_context_around_user.lines[3].match_type == LineType.AFTER_MATCH assert with_context_around_user.lines[4].match_type == LineType.AFTER_MATCH # Scenario 3a: Only context above with_context_above = project.retrieve_content_around_line(file_path, 31, 3, 0) assert len(with_context_above.lines) == 4 assert "return cls(id=id, name=name)" in with_context_above.lines[0].line_content assert "class User(BaseModel):" in with_context_above.matched_lines[0].line_content assert with_context_above.num_matched_lines == 1 # Check line numbers assert with_context_above.lines[0].line_number == 28 assert with_context_above.lines[1].line_number == 29 assert with_context_above.lines[2].line_number == 30 assert with_context_above.lines[3].line_number == 31 # Check match types assert with_context_above.lines[0].match_type == LineType.BEFORE_MATCH assert with_context_above.lines[1].match_type == LineType.BEFORE_MATCH assert with_context_above.lines[2].match_type == LineType.BEFORE_MATCH assert with_context_above.lines[3].match_type == LineType.MATCH # Scenario 3b: Only context below with_context_below = project.retrieve_content_around_line(file_path, 31, 0, 3) assert len(with_context_below.lines) == 4 assert "class User(BaseModel):" in with_context_below.matched_lines[0].line_content assert with_context_below.num_matched_lines == 1 assert with_context_below.lines[0].line_number == 31 assert with_context_below.lines[1].line_number == 32 assert with_context_below.lines[2].line_number == 33 assert with_context_below.lines[3].line_number == 34 # Check match types assert with_context_below.lines[0].match_type == LineType.MATCH assert with_context_below.lines[1].match_type == LineType.AFTER_MATCH assert with_context_below.lines[2].match_type == LineType.AFTER_MATCH assert with_context_below.lines[3].match_type == LineType.AFTER_MATCH # Scenario 4a: Edge case - context above but line is at 0 first_line_with_context_around = project.retrieve_content_around_line(file_path, 0, 2, 1) assert len(first_line_with_context_around.lines) <= 4 # Should have at most 4 lines (line 0 + 1 below + up to 2 above) assert first_line_with_context_around.lines[0].line_number <= 2 # First line should be at most line 2 # Check match type for the target line for line in first_line_with_context_around.lines: if line.line_number == 0: assert line.match_type == LineType.MATCH elif line.line_number < 0: assert line.match_type == LineType.BEFORE_MATCH else: assert line.match_type == LineType.AFTER_MATCH # Scenario 4b: Edge case - context above but line is at 1 second_line_with_context_above = project.retrieve_content_around_line(file_path, 1, 3, 1) assert len(second_line_with_context_above.lines) <= 5 # Should have at most 5 lines (line 1 + 1 below + up to 3 above) assert second_line_with_context_above.lines[0].line_number <= 1 # First line should be at most line 1 # Check match type for the target line for line in second_line_with_context_above.lines: if line.line_number == 1: assert line.match_type == LineType.MATCH elif line.line_number < 1: assert line.match_type == LineType.BEFORE_MATCH else: assert line.match_type == LineType.AFTER_MATCH # Scenario 4c: Edge case - context below but line is at the end of file # First get the total number of lines in the file all_content = project.read_file(file_path) total_lines = len(all_content.split("\n")) last_line_with_context_around = project.retrieve_content_around_line(file_path, total_lines - 1, 1, 3) assert len(last_line_with_context_around.lines) <= 5 # Should have at most 5 lines (last line + 1 above + up to 3 below) assert last_line_with_context_around.lines[-1].line_number >= total_lines - 4 # Last line should be at least total_lines - 4 # Check match type for the target line for line in last_line_with_context_around.lines: if line.line_number == total_lines - 1: assert line.match_type == LineType.MATCH elif line.line_number < total_lines - 1: assert line.match_type == LineType.BEFORE_MATCH else: assert line.match_type == LineType.AFTER_MATCH @pytest.mark.parametrize("project", [Language.PYTHON], indirect=True) def test_search_files_for_pattern(self, project: Project) -> None: """Test search_files_for_pattern with various patterns and glob filters.""" # Test 1: Search for class definitions across all files class_pattern = r"class\s+\w+\s*(?:\([^{]*\)|:)" matches = project.search_source_files_for_pattern(class_pattern) assert len(matches) > 0 # Should find multiple classes like User, Item, BaseModel, etc. assert len(matches) >= 5 # Test 2: Search for specific class with include glob user_class_pattern = r"class\s+User\s*(?:\([^{]*\)|:)" matches = project.search_source_files_for_pattern(user_class_pattern, paths_include_glob="**/models.py") assert len(matches) == 1 # Should only find User class in models.py assert matches[0].source_file_path is not None assert "models.py" in matches[0].source_file_path # Test 3: Search for method definitions with exclude glob method_pattern = r"def\s+\w+\s*\([^)]*\):" matches = project.search_source_files_for_pattern(method_pattern, paths_exclude_glob="**/models.py") assert len(matches) > 0 # Should find methods in services.py but not in models.py assert all(match.source_file_path is not None and "models.py" not in match.source_file_path for match in matches) # Test 4: Search for specific method with both include and exclude globs create_user_pattern = r"def\s+create_user\s*\([^)]*\)(?:\s*->[^:]+)?:" matches = project.search_source_files_for_pattern( create_user_pattern, paths_include_glob="**/*.py", paths_exclude_glob="**/models.py" ) assert len(matches) == 1 # Should only find create_user in services.py assert matches[0].source_file_path is not None assert "services.py" in matches[0].source_file_path # Test 5: Search for a pattern that should appear in multiple files init_pattern = r"def\s+__init__\s*\([^)]*\):" matches = project.search_source_files_for_pattern(init_pattern) assert len(matches) > 1 # Should find __init__ in multiple classes # Should find __init__ in both models.py and services.py assert any(match.source_file_path is not None and "models.py" in match.source_file_path for match in matches) assert any(match.source_file_path is not None and "services.py" in match.source_file_path for match in matches) # Test 6: Search with a pattern that should have no matches no_match_pattern = r"def\s+this_method_does_not_exist\s*\([^)]*\):" matches = project.search_source_files_for_pattern(no_match_pattern) assert len(matches) == 0 ================================================ FILE: test/solidlsp/python/test_retrieval_with_ignored_dirs.py ================================================ from collections.abc import Generator from pathlib import Path import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from test.conftest import start_ls_context # This mark will be applied to all tests in this module pytestmark = pytest.mark.python @pytest.fixture(scope="module") def ls_with_ignored_dirs() -> Generator[SolidLanguageServer, None, None]: """Fixture to set up an LS for the python test repo with the 'scripts' directory ignored.""" ignored_paths = ["scripts", "custom_test"] with start_ls_context(language=Language.PYTHON, ignored_paths=ignored_paths) as ls: yield ls @pytest.mark.parametrize("ls_with_ignored_dirs", [Language.PYTHON], indirect=True) def test_symbol_tree_ignores_dir(ls_with_ignored_dirs: SolidLanguageServer): """Tests that request_full_symbol_tree ignores the configured directory.""" root = ls_with_ignored_dirs.request_full_symbol_tree()[0] root_children = root["children"] children_names = {child["name"] for child in root_children} assert children_names == {"test_repo", "examples"} @pytest.mark.parametrize("ls_with_ignored_dirs", [Language.PYTHON], indirect=True) def test_find_references_ignores_dir(ls_with_ignored_dirs: SolidLanguageServer): """Tests that find_references ignores the configured directory.""" # Location of Item, which is referenced in scripts definition_file = "test_repo/models.py" definition_line = 56 definition_col = 6 references = ls_with_ignored_dirs.request_references(definition_file, definition_line, definition_col) # assert that scripts does not appear in the references assert not any("scripts" in ref["relativePath"] for ref in references) @pytest.mark.parametrize("repo_path", [Language.PYTHON], indirect=True) def test_refs_and_symbols_with_glob_patterns(repo_path: Path) -> None: """Tests that refs and symbols with glob patterns are ignored.""" ignored_paths = ["*ipts", "custom_t*"] with start_ls_context(language=Language.PYTHON, repo_path=str(repo_path), ignored_paths=ignored_paths) as ls: # same as in the above tests root = ls.request_full_symbol_tree()[0] root_children = root["children"] children_names = {child["name"] for child in root_children} assert children_names == {"test_repo", "examples"} # test that the refs and symbols with glob patterns are ignored definition_file = "test_repo/models.py" definition_line = 56 definition_col = 6 references = ls.request_references(definition_file, definition_line, definition_col) assert not any("scripts" in ref["relativePath"] for ref in references) ================================================ FILE: test/solidlsp/python/test_symbol_retrieval.py ================================================ """ Tests for the language server symbol-related functionality. These tests focus on the following methods: - request_containing_symbol - request_referencing_symbols """ import os import pytest from serena.symbol import LanguageServerSymbol from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind pytestmark = pytest.mark.python class TestLanguageServerSymbols: """Test the language server's symbol-related functionality.""" @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) def test_request_containing_symbol_function(self, language_server: SolidLanguageServer) -> None: """Test request_containing_symbol for a function.""" # Test for a position inside the create_user method file_path = os.path.join("test_repo", "services.py") # Line 17 is inside the create_user method body containing_symbol = language_server.request_containing_symbol(file_path, 17, 20, include_body=True) # Verify that we found the containing symbol assert containing_symbol is not None assert containing_symbol["name"] == "create_user" assert containing_symbol["kind"] == SymbolKind.Method if "body" in containing_symbol: assert containing_symbol["body"].get_text().strip().startswith("def create_user(self") @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) def test_references_to_variables(self, language_server: SolidLanguageServer) -> None: """Test request_referencing_symbols for a variable.""" file_path = os.path.join("test_repo", "variables.py") # Line 75 contains the field status that is later modified ref_symbols = [ref.symbol for ref in language_server.request_referencing_symbols(file_path, 74, 4)] assert len(ref_symbols) > 0 ref_lines = [ref["location"]["range"]["start"]["line"] for ref in ref_symbols if "location" in ref and "range" in ref["location"]] ref_names = [ref["name"] for ref in ref_symbols] assert 87 in ref_lines assert 95 in ref_lines assert "dataclass_instance" in ref_names assert "second_dataclass" in ref_names @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) def test_request_containing_symbol_class(self, language_server: SolidLanguageServer) -> None: """Test request_containing_symbol for a class.""" # Test for a position inside the UserService class but outside any method file_path = os.path.join("test_repo", "services.py") # Line 9 is the class definition line for UserService containing_symbol = language_server.request_containing_symbol(file_path, 9, 7) # Verify that we found the containing symbol assert containing_symbol is not None assert containing_symbol["name"] == "UserService" assert containing_symbol["kind"] == SymbolKind.Class @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) def test_request_containing_symbol_nested(self, language_server: SolidLanguageServer) -> None: """Test request_containing_symbol with nested scopes.""" # Test for a position inside a method which is inside a class file_path = os.path.join("test_repo", "services.py") # Line 18 is inside the create_user method inside UserService class containing_symbol = language_server.request_containing_symbol(file_path, 18, 25) # Verify that we found the innermost containing symbol (the method) assert containing_symbol is not None assert containing_symbol["name"] == "create_user" assert containing_symbol["kind"] == SymbolKind.Method # Get the parent containing symbol if "location" in containing_symbol and "range" in containing_symbol["location"]: parent_symbol = language_server.request_containing_symbol( file_path, containing_symbol["location"]["range"]["start"]["line"], containing_symbol["location"]["range"]["start"]["character"] - 1, ) # Verify that the parent is the class assert parent_symbol is not None assert parent_symbol["name"] == "UserService" assert parent_symbol["kind"] == SymbolKind.Class @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) def test_request_containing_symbol_none(self, language_server: SolidLanguageServer) -> None: """Test request_containing_symbol for a position with no containing symbol.""" # Test for a position outside any function/class (e.g., in imports) file_path = os.path.join("test_repo", "services.py") # Line 1 is in imports, not inside any function or class containing_symbol = language_server.request_containing_symbol(file_path, 1, 10) # Should return None or an empty dictionary assert containing_symbol is None or containing_symbol == {} @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) def test_request_referencing_symbols_function(self, language_server: SolidLanguageServer) -> None: """Test request_referencing_symbols for a function.""" # Test referencing symbols for create_user function file_path = os.path.join("test_repo", "services.py") # Line 15 contains the create_user function definition symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() create_user_symbol = next((s for s in symbols[0] if s.get("name") == "create_user"), None) if not create_user_symbol or "selectionRange" not in create_user_symbol: raise AssertionError("create_user symbol or its selectionRange not found") sel_start = create_user_symbol["selectionRange"]["start"] ref_symbols = [ ref.symbol for ref in language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"]) ] assert len(ref_symbols) > 0, "No referencing symbols found for create_user (selectionRange)" # Verify the structure of referencing symbols for symbol in ref_symbols: assert "name" in symbol assert "kind" in symbol if "location" in symbol and "range" in symbol["location"]: assert "start" in symbol["location"]["range"] assert "end" in symbol["location"]["range"] @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) def test_request_referencing_symbols_class(self, language_server: SolidLanguageServer) -> None: """Test request_referencing_symbols for a class.""" # Test referencing symbols for User class file_path = os.path.join("test_repo", "models.py") # Line 31 contains the User class definition symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() user_symbol = next((s for s in symbols[0] if s.get("name") == "User"), None) if not user_symbol or "selectionRange" not in user_symbol: raise AssertionError("User symbol or its selectionRange not found") sel_start = user_symbol["selectionRange"]["start"] ref_symbols = [ ref.symbol for ref in language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"]) ] services_references = [ symbol for symbol in ref_symbols if "location" in symbol and "uri" in symbol["location"] and "services.py" in symbol["location"]["uri"] ] assert len(services_references) > 0, "No referencing symbols from services.py for User (selectionRange)" @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) def test_request_referencing_symbols_parameter(self, language_server: SolidLanguageServer) -> None: """Test request_referencing_symbols for a function parameter.""" # Test referencing symbols for id parameter in get_user file_path = os.path.join("test_repo", "services.py") # Line 24 contains the get_user method with id parameter symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() get_user_symbol = next((s for s in symbols[0] if s.get("name") == "get_user"), None) if not get_user_symbol or "selectionRange" not in get_user_symbol: raise AssertionError("get_user symbol or its selectionRange not found") sel_start = get_user_symbol["selectionRange"]["start"] ref_symbols = [ ref.symbol for ref in language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"]) ] method_refs = [ symbol for symbol in ref_symbols if "location" in symbol and "range" in symbol["location"] and symbol["location"]["range"]["start"]["line"] > sel_start["line"] ] assert len(method_refs) > 0, "No referencing symbols within method body for get_user (selectionRange)" @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) def test_request_referencing_symbols_none(self, language_server: SolidLanguageServer) -> None: """Test request_referencing_symbols for a position with no symbol.""" # For positions with no symbol, the method might throw an error or return None/empty list # We'll modify our test to handle this by using a try-except block file_path = os.path.join("test_repo", "services.py") # Line 3 is a blank line or comment try: ref_symbols = [ref.symbol for ref in language_server.request_referencing_symbols(file_path, 3, 0)] # If we get here, make sure we got an empty result assert ref_symbols == [] or ref_symbols is None except Exception: # The method might raise an exception for invalid positions # which is acceptable behavior pass # Tests for request_defining_symbol @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) def test_request_defining_symbol_variable(self, language_server: SolidLanguageServer) -> None: """Test request_defining_symbol for a variable usage.""" # Test finding the definition of a symbol in the create_user method file_path = os.path.join("test_repo", "services.py") # Line 21 contains self.users[id] = user defining_symbol = language_server.request_defining_symbol(file_path, 21, 10) # Verify that we found the defining symbol # The defining symbol method returns a dictionary with information about the defining symbol assert defining_symbol is not None assert defining_symbol.get("name") == "create_user" # Verify the location and kind of the symbol # SymbolKind.Method = 6 for a method assert defining_symbol.get("kind") == SymbolKind.Method.value if "location" in defining_symbol and "uri" in defining_symbol["location"]: assert "services.py" in defining_symbol["location"]["uri"] @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) def test_request_defining_symbol_imported_class(self, language_server: SolidLanguageServer) -> None: """Test request_defining_symbol for an imported class.""" # Test finding the definition of the 'User' class used in the UserService.create_user method file_path = os.path.join("test_repo", "services.py") # Line 20 references 'User' which was imported from models defining_symbol = language_server.request_defining_symbol(file_path, 20, 15) # Verify that we found the defining symbol - this should be the User class from models assert defining_symbol is not None assert defining_symbol.get("name") == "User" @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) def test_request_defining_symbol_method_call(self, language_server: SolidLanguageServer) -> None: """Test request_defining_symbol for a method call.""" # Create an example file path for a file that calls UserService.create_user examples_file_path = os.path.join("examples", "user_management.py") # Find the line number where create_user is called # This could vary, so we'll use a relative position that makes sense defining_symbol = language_server.request_defining_symbol(examples_file_path, 10, 30) # Verify that we found the defining symbol - should be the create_user method # Because this might fail if the structure isn't exactly as expected, we'll use try-except try: assert defining_symbol is not None assert defining_symbol.get("name") == "create_user" # The defining symbol should be in the services.py file if "location" in defining_symbol and "uri" in defining_symbol["location"]: assert "services.py" in defining_symbol["location"]["uri"] except AssertionError: # If the file structure doesn't match what we expect, we can't guarantee this test # will pass, so we'll consider it a warning rather than a failure import warnings warnings.warn("Could not verify method call definition - file structure may differ from expected") @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) def test_request_defining_symbol_none(self, language_server: SolidLanguageServer) -> None: """Test request_defining_symbol for a position with no symbol.""" # Test for a position with no symbol (e.g., whitespace or comment) file_path = os.path.join("test_repo", "services.py") # Line 3 is a blank line defining_symbol = language_server.request_defining_symbol(file_path, 3, 0) # Should return None for positions with no symbol assert defining_symbol is None @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) def test_request_containing_symbol_variable(self, language_server: SolidLanguageServer) -> None: """Test request_containing_symbol where the symbol is a variable.""" # Test for a position inside a variable definition file_path = os.path.join("test_repo", "services.py") # Line 74 defines the 'user' variable containing_symbol = language_server.request_containing_symbol(file_path, 73, 1) # Verify that we found the containing symbol assert containing_symbol is not None assert containing_symbol["name"] == "user_var_str" assert containing_symbol["kind"] == SymbolKind.Variable @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) def test_request_defining_symbol_nested_function(self, language_server: SolidLanguageServer) -> None: """Test request_defining_symbol for a nested function or closure.""" # Use the existing nested.py file which contains nested classes and methods file_path = os.path.join("test_repo", "nested.py") # Test 1: Find definition of nested method - line with 'b = OuterClass().NestedClass().find_me()' defining_symbol = language_server.request_defining_symbol(file_path, 15, 35) # Position of find_me() call # This should resolve to the find_me method in the NestedClass assert defining_symbol is not None assert defining_symbol.get("name") == "find_me" assert defining_symbol.get("kind") == SymbolKind.Method.value # Test 2: Find definition of the nested class defining_symbol = language_server.request_defining_symbol(file_path, 15, 18) # Position of NestedClass # This should resolve to the NestedClass assert defining_symbol is not None assert defining_symbol.get("name") == "NestedClass" assert defining_symbol.get("kind") == SymbolKind.Class.value # Test 3: Find definition of a method-local function defining_symbol = language_server.request_defining_symbol(file_path, 9, 15) # Position inside func_within_func # This is challenging for many language servers and may fail try: assert defining_symbol is not None assert defining_symbol.get("name") == "func_within_func" except (AssertionError, TypeError, KeyError): # This is expected to potentially fail in many implementations import warnings warnings.warn("Could not resolve nested class method definition - implementation limitation") # Test 2: Find definition of the nested class defining_symbol = language_server.request_defining_symbol(file_path, 15, 18) # Position of NestedClass # This should resolve to the NestedClass assert defining_symbol is not None assert defining_symbol.get("name") == "NestedClass" assert defining_symbol.get("kind") == SymbolKind.Class.value # Test 3: Find definition of a method-local function defining_symbol = language_server.request_defining_symbol(file_path, 9, 15) # Position inside func_within_func # This is challenging for many language servers and may fail assert defining_symbol is not None assert defining_symbol.get("name") == "func_within_func" assert defining_symbol.get("kind") == SymbolKind.Function.value @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) def test_symbol_methods_integration(self, language_server: SolidLanguageServer) -> None: """Test the integration between different symbol-related methods.""" # This test demonstrates using the various symbol methods together # by finding a symbol and then checking its definition file_path = os.path.join("test_repo", "services.py") # First approach: Use a method from the UserService class # Step 1: Find a method we know exists containing_symbol = language_server.request_containing_symbol(file_path, 15, 8) # create_user method assert containing_symbol is not None assert containing_symbol["name"] == "create_user" # Step 2: Get the defining symbol for the same position # This should be the same method defining_symbol = language_server.request_defining_symbol(file_path, 15, 8) assert defining_symbol is not None assert defining_symbol["name"] == "create_user" # Step 3: Verify that they refer to the same symbol assert defining_symbol["kind"] == containing_symbol["kind"] if "location" in defining_symbol and "location" in containing_symbol: assert defining_symbol["location"]["uri"] == containing_symbol["location"]["uri"] # The integration test is successful if we've gotten this far, # as it demonstrates the integration between request_containing_symbol and request_defining_symbol # Try to get the container information for our method, but be flexible # since implementations may vary container_name = defining_symbol.get("containerName", None) if container_name and "UserService" in container_name: # If containerName contains UserService, that's a valid implementation pass else: # Try an alternative approach - looking for the containing class try: # Look for the class symbol in the file for line in range(5, 12): # Approximate range where UserService class should be defined symbol = language_server.request_containing_symbol(file_path, line, 5) # column 5 should be within class definition if symbol and symbol.get("name") == "UserService" and symbol.get("kind") == SymbolKind.Class.value: # Found the class - this is also a valid implementation break except Exception: # Just log a warning - this is an alternative verification and not essential import warnings warnings.warn("Could not verify container hierarchy - implementation detail") @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) def test_symbol_tree_structure(self, language_server: SolidLanguageServer) -> None: """Test that the symbol tree structure is correctly built.""" # Get all symbols in the test file repo_structure = language_server.request_full_symbol_tree() assert len(repo_structure) == 1 # Assert that the root symbol is the test_repo directory assert repo_structure[0]["name"] == "test_repo" assert repo_structure[0]["kind"] == SymbolKind.Package assert "children" in repo_structure[0] # Assert that the children are the top-level packages child_names = {child["name"] for child in repo_structure[0]["children"]} child_kinds = {child["kind"] for child in repo_structure[0]["children"]} assert child_names == {"test_repo", "custom_test", "examples", "scripts"} assert child_kinds == {SymbolKind.Package} examples_package = next(child for child in repo_structure[0]["children"] if child["name"] == "examples") # assert that children are __init__ and user_management assert {child["name"] for child in examples_package["children"]} == {"__init__", "user_management"} assert {child["kind"] for child in examples_package["children"]} == {SymbolKind.File} # assert that tree of user_management node is same as retrieved directly user_management_node = next(child for child in examples_package["children"] if child["name"] == "user_management") if "location" in user_management_node and "relativePath" in user_management_node["location"]: user_management_rel_path = user_management_node["location"]["relativePath"] assert user_management_rel_path == os.path.join("examples", "user_management.py") _, user_management_roots = language_server.request_document_symbols( os.path.join("examples", "user_management.py") ).get_all_symbols_and_roots() assert user_management_roots == user_management_node["children"] @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) def test_symbol_tree_structure_subdir(self, language_server: SolidLanguageServer) -> None: """Test that the symbol tree structure is correctly built.""" # Get all symbols in the test file examples_package_roots = language_server.request_full_symbol_tree(within_relative_path="examples") assert len(examples_package_roots) == 1 examples_package = examples_package_roots[0] assert examples_package["name"] == "examples" assert examples_package["kind"] == SymbolKind.Package # assert that children are __init__ and user_management assert {child["name"] for child in examples_package["children"]} == {"__init__", "user_management"} assert {child["kind"] for child in examples_package["children"]} == {SymbolKind.File} # assert that tree of user_management node is same as retrieved directly user_management_node = next(child for child in examples_package["children"] if child["name"] == "user_management") if "location" in user_management_node and "relativePath" in user_management_node["location"]: user_management_rel_path = user_management_node["location"]["relativePath"] assert user_management_rel_path == os.path.join("examples", "user_management.py") _, user_management_roots = language_server.request_document_symbols( os.path.join("examples", "user_management.py") ).get_all_symbols_and_roots() assert user_management_roots == user_management_node["children"] @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) def test_request_dir_overview(self, language_server: SolidLanguageServer) -> None: """Test that request_dir_overview returns correct symbol information for files in a directory.""" # Get overview of the examples directory overview = language_server.request_dir_overview("test_repo") # Verify that we have entries for both files assert os.path.join("test_repo", "nested.py") in overview # Get the symbols for user_management.py services_symbols = overview[os.path.join("test_repo", "services.py")] assert len(services_symbols) > 0 # Check for specific symbols from services.py expected_symbols = { "UserService", "ItemService", "create_service_container", "user_var_str", "user_service", } retrieved_symbols = {symbol["name"] for symbol in services_symbols if "name" in symbol} assert expected_symbols.issubset(retrieved_symbols) @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) def test_request_document_overview(self, language_server: SolidLanguageServer) -> None: """Test that request_document_overview returns correct symbol information for a file.""" # Get overview of the user_management.py file overview = language_server.request_document_overview(os.path.join("examples", "user_management.py")) # Verify that we have entries for both files symbol_names = {LanguageServerSymbol(s_info).name for s_info in overview} assert {"UserStats", "UserManager", "process_user_data", "main"}.issubset(symbol_names) @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) def test_containing_symbol_of_var_is_file(self, language_server: SolidLanguageServer) -> None: """Test that the containing symbol of a variable is the file itself.""" # Get the containing symbol of a variable in a file file_path = os.path.join("test_repo", "services.py") # import of typing references_to_typing = [ ref.symbol for ref in language_server.request_referencing_symbols(file_path, 4, 6, include_imports=False, include_file_symbols=True) ] assert {ref["kind"] for ref in references_to_typing} == {SymbolKind.File} # now include bodies references_to_typing = [ ref.symbol for ref in language_server.request_referencing_symbols( file_path, 4, 6, include_imports=False, include_file_symbols=True, include_body=True ) ] assert {ref["kind"] for ref in references_to_typing} == {SymbolKind.File} assert references_to_typing[0]["body"] ================================================ FILE: test/solidlsp/r/__init__.py ================================================ # Empty init file for R tests ================================================ FILE: test/solidlsp/r/test_r_basic.py ================================================ """ Basic tests for R Language Server integration """ import os from pathlib import Path import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language @pytest.mark.r class TestRLanguageServer: """Test basic functionality of the R language server.""" @pytest.mark.parametrize("language_server", [Language.R], indirect=True) @pytest.mark.parametrize("repo_path", [Language.R], indirect=True) def test_server_initialization(self, language_server: SolidLanguageServer, repo_path: Path): """Test that the R language server initializes properly.""" assert language_server is not None assert language_server.language_id == "r" assert language_server.is_running() assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve() @pytest.mark.parametrize("language_server", [Language.R], indirect=True) def test_symbol_retrieval(self, language_server: SolidLanguageServer): """Test R document symbol extraction.""" all_symbols, _root_symbols = language_server.request_document_symbols(os.path.join("R", "utils.R")).get_all_symbols_and_roots() # Should find the three exported functions function_symbols = [s for s in all_symbols if s.get("kind") == 12] # Function kind assert len(function_symbols) >= 3 # Check that we found the expected functions function_names = {s.get("name") for s in function_symbols} expected_functions = {"calculate_mean", "process_data", "create_data_frame"} assert expected_functions.issubset(function_names), f"Expected functions {expected_functions} but found {function_names}" @pytest.mark.parametrize("language_server", [Language.R], indirect=True) def test_find_definition_across_files(self, language_server: SolidLanguageServer): """Test finding function definitions across files.""" analysis_file = os.path.join("examples", "analysis.R") # In analysis.R line 7: create_data_frame(n = 50) # The function create_data_frame is defined in R/utils.R # Find definition of create_data_frame function call (0-indexed: line 6) definition_location_list = language_server.request_definition(analysis_file, 6, 17) # cursor on 'create_data_frame' assert definition_location_list, f"Expected non-empty definition_location_list but got {definition_location_list=}" assert len(definition_location_list) >= 1 definition_location = definition_location_list[0] assert definition_location["uri"].endswith("utils.R") # Definition should be around line 37 (0-indexed: 36) where create_data_frame is defined assert definition_location["range"]["start"]["line"] >= 35 @pytest.mark.parametrize("language_server", [Language.R], indirect=True) def test_find_references_across_files(self, language_server: SolidLanguageServer): """Test finding function references across files.""" analysis_file = os.path.join("examples", "analysis.R") # Test from usage side: find references to calculate_mean from its usage in analysis.R # In analysis.R line 13: calculate_mean(clean_data$value) # calculate_mean function call is at line 13 (0-indexed: line 12) references = language_server.request_references(analysis_file, 12, 15) # cursor on 'calculate_mean' assert references, f"Expected non-empty references for calculate_mean but got {references=}" # Must find the definition in utils.R (cross-file reference) reference_files = [ref["uri"] for ref in references] assert any(uri.endswith("utils.R") for uri in reference_files), "Cross-file reference to definition in utils.R not found" # Verify we actually found the right location in utils.R utils_refs = [ref for ref in references if ref["uri"].endswith("utils.R")] assert len(utils_refs) >= 1, "Should find at least one reference in utils.R" utils_ref = utils_refs[0] # Should be around line 6 where calculate_mean is defined (0-indexed: line 5) assert ( utils_ref["range"]["start"]["line"] == 5 ), f"Expected reference at line 5 in utils.R, got line {utils_ref['range']['start']['line']}" def test_file_matching(self): """Test that R files are properly matched.""" from solidlsp.ls_config import Language matcher = Language.R.get_source_fn_matcher() assert matcher.is_relevant_filename("script.R") assert matcher.is_relevant_filename("analysis.r") assert not matcher.is_relevant_filename("script.py") assert not matcher.is_relevant_filename("README.md") def test_r_language_enum(self): """Test R language enum value.""" assert Language.R == "r" assert str(Language.R) == "r" ================================================ FILE: test/solidlsp/rego/test_rego_basic.py ================================================ """Tests for Rego language server (Regal) functionality.""" import os import pytest from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils @pytest.mark.rego class TestRegoLanguageServer: """Test Regal language server functionality for Rego.""" @pytest.mark.parametrize("language_server", [Language.REGO], indirect=True) def test_request_document_symbols_authz(self, language_server: SolidLanguageServer) -> None: """Test that document symbols can be retrieved from authz.rego.""" file_path = os.path.join("policies", "authz.rego") symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() assert symbols is not None assert len(symbols) > 0 # Extract symbol names symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols symbol_names = {sym.get("name") for sym in symbol_list if isinstance(sym, dict)} # Verify specific Rego rules/functions are found assert "allow" in symbol_names, "allow rule not found" assert "allow_read" in symbol_names, "allow_read rule not found" assert "is_admin" in symbol_names, "is_admin function not found" assert "admin_roles" in symbol_names, "admin_roles constant not found" @pytest.mark.parametrize("language_server", [Language.REGO], indirect=True) def test_request_document_symbols_helpers(self, language_server: SolidLanguageServer) -> None: """Test that document symbols can be retrieved from helpers.rego.""" file_path = os.path.join("utils", "helpers.rego") symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() assert symbols is not None assert len(symbols) > 0 # Extract symbol names symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols symbol_names = {sym.get("name") for sym in symbol_list if isinstance(sym, dict)} # Verify specific helper functions are found assert "is_valid_user" in symbol_names, "is_valid_user function not found" assert "is_valid_email" in symbol_names, "is_valid_email function not found" assert "is_valid_username" in symbol_names, "is_valid_username function not found" @pytest.mark.parametrize("language_server", [Language.REGO], indirect=True) def test_find_symbol_full_tree(self, language_server: SolidLanguageServer) -> None: """Test finding symbols across entire workspace using symbol tree.""" symbols = language_server.request_full_symbol_tree() # Use SymbolUtils to check for expected symbols assert SymbolUtils.symbol_tree_contains_name(symbols, "allow"), "allow rule not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "is_valid_user"), "is_valid_user function not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "is_admin"), "is_admin function not found in symbol tree" @pytest.mark.parametrize("language_server", [Language.REGO], indirect=True) def test_request_definition_within_file(self, language_server: SolidLanguageServer) -> None: """Test go-to-definition for symbols within the same file.""" # In authz.rego, check_permission references admin_roles file_path = os.path.join("policies", "authz.rego") # Get document symbols symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols # Find the is_admin symbol which references admin_roles is_admin_symbol = next((s for s in symbol_list if s.get("name") == "is_admin"), None) assert is_admin_symbol is not None, "is_admin symbol should always be found in authz.rego" assert "range" in is_admin_symbol, "is_admin symbol should have a range" # Request definition from within is_admin (line 25, which references admin_roles at line 21) # Line 25 is: admin_roles[_] == user.role line = is_admin_symbol["range"]["start"]["line"] + 1 char = 4 # Position at "admin_roles" definitions = language_server.request_definition(file_path, line, char) assert definitions is not None and len(definitions) > 0, "Should find definition for admin_roles" # Verify the definition points to admin_roles in the same file assert any("authz.rego" in defn.get("relativePath", "") for defn in definitions), "Definition should be in authz.rego" @pytest.mark.parametrize("language_server", [Language.REGO], indirect=True) def test_request_definition_across_files(self, language_server: SolidLanguageServer) -> None: """Test go-to-definition for symbols across files (cross-file references).""" # In authz.rego line 11, the allow rule calls utils.is_valid_user # This function is defined in utils/helpers.rego file_path = os.path.join("policies", "authz.rego") # Get document symbols symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols # Find the allow symbol allow_symbol = next((s for s in symbol_list if s.get("name") == "allow"), None) assert allow_symbol is not None, "allow symbol should always be found in authz.rego" assert "range" in allow_symbol, "allow symbol should have a range" # Request definition from line 11 where utils.is_valid_user is called # Line 11: utils.is_valid_user(input.user) line = 10 # 0-indexed, so line 11 in file is line 10 in LSP char = 7 # Position at "is_valid_user" in "utils.is_valid_user" definitions = language_server.request_definition(file_path, line, char) assert definitions is not None and len(definitions) > 0, "Should find cross-file definition for is_valid_user" # Verify the definition points to helpers.rego (cross-file) assert any( "helpers.rego" in defn.get("relativePath", "") for defn in definitions ), "Definition should be in utils/helpers.rego (cross-file reference)" @pytest.mark.parametrize("language_server", [Language.REGO], indirect=True) def test_find_symbols_validation(self, language_server: SolidLanguageServer) -> None: """Test finding symbols in validation.rego which has imports.""" file_path = os.path.join("policies", "validation.rego") symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() assert symbols is not None assert len(symbols) > 0 # Extract symbol names symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols symbol_names = {sym.get("name") for sym in symbol_list if isinstance(sym, dict)} # Verify expected symbols assert "validate_user_input" in symbol_names, "validate_user_input rule not found" assert "has_valid_credentials" in symbol_names, "has_valid_credentials function not found" assert "validate_request" in symbol_names, "validate_request rule not found" ================================================ FILE: test/solidlsp/ruby/test_ruby_basic.py ================================================ import os from pathlib import Path import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils @pytest.mark.ruby class TestRubyLanguageServer: @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) def test_find_symbol(self, language_server: SolidLanguageServer) -> None: symbols = language_server.request_full_symbol_tree() assert SymbolUtils.symbol_tree_contains_name(symbols, "DemoClass"), "DemoClass not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "helper_function"), "helper_function not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "print_value"), "print_value not found in symbol tree" @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) def test_find_referencing_symbols(self, language_server: SolidLanguageServer) -> None: file_path = os.path.join("main.rb") symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() helper_symbol = None for sym in symbols[0]: if sym.get("name") == "helper_function": helper_symbol = sym break print(helper_symbol) assert helper_symbol is not None, "Could not find 'helper_function' symbol in main.rb" @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) @pytest.mark.parametrize("repo_path", [Language.RUBY], indirect=True) def test_find_definition_across_files(self, language_server: SolidLanguageServer, repo_path: Path) -> None: # Test finding Calculator.add method definition from line 17: Calculator.new.add(demo.value, 10) definition_location_list = language_server.request_definition( str(repo_path / "main.rb"), 16, 17 ) # add method at line 17 (0-indexed 16), position 17 assert len(definition_location_list) == 1 definition_location = definition_location_list[0] print(f"Found definition: {definition_location}") assert definition_location["uri"].endswith("lib.rb") assert definition_location["range"]["start"]["line"] == 1 # add method on line 2 (0-indexed 1) ================================================ FILE: test/solidlsp/ruby/test_ruby_symbol_retrieval.py ================================================ """ Tests for the Ruby language server symbol-related functionality. These tests focus on the following methods: - request_containing_symbol - request_referencing_symbols - request_defining_symbol - request_document_symbols integration """ import os import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind pytestmark = pytest.mark.ruby class TestRubyLanguageServerSymbols: """Test the Ruby language server's symbol-related functionality.""" @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) def test_request_containing_symbol_method(self, language_server: SolidLanguageServer) -> None: """Test request_containing_symbol for a method.""" # Test for a position inside the create_user method file_path = os.path.join("services.rb") # Look for a position inside the create_user method body containing_symbol = language_server.request_containing_symbol(file_path, 11, 10, include_body=True) # Verify that we found the containing symbol assert containing_symbol is not None, "Should find containing symbol for method position" assert containing_symbol["name"] == "create_user", f"Expected 'create_user', got '{containing_symbol['name']}'" assert ( containing_symbol["kind"] == SymbolKind.Method.value ), f"Expected Method kind ({SymbolKind.Method.value}), got {containing_symbol['kind']}" # Verify location information assert "location" in containing_symbol, "Containing symbol should have location information" location = containing_symbol["location"] assert "range" in location, "Location should contain range information" assert "start" in location["range"], "Range should have start position" assert "end" in location["range"], "Range should have end position" # Verify container information if "containerName" in containing_symbol: assert containing_symbol["containerName"] in [ "Services::UserService", "UserService", ], f"Expected UserService container, got '{containing_symbol['containerName']}'" # Verify body content if available if "body" in containing_symbol: body = containing_symbol["body"].get_text() assert "def create_user" in body, "Method body should contain method definition" assert len(body.strip()) > 0, "Method body should not be empty" @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) def test_request_containing_symbol_class(self, language_server: SolidLanguageServer) -> None: """Test request_containing_symbol for a class.""" # Test for a position inside the UserService class but outside any method file_path = os.path.join("services.rb") # Line around the class definition containing_symbol = language_server.request_containing_symbol(file_path, 5, 5) # Verify that we found the containing symbol assert containing_symbol is not None, "Should find containing symbol for class position" assert containing_symbol["name"] == "UserService", f"Expected 'UserService', got '{containing_symbol['name']}'" assert ( containing_symbol["kind"] == SymbolKind.Class.value ), f"Expected Class kind ({SymbolKind.Class.value}), got {containing_symbol['kind']}" # Verify location information exists assert "location" in containing_symbol, "Class symbol should have location information" location = containing_symbol["location"] assert "range" in location, "Location should contain range" assert "start" in location["range"] and "end" in location["range"], "Range should have start and end positions" # Verify the class is properly nested in the Services module if "containerName" in containing_symbol: assert ( containing_symbol["containerName"] == "Services" ), f"Expected 'Services' as container, got '{containing_symbol['containerName']}'" @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) def test_request_containing_symbol_module(self, language_server: SolidLanguageServer) -> None: """Test request_containing_symbol for a module context.""" # Test that we can find the Services module in document symbols file_path = os.path.join("services.rb") symbols, _roots = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() # Verify Services module appears in document symbols services_module = None for symbol in symbols: if symbol.get("name") == "Services" and symbol.get("kind") == SymbolKind.Module: services_module = symbol break assert services_module is not None, "Services module not found in document symbols" # Test that UserService class has Services as container # Position inside UserService class containing_symbol = language_server.request_containing_symbol(file_path, 4, 8) assert containing_symbol is not None assert containing_symbol["name"] == "UserService" assert containing_symbol["kind"] == SymbolKind.Class # Verify the module context is preserved in containerName (if supported by the language server) # ruby-lsp doesn't provide containerName, but Solargraph does if "containerName" in containing_symbol: assert containing_symbol.get("containerName") == "Services" @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) def test_request_containing_symbol_nested_class(self, language_server: SolidLanguageServer) -> None: """Test request_containing_symbol with nested classes.""" # Test for a position inside a nested class method file_path = os.path.join("nested.rb") # Position inside NestedClass.find_me method containing_symbol = language_server.request_containing_symbol(file_path, 20, 10) # Verify that we found the innermost containing symbol assert containing_symbol is not None assert containing_symbol["name"] == "find_me" assert containing_symbol["kind"] == SymbolKind.Method @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) def test_request_containing_symbol_none(self, language_server: SolidLanguageServer) -> None: """Test request_containing_symbol for a position with no containing symbol.""" # Test for a position outside any class/method (e.g., in requires) file_path = os.path.join("services.rb") # Line 1 is a require statement, not inside any class or method containing_symbol = language_server.request_containing_symbol(file_path, 1, 5) # Should return None or an empty dictionary assert containing_symbol is None or containing_symbol == {} @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) def test_request_referencing_symbols_method(self, language_server: SolidLanguageServer) -> None: """Test request_referencing_symbols for a method.""" # Test referencing symbols for create_user method file_path = os.path.join("services.rb") # Line containing the create_user method definition symbols, _roots = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() create_user_symbol = None # Find create_user method in the document symbols (Ruby returns flat list) for symbol in symbols: if symbol.get("name") == "create_user": create_user_symbol = symbol break if not create_user_symbol or "selectionRange" not in create_user_symbol: pytest.skip("create_user symbol or its selectionRange not found") sel_start = create_user_symbol["selectionRange"]["start"] ref_symbols = [ ref.symbol for ref in language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"]) ] # We might not have references in our simple test setup, so just verify structure for symbol in ref_symbols: assert "name" in symbol assert "kind" in symbol @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) def test_request_referencing_symbols_class(self, language_server: SolidLanguageServer) -> None: """Test request_referencing_symbols for a class.""" # Test referencing symbols for User class file_path = os.path.join("models.rb") # Find User class in document symbols symbols, _roots = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() user_symbol = None for symbol in symbols: if symbol.get("name") == "User": user_symbol = symbol break if not user_symbol or "selectionRange" not in user_symbol: pytest.skip("User symbol or its selectionRange not found") sel_start = user_symbol["selectionRange"]["start"] ref_symbols = [ ref.symbol for ref in language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"]) ] # Verify structure of referencing symbols for symbol in ref_symbols: assert "name" in symbol assert "kind" in symbol if "location" in symbol and "range" in symbol["location"]: assert "start" in symbol["location"]["range"] assert "end" in symbol["location"]["range"] @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) def test_request_defining_symbol_variable(self, language_server: SolidLanguageServer) -> None: """Test request_defining_symbol for a variable usage.""" # Test finding the definition of a variable in a method file_path = os.path.join("services.rb") # Look for @users variable usage defining_symbol = language_server.request_defining_symbol(file_path, 12, 10) # This test might fail if the language server doesn't support it well if defining_symbol is not None: assert "name" in defining_symbol assert "kind" in defining_symbol @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) def test_request_defining_symbol_class(self, language_server: SolidLanguageServer) -> None: """Test request_defining_symbol for a class reference.""" # Test finding the definition of the User class used in services file_path = os.path.join("services.rb") # Line that references User class defining_symbol = language_server.request_defining_symbol(file_path, 11, 15) # This might not work perfectly in all Ruby language servers if defining_symbol is not None: assert "name" in defining_symbol # The name might be "User" or the method that contains it assert defining_symbol.get("name") is not None @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) def test_request_defining_symbol_none(self, language_server: SolidLanguageServer) -> None: """Test request_defining_symbol for a position with no symbol.""" # Test for a position with no symbol (e.g., whitespace or comment) file_path = os.path.join("services.rb") # Line 3 is likely a blank line or comment defining_symbol = language_server.request_defining_symbol(file_path, 3, 0) # Should return None for positions with no symbol assert defining_symbol is None or defining_symbol == {} @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) def test_request_defining_symbol_nested_class(self, language_server: SolidLanguageServer) -> None: """Test request_defining_symbol for nested class access.""" # Test finding definition of NestedClass file_path = os.path.join("nested.rb") # Position where NestedClass is referenced defining_symbol = language_server.request_defining_symbol(file_path, 44, 25) # This is challenging for many language servers if defining_symbol is not None: assert "name" in defining_symbol assert defining_symbol.get("name") in ["NestedClass", "OuterClass"] @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) def test_symbol_methods_integration(self, language_server: SolidLanguageServer) -> None: """Test the integration between different symbol-related methods.""" file_path = os.path.join("models.rb") # Step 1: Find a method we know exists containing_symbol = language_server.request_containing_symbol(file_path, 8, 5) # inside initialize method if containing_symbol is not None: assert containing_symbol["name"] == "initialize" # Step 2: Get the defining symbol for the same position defining_symbol = language_server.request_defining_symbol(file_path, 8, 5) if defining_symbol is not None: assert defining_symbol["name"] == "initialize" # Step 3: Verify that they refer to the same symbol type assert defining_symbol["kind"] == containing_symbol["kind"] @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) def test_symbol_tree_structure_basic(self, language_server: SolidLanguageServer) -> None: """Test that the symbol tree structure includes Ruby symbols.""" # Get all symbols in the test repository repo_structure = language_server.request_full_symbol_tree() assert len(repo_structure) >= 1 # Look for our Ruby files in the structure found_ruby_files = False for root in repo_structure: if "children" in root: for child in root["children"]: if child.get("name") in ["models", "services", "nested"]: found_ruby_files = True break # We should find at least some Ruby files in the symbol tree assert found_ruby_files, "Ruby files not found in symbol tree" @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) def test_document_symbols_detailed(self, language_server: SolidLanguageServer) -> None: """Test document symbols for detailed Ruby file structure.""" file_path = os.path.join("models.rb") symbols, roots = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() # Verify we have symbols assert len(symbols) > 0 or len(roots) > 0 # Look for expected class names symbol_names = set() all_symbols = symbols if symbols else roots for symbol in all_symbols: symbol_names.add(symbol.get("name")) # Add children names too if "children" in symbol: for child in symbol["children"]: symbol_names.add(child.get("name")) # We should find at least some of our defined classes/methods expected_symbols = {"User", "Item", "Order", "ItemHelpers"} found_symbols = symbol_names.intersection(expected_symbols) assert len(found_symbols) > 0, f"Expected symbols not found. Found: {symbol_names}" @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) def test_module_and_class_hierarchy(self, language_server: SolidLanguageServer) -> None: """Test symbol detection for modules and nested class hierarchies.""" file_path = os.path.join("nested.rb") symbols, roots = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() # Verify we can detect the nested structure assert len(symbols) > 0 or len(roots) > 0 # Look for OuterClass and its nested elements symbol_names = set() all_symbols = symbols if symbols else roots for symbol in all_symbols: symbol_names.add(symbol.get("name")) if "children" in symbol: for child in symbol["children"]: symbol_names.add(child.get("name")) # Check deeply nested too if "children" in child: for grandchild in child["children"]: symbol_names.add(grandchild.get("name")) # Should find the outer class at minimum assert "OuterClass" in symbol_names, f"OuterClass not found in symbols: {symbol_names}" @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) def test_references_to_variables(self, language_server: SolidLanguageServer) -> None: """Test request_referencing_symbols for a variable with detailed verification.""" file_path = os.path.join("variables.rb") # Test references to @status variable in DataContainer class (around line 9) ref_symbols = [ref.symbol for ref in language_server.request_referencing_symbols(file_path, 8, 4)] if len(ref_symbols) > 0: # Verify we have references assert len(ref_symbols) > 0, "Should find references to @status variable" # Check that we have location information ref_with_locations = [ref for ref in ref_symbols if "location" in ref and "range" in ref["location"]] assert len(ref_with_locations) > 0, "References should include location information" # Verify line numbers are reasonable (should be within the file) ref_lines = [ref["location"]["range"]["start"]["line"] for ref in ref_with_locations] assert all(line >= 0 for line in ref_lines), "Reference lines should be valid" # Check for specific reference locations we expect # Lines where @status is modified/accessed expected_line_ranges = [(20, 40), (45, 70)] # Approximate ranges found_in_expected_range = any(any(start <= line <= end for start, end in expected_line_ranges) for line in ref_lines) assert found_in_expected_range, f"Expected references in ranges {expected_line_ranges}, found lines: {ref_lines}" @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) def test_request_referencing_symbols_parameter(self, language_server: SolidLanguageServer) -> None: """Test request_referencing_symbols for a method parameter.""" # Test referencing symbols for a method parameter in get_user method file_path = os.path.join("services.rb") # Find get_user method and test parameter references symbols, _roots = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() get_user_symbol = None for symbol in symbols: if symbol.get("name") == "get_user": get_user_symbol = symbol break if not get_user_symbol or "selectionRange" not in get_user_symbol: pytest.skip("get_user symbol or its selectionRange not found") # Test parameter reference within method body method_start_line = get_user_symbol["selectionRange"]["start"]["line"] ref_symbols = [ ref.symbol for ref in language_server.request_referencing_symbols(file_path, method_start_line + 1, 10) # Position within method body ] # Verify structure of referencing symbols for symbol in ref_symbols: assert "name" in symbol, "Symbol should have name" assert "kind" in symbol, "Symbol should have kind" if "location" in symbol and "range" in symbol["location"]: range_info = symbol["location"]["range"] assert "start" in range_info, "Range should have start" assert "end" in range_info, "Range should have end" # Verify line number is valid (references can be before method definition too) assert range_info["start"]["line"] >= 0, "Reference line should be valid" @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) def test_request_referencing_symbols_none(self, language_server: SolidLanguageServer) -> None: """Test request_referencing_symbols for a position with no symbol.""" # Test for a position with no symbol (comment or blank line) file_path = os.path.join("services.rb") # Try multiple positions that should have no symbols test_positions = [(1, 0), (2, 0)] # Comment/require lines for line, char in test_positions: try: ref_symbols = [ref.symbol for ref in language_server.request_referencing_symbols(file_path, line, char)] # If we get here, make sure we got an empty result or minimal results if ref_symbols: # Some language servers might return minimal info, verify it's reasonable assert len(ref_symbols) <= 3, f"Expected few/no references at line {line}, got {len(ref_symbols)}" except Exception as e: # Some language servers throw exceptions for invalid positions, which is acceptable assert ( "symbol" in str(e).lower() or "position" in str(e).lower() or "reference" in str(e).lower() ), f"Exception should be related to symbol/position/reference issues, got: {e}" @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) def test_request_dir_overview(self, language_server: SolidLanguageServer) -> None: """Test that request_dir_overview returns correct symbol information for files in a directory.""" # Get overview of the test repo directory overview = language_server.request_dir_overview(".") # Verify that we have entries for our main files expected_files = ["services.rb", "models.rb", "variables.rb", "nested.rb"] found_files = [] for file_path in overview.keys(): for expected in expected_files: if expected in file_path: found_files.append(expected) break assert len(found_files) >= 2, f"Should find at least 2 expected files, found: {found_files}" # Test specific symbols from services.rb if it exists services_file_key = None for file_path in overview.keys(): if "services.rb" in file_path: services_file_key = file_path break if services_file_key: services_symbols = overview[services_file_key] assert len(services_symbols) > 0, "services.rb should have symbols" # Check for expected symbols with detailed verification symbol_names = [s[0] for s in services_symbols if isinstance(s, tuple) and len(s) > 0] if not symbol_names: # If not tuples, try different format symbol_names = [s.get("name") for s in services_symbols if hasattr(s, "get")] expected_symbols = ["Services", "UserService", "ItemService"] found_expected = [name for name in expected_symbols if name in symbol_names] assert len(found_expected) >= 1, f"Should find at least one expected symbol, found: {found_expected} in {symbol_names}" @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) def test_request_document_overview(self, language_server: SolidLanguageServer) -> None: """Test that request_document_overview returns correct symbol information for a file.""" # Get overview of the user_management.rb file file_path = os.path.join("examples", "user_management.rb") overview = language_server.request_document_overview(file_path) # Verify that we have symbol information assert len(overview) > 0, "Document overview should contain symbols" # Look for expected symbols from the file symbol_names = set() for s_info in overview: if isinstance(s_info, tuple) and len(s_info) > 0: symbol_names.add(s_info[0]) elif hasattr(s_info, "get"): symbol_names.add(s_info.get("name")) elif isinstance(s_info, str): symbol_names.add(s_info) # We should find some of our defined classes/methods expected_symbols = {"UserStats", "UserManager", "process_user_data", "main"} found_symbols = symbol_names.intersection(expected_symbols) assert len(found_symbols) > 0, f"Expected to find some symbols from {expected_symbols}, found: {symbol_names}" @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) def test_request_containing_symbol_variable(self, language_server: SolidLanguageServer) -> None: """Test request_containing_symbol where the target is a variable.""" # Test for a position inside a variable definition or usage file_path = os.path.join("variables.rb") # Position around a variable assignment (e.g., @status = "pending") containing_symbol = language_server.request_containing_symbol(file_path, 10, 5) # Verify that we found a containing symbol (likely the method or class) if containing_symbol is not None: assert "name" in containing_symbol, "Containing symbol should have a name" assert "kind" in containing_symbol, "Containing symbol should have a kind" # The containing symbol should be a method, class, or similar construct expected_kinds = [SymbolKind.Method, SymbolKind.Class, SymbolKind.Function, SymbolKind.Constructor] assert containing_symbol["kind"] in [ k.value for k in expected_kinds ], f"Expected containing symbol to be method/class/function, got kind: {containing_symbol['kind']}" @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) def test_request_containing_symbol_function(self, language_server: SolidLanguageServer) -> None: """Test request_containing_symbol for a function (not method).""" # Test for a position inside a standalone function file_path = os.path.join("variables.rb") # Position inside the demonstrate_variable_usage function containing_symbol = language_server.request_containing_symbol(file_path, 100, 10) if containing_symbol is not None: assert containing_symbol["name"] in [ "demonstrate_variable_usage", "main", ], f"Expected function name, got: {containing_symbol['name']}" assert containing_symbol["kind"] in [ SymbolKind.Function.value, SymbolKind.Method.value, ], f"Expected function or method kind, got: {containing_symbol['kind']}" @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) def test_request_containing_symbol_nested(self, language_server: SolidLanguageServer) -> None: """Test request_containing_symbol with nested scopes.""" # Test for a position inside a method which is inside a class file_path = os.path.join("services.rb") # Position inside create_user method within UserService class containing_symbol = language_server.request_containing_symbol(file_path, 12, 15) # Verify that we found the innermost containing symbol (the method) assert containing_symbol is not None assert containing_symbol["name"] == "create_user" assert containing_symbol["kind"] == SymbolKind.Method # Verify the container context is preserved if "containerName" in containing_symbol: assert "UserService" in containing_symbol["containerName"] @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) def test_symbol_tree_structure_subdir(self, language_server: SolidLanguageServer) -> None: """Test that the symbol tree structure correctly handles subdirectories.""" # Get symbols within the examples subdirectory examples_structure = language_server.request_full_symbol_tree(within_relative_path="examples") if len(examples_structure) > 0: # Should find the examples directory structure assert len(examples_structure) >= 1, "Should find examples directory structure" # Look for the user_management file in the structure found_user_management = False for root in examples_structure: if "children" in root: for child in root["children"]: if "user_management" in child.get("name", ""): found_user_management = True # Verify the structure includes symbol information if "children" in child: child_names = [c.get("name") for c in child["children"]] expected_names = ["UserStats", "UserManager", "process_user_data"] found_expected = [name for name in expected_names if name in child_names] assert ( len(found_expected) > 0 ), f"Should find symbols in user_management, expected {expected_names}, found {child_names}" break if not found_user_management: pytest.skip("user_management file not found in examples subdirectory structure") @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) def test_request_defining_symbol_imported_class(self, language_server: SolidLanguageServer) -> None: """Test request_defining_symbol for an imported/required class.""" # Test finding the definition of a class used from another file file_path = os.path.join("examples", "user_management.rb") # Position where Services::UserService is referenced defining_symbol = language_server.request_defining_symbol(file_path, 25, 20) # This might not work perfectly in all Ruby language servers due to require complexity if defining_symbol is not None: assert "name" in defining_symbol # The defining symbol should relate to UserService or Services # The defining symbol should relate to UserService, Services, or the containing class # Different language servers may resolve this differently expected_names = ["UserService", "Services", "new", "UserManager"] assert defining_symbol.get("name") in expected_names, f"Expected one of {expected_names}, got: {defining_symbol.get('name')}" @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) def test_request_defining_symbol_method_call(self, language_server: SolidLanguageServer) -> None: """Test request_defining_symbol for a method call.""" # Test finding the definition of a method being called file_path = os.path.join("examples", "user_management.rb") # Position at a method call like create_user defining_symbol = language_server.request_defining_symbol(file_path, 30, 15) # Verify that we can find method definitions if defining_symbol is not None: assert "name" in defining_symbol assert "kind" in defining_symbol # Should be a method or constructor assert defining_symbol.get("kind") in [SymbolKind.Method.value, SymbolKind.Constructor.value, SymbolKind.Function.value] @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) def test_request_defining_symbol_nested_function(self, language_server: SolidLanguageServer) -> None: """Test request_defining_symbol for a nested function or block.""" # Test finding definition within nested contexts file_path = os.path.join("nested.rb") # Position inside or referencing nested functionality defining_symbol = language_server.request_defining_symbol(file_path, 15, 10) # This is challenging for many language servers if defining_symbol is not None: assert "name" in defining_symbol assert "kind" in defining_symbol # Could be method, function, or variable depending on implementation valid_kinds = [SymbolKind.Method.value, SymbolKind.Function.value, SymbolKind.Variable.value, SymbolKind.Class.value] assert defining_symbol.get("kind") in valid_kinds @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) def test_containing_symbol_of_var_is_file(self, language_server: SolidLanguageServer) -> None: """Test that the containing symbol of a file-level variable is handled appropriately.""" # Test behavior with file-level variables or constants file_path = os.path.join("variables.rb") # Position at file-level variable/constant containing_symbol = language_server.request_containing_symbol(file_path, 5, 5) # Different language servers handle file-level symbols differently # Some return None, others return file-level containers if containing_symbol is not None: # If we get a symbol, verify its structure assert "name" in containing_symbol assert "kind" in containing_symbol ================================================ FILE: test/solidlsp/rust/test_rust_2024_edition.py ================================================ import os from collections.abc import Iterator from pathlib import Path import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils from test.conftest import start_ls_context @pytest.fixture(scope="class") def rust_language_server() -> Iterator[SolidLanguageServer]: """Set up the test class with the Rust 2024 edition test repository.""" test_repo_2024_path = TestRust2024EditionLanguageServer.test_repo_2024_path if not test_repo_2024_path.exists(): pytest.skip("Rust 2024 edition test repository not found") # Create and start the language server for the 2024 edition repo with start_ls_context(Language.RUST, str(test_repo_2024_path)) as ls: yield ls @pytest.mark.rust class TestRust2024EditionLanguageServer: test_repo_2024_path = Path(__file__).parent.parent.parent / "resources" / "repos" / "rust" / "test_repo_2024" def test_find_references_raw(self, rust_language_server) -> None: # Test finding references to the 'add' function defined in main.rs file_path = os.path.join("src", "main.rs") symbols = rust_language_server.request_document_symbols(file_path).get_all_symbols_and_roots() add_symbol = None for sym in symbols[0]: if sym.get("name") == "add": add_symbol = sym break assert add_symbol is not None, "Could not find 'add' function symbol in main.rs" sel_start = add_symbol["selectionRange"]["start"] refs = rust_language_server.request_references(file_path, sel_start["line"], sel_start["character"]) # The add function should be referenced within main.rs itself (in the main function) assert any("main.rs" in ref.get("relativePath", "") for ref in refs), "main.rs should reference add function" def test_find_symbol(self, rust_language_server) -> None: symbols = rust_language_server.request_full_symbol_tree() assert SymbolUtils.symbol_tree_contains_name(symbols, "main"), "main function not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "add"), "add function not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "multiply"), "multiply function not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "Calculator"), "Calculator struct not found in symbol tree" def test_find_referencing_symbols_multiply(self, rust_language_server) -> None: # Find references to 'multiply' function defined in lib.rs file_path = os.path.join("src", "lib.rs") symbols = rust_language_server.request_document_symbols(file_path).get_all_symbols_and_roots() multiply_symbol = None for sym in symbols[0]: if sym.get("name") == "multiply": multiply_symbol = sym break assert multiply_symbol is not None, "Could not find 'multiply' function symbol in lib.rs" sel_start = multiply_symbol["selectionRange"]["start"] refs = rust_language_server.request_references(file_path, sel_start["line"], sel_start["character"]) # The multiply function exists but may not be referenced anywhere, which is fine # This test just verifies we can find the symbol and request references without error assert isinstance(refs, list), "Should return a list of references (even if empty)" def test_find_calculator_struct_and_impl(self, rust_language_server) -> None: # Test finding the Calculator struct and its impl block file_path = os.path.join("src", "lib.rs") symbols = rust_language_server.request_document_symbols(file_path).get_all_symbols_and_roots() # Find the Calculator struct calculator_struct = None calculator_impl = None for sym in symbols[0]: if sym.get("name") == "Calculator" and sym.get("kind") == 23: # Struct kind calculator_struct = sym elif sym.get("name") == "Calculator" and sym.get("kind") == 11: # Interface/Impl kind calculator_impl = sym assert calculator_struct is not None, "Could not find 'Calculator' struct symbol in lib.rs" # The struct should have the 'result' field struct_children = calculator_struct.get("children", []) field_names = [child.get("name") for child in struct_children] assert "result" in field_names, "Calculator struct should have 'result' field" # Find the impl block and check its methods if calculator_impl is not None: impl_children = calculator_impl.get("children", []) method_names = [child.get("name") for child in impl_children] assert "new" in method_names, "Calculator impl should have 'new' method" assert "add" in method_names, "Calculator impl should have 'add' method" assert "get_result" in method_names, "Calculator impl should have 'get_result' method" def test_overview_methods(self, rust_language_server) -> None: symbols = rust_language_server.request_full_symbol_tree() assert SymbolUtils.symbol_tree_contains_name(symbols, "main"), "main missing from overview" assert SymbolUtils.symbol_tree_contains_name(symbols, "add"), "add missing from overview" assert SymbolUtils.symbol_tree_contains_name(symbols, "multiply"), "multiply missing from overview" assert SymbolUtils.symbol_tree_contains_name(symbols, "Calculator"), "Calculator missing from overview" def test_rust_2024_edition_specific(self) -> None: # Verify we're actually working with the 2024 edition repository cargo_toml_path = self.test_repo_2024_path / "Cargo.toml" assert cargo_toml_path.exists(), "Cargo.toml should exist in test repository" with open(cargo_toml_path) as f: content = f.read() assert 'edition = "2024"' in content, "Should be using Rust 2024 edition" ================================================ FILE: test/solidlsp/rust/test_rust_analyzer_detection.py ================================================ """ Tests for rust-analyzer detection logic. These tests describe the expected behavior of RustAnalyzer._ensure_rust_analyzer_installed(): 1. Rustup should be checked FIRST (avoids picking up incorrect PATH aliases) 2. Common installation locations (Homebrew, cargo, Scoop) should be checked as fallback 3. System PATH should be checked last (can pick up incompatible versions) 4. Error messages should list all searched locations 5. Windows-specific paths should be checked on Windows WHY these tests matter: - Users install rust-analyzer via Homebrew, cargo, Scoop, or system packages - not just rustup - macOS Homebrew installs to /opt/homebrew/bin (Apple Silicon) or /usr/local/bin (Intel) - Windows users install via Scoop, Chocolatey, or cargo - Detection failing means Serena is unusable for Rust, even when rust-analyzer is correctly installed - Without these tests, the detection logic can silently break for non-rustup users """ import os import pathlib import sys from unittest.mock import MagicMock, patch import pytest # Platform detection for skipping platform-specific tests IS_WINDOWS = sys.platform == "win32" IS_UNIX = sys.platform != "win32" class TestRustAnalyzerDetection: """Unit tests for rust-analyzer binary detection logic.""" @pytest.mark.rust def test_detect_from_path_as_last_resort(self): """ GIVEN rustup is not available AND rust-analyzer is NOT in common locations (Homebrew, cargo) AND rust-analyzer IS in system PATH WHEN _ensure_rust_analyzer_installed is called THEN it should return the path from shutil.which as last resort WHY: PATH is checked last to avoid picking up incorrect aliases. Users with rust-analyzer in PATH but not via rustup/common locations should still work. """ from solidlsp.language_servers.rust_analyzer import RustAnalyzer # Mock rustup to be unavailable with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): # Mock common locations to NOT exist with patch("os.path.isfile", return_value=False): # Mock PATH to have rust-analyzer with patch("shutil.which") as mock_which: mock_which.return_value = "/custom/bin/rust-analyzer" with patch("os.access", return_value=True): # Need isfile to return True for PATH result only def selective_isfile(path): return path == "/custom/bin/rust-analyzer" with patch("os.path.isfile", side_effect=selective_isfile): result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() assert result == "/custom/bin/rust-analyzer" mock_which.assert_called_with("rust-analyzer") @pytest.mark.rust @pytest.mark.skipif(IS_WINDOWS, reason="Homebrew paths only apply to macOS/Linux") def test_detect_from_homebrew_apple_silicon_path(self): """ GIVEN rustup is NOT available AND rust-analyzer is installed via Homebrew on Apple Silicon Mac AND it is NOT in PATH (shutil.which returns None) WHEN _ensure_rust_analyzer_installed is called THEN it should find /opt/homebrew/bin/rust-analyzer WHY: Apple Silicon Macs use /opt/homebrew/bin for Homebrew. This path should be checked as fallback when rustup is unavailable. """ from solidlsp.language_servers.rust_analyzer import RustAnalyzer def mock_isfile(path): return path == "/opt/homebrew/bin/rust-analyzer" def mock_access(path, mode): return path == "/opt/homebrew/bin/rust-analyzer" with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): with patch("shutil.which", return_value=None): with patch("os.path.isfile", side_effect=mock_isfile): with patch("os.access", side_effect=mock_access): result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() assert result == "/opt/homebrew/bin/rust-analyzer" @pytest.mark.rust @pytest.mark.skipif(IS_WINDOWS, reason="Homebrew paths only apply to macOS/Linux") def test_detect_from_homebrew_intel_path(self): """ GIVEN rustup is NOT available AND rust-analyzer is installed via Homebrew on Intel Mac AND it is NOT in PATH WHEN _ensure_rust_analyzer_installed is called THEN it should find /usr/local/bin/rust-analyzer WHY: Intel Macs use /usr/local/bin for Homebrew. Linux systems may also install to this location. """ from solidlsp.language_servers.rust_analyzer import RustAnalyzer def mock_isfile(path): return path == "/usr/local/bin/rust-analyzer" def mock_access(path, mode): return path == "/usr/local/bin/rust-analyzer" with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): with patch("shutil.which", return_value=None): with patch("os.path.isfile", side_effect=mock_isfile): with patch("os.access", side_effect=mock_access): result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() assert result == "/usr/local/bin/rust-analyzer" @pytest.mark.rust @pytest.mark.skipif(IS_WINDOWS, reason="Unix cargo path - Windows has separate test") def test_detect_from_cargo_install_path(self): """ GIVEN rustup is NOT available AND rust-analyzer is installed via `cargo install rust-analyzer` AND it is NOT in PATH or Homebrew locations WHEN _ensure_rust_analyzer_installed is called THEN it should find ~/.cargo/bin/rust-analyzer WHY: `cargo install rust-analyzer` is a common installation method. The binary lands in ~/.cargo/bin which may not be in PATH. """ from solidlsp.language_servers.rust_analyzer import RustAnalyzer cargo_path = os.path.expanduser("~/.cargo/bin/rust-analyzer") def mock_isfile(path): return path == cargo_path def mock_access(path, mode): return path == cargo_path with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): with patch("shutil.which", return_value=None): with patch("os.path.isfile", side_effect=mock_isfile): with patch("os.access", side_effect=mock_access): result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() assert result == cargo_path @pytest.mark.rust def test_detect_from_rustup_when_available(self): """ GIVEN rustup has rust-analyzer installed WHEN _ensure_rust_analyzer_installed is called THEN it should return the rustup path WHY: Rustup is checked FIRST to avoid picking up incorrect aliases from PATH. This ensures compatibility with the toolchain. """ from solidlsp.language_servers.rust_analyzer import RustAnalyzer with patch("shutil.which", return_value=None): with patch("os.path.isfile", return_value=False): with patch.object( RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value="/home/user/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin/rust-analyzer", ): result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() assert "rustup" in result or ".rustup" in result @pytest.mark.rust @pytest.mark.skipif(IS_WINDOWS, reason="Unix error messages - Windows has separate test") def test_error_message_lists_searched_locations_when_not_found(self): """ GIVEN rust-analyzer is NOT installed anywhere AND rustup is NOT installed WHEN _ensure_rust_analyzer_installed is called THEN it should raise RuntimeError with helpful message listing searched locations WHY: Users need to know WHERE Serena looked so they can fix their installation. The old error "Neither rust-analyzer nor rustup is installed" was unhelpful. """ from solidlsp.language_servers.rust_analyzer import RustAnalyzer with patch("shutil.which", return_value=None): with patch("os.path.isfile", return_value=False): with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): with patch.object(RustAnalyzer.DependencyProvider, "_get_rustup_version", return_value=None): with pytest.raises(RuntimeError) as exc_info: RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() error_message = str(exc_info.value) # Error should list the locations that were searched (Unix paths) assert "/opt/homebrew/bin/rust-analyzer" in error_message or "Homebrew" in error_message assert "cargo" in error_message.lower() or ".cargo/bin" in error_message # Error should suggest installation methods assert "rustup" in error_message.lower() or "Rustup" in error_message @pytest.mark.rust def test_detection_priority_prefers_rustup_over_path_and_common_locations(self): """ GIVEN rust-analyzer is available via rustup AND rust-analyzer also exists in PATH and common locations WHEN _ensure_rust_analyzer_installed is called THEN it should return the rustup version WHY: Rustup provides version management and ensures compatibility. Using PATH directly can pick up incorrect aliases or incompatible versions that cause LSP crashes (as discovered in CI failures). """ from solidlsp.language_servers.rust_analyzer import RustAnalyzer rustup_path = "/home/user/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin/rust-analyzer" # Rustup has rust-analyzer, PATH also has it, common locations also exist with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=rustup_path): with patch("shutil.which", return_value="/custom/path/rust-analyzer"): with patch("os.path.isfile", return_value=True): with patch("os.access", return_value=True): result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() # Should use rustup version, NOT PATH or common locations assert result == rustup_path @pytest.mark.rust @pytest.mark.skipif(IS_WINDOWS, reason="Uses Unix paths - Windows has different behavior") def test_skips_nonexecutable_files(self): """ GIVEN a file exists at a detection path but is NOT executable WHEN _ensure_rust_analyzer_installed is called THEN it should skip that path and continue checking others WHY: A non-executable file (e.g., broken symlink, wrong permissions) should not be returned as a valid rust-analyzer path. """ from solidlsp.language_servers.rust_analyzer import RustAnalyzer def mock_isfile(path): # File exists at Homebrew location but not executable return path == "/opt/homebrew/bin/rust-analyzer" def mock_access(path, mode): # Homebrew location exists but not executable if path == "/opt/homebrew/bin/rust-analyzer": return False # Cargo location is executable if path == os.path.expanduser("~/.cargo/bin/rust-analyzer"): return True return False def mock_isfile_for_cargo(path): return path in ["/opt/homebrew/bin/rust-analyzer", os.path.expanduser("~/.cargo/bin/rust-analyzer")] with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): with patch("shutil.which", return_value=None): with patch("os.path.isfile", side_effect=mock_isfile_for_cargo): with patch("os.access", side_effect=mock_access): result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() # Should skip non-executable Homebrew and use cargo assert result == os.path.expanduser("~/.cargo/bin/rust-analyzer") @pytest.mark.rust def test_detect_from_scoop_shims_path_on_windows(self): """ GIVEN rustup is NOT available AND rust-analyzer is installed via Scoop on Windows AND it is NOT in PATH WHEN _ensure_rust_analyzer_installed is called THEN it should find ~/scoop/shims/rust-analyzer.exe WHY: Scoop is a popular package manager for Windows. The binary lands in ~/scoop/shims which may not be in PATH. """ from solidlsp.language_servers.rust_analyzer import RustAnalyzer home = pathlib.Path.home() scoop_path = str(home / "scoop" / "shims" / "rust-analyzer.exe") def mock_isfile(path): return path == scoop_path def mock_access(path, mode): return path == scoop_path with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): with patch("platform.system", return_value="Windows"): with patch("shutil.which", return_value=None): with patch("os.path.isfile", side_effect=mock_isfile): with patch("os.access", side_effect=mock_access): result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() assert result == scoop_path @pytest.mark.rust def test_detect_from_cargo_path_on_windows(self): """ GIVEN rustup is NOT available AND rust-analyzer is installed via cargo on Windows AND it is NOT in PATH or Scoop locations WHEN _ensure_rust_analyzer_installed is called THEN it should find ~/.cargo/bin/rust-analyzer.exe WHY: `cargo install rust-analyzer` works on Windows. The binary has .exe extension and lands in ~/.cargo/bin. """ from solidlsp.language_servers.rust_analyzer import RustAnalyzer home = pathlib.Path.home() cargo_path = str(home / ".cargo" / "bin" / "rust-analyzer.exe") def mock_isfile(path): return path == cargo_path def mock_access(path, mode): return path == cargo_path with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): with patch("platform.system", return_value="Windows"): with patch("shutil.which", return_value=None): with patch("os.path.isfile", side_effect=mock_isfile): with patch("os.access", side_effect=mock_access): result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() assert result == cargo_path @pytest.mark.rust def test_windows_error_message_suggests_windows_package_managers(self): """ GIVEN rust-analyzer is NOT installed anywhere on Windows AND rustup is NOT installed WHEN _ensure_rust_analyzer_installed is called THEN it should raise RuntimeError with Windows-specific installation suggestions WHY: Windows users need Windows-specific package manager suggestions (Scoop, Chocolatey) instead of Homebrew/apt. """ from solidlsp.language_servers.rust_analyzer import RustAnalyzer with patch("platform.system", return_value="Windows"): with patch("shutil.which", return_value=None): with patch("os.path.isfile", return_value=False): with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): with patch.object(RustAnalyzer.DependencyProvider, "_get_rustup_version", return_value=None): with pytest.raises(RuntimeError) as exc_info: RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() error_message = str(exc_info.value) # Error should suggest Windows-specific package managers assert "Scoop" in error_message or "scoop" in error_message assert "Chocolatey" in error_message or "choco" in error_message # Should NOT suggest Homebrew on Windows assert "Homebrew" not in error_message and "brew" not in error_message @pytest.mark.rust def test_auto_install_via_rustup_when_not_found(self): """ GIVEN rust-analyzer is NOT installed anywhere AND rustup IS installed WHEN _ensure_rust_analyzer_installed is called AND rustup component add succeeds THEN it should return the rustup-installed path WHY: Serena should auto-install rust-analyzer via rustup when possible. This matches the original behavior and enables CI to work without pre-installing. """ from solidlsp.language_servers.rust_analyzer import RustAnalyzer with patch("shutil.which", return_value=None): with patch("os.path.isfile", return_value=False): with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup") as mock_rustup_path: # First call returns None (not installed), second returns path (after install) mock_rustup_path.side_effect = [None, "/home/user/.rustup/toolchains/stable/bin/rust-analyzer"] with patch.object(RustAnalyzer.DependencyProvider, "_get_rustup_version", return_value="1.70.0"): with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() assert result == "/home/user/.rustup/toolchains/stable/bin/rust-analyzer" mock_run.assert_called_once() assert mock_run.call_args[0][0] == ["rustup", "component", "add", "rust-analyzer"] @pytest.mark.rust def test_auto_install_failure_falls_through_to_common_paths(self): """ GIVEN rust-analyzer is NOT installed anywhere AND rustup IS installed WHEN _ensure_rust_analyzer_installed is called AND rustup component add FAILS THEN it should fall through to common paths and eventually raise helpful error WHY: The new resilient behavior tries all fallback options before failing. When rustup auto-install fails, we try common paths (Homebrew, cargo, etc.) as a last resort. This is more robust than failing immediately. The error message should still help users install rust-analyzer. """ from solidlsp.language_servers.rust_analyzer import RustAnalyzer with patch("shutil.which", return_value=None): with patch("os.path.isfile", return_value=False): with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): with patch.object(RustAnalyzer.DependencyProvider, "_get_rustup_version", return_value="1.70.0"): with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock( returncode=1, stdout="", stderr="error: component 'rust-analyzer' is not available" ) with pytest.raises(RuntimeError) as exc_info: RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() error_message = str(exc_info.value) # Error should provide helpful installation instructions assert "rust-analyzer is not installed" in error_message.lower() assert "rustup" in error_message.lower() # Should suggest rustup installation @pytest.mark.rust def test_auto_install_success_but_binary_not_found_falls_through(self): """ GIVEN rust-analyzer is NOT installed anywhere AND rustup IS installed WHEN _ensure_rust_analyzer_installed is called AND rustup component add SUCCEEDS BUT the binary is still not found after installation THEN it should fall through to common paths and eventually raise helpful error WHY: Even if rustup install reports success but binary isn't found, we try common paths as fallback. The final error provides installation guidance to help users resolve the issue. """ from solidlsp.language_servers.rust_analyzer import RustAnalyzer with patch("shutil.which", return_value=None): with patch("os.path.isfile", return_value=False): with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): with patch.object(RustAnalyzer.DependencyProvider, "_get_rustup_version", return_value="1.70.0"): with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") with pytest.raises(RuntimeError) as exc_info: RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() error_message = str(exc_info.value) # Error should indicate rust-analyzer is not available and provide install instructions assert "rust-analyzer is not installed" in error_message.lower() assert "searched locations" in error_message.lower() # Should show what was checked class TestRustAnalyzerDetectionIntegration: """ Integration tests that verify detection works on the current system. These tests are skipped if rust-analyzer is not installed. """ @pytest.mark.rust def test_detection_finds_installed_rust_analyzer(self): """ GIVEN rust-analyzer is installed on this system (via any method) WHEN _ensure_rust_analyzer_installed is called THEN it should return a valid path This test verifies the detection logic works end-to-end on the current system. """ import shutil from solidlsp.language_servers.rust_analyzer import RustAnalyzer # Skip if rust-analyzer is not installed at all if not shutil.which("rust-analyzer"): # Check common locations common_paths = [ "/opt/homebrew/bin/rust-analyzer", "/usr/local/bin/rust-analyzer", os.path.expanduser("~/.cargo/bin/rust-analyzer"), ] if not any(os.path.isfile(p) and os.access(p, os.X_OK) for p in common_paths): pytest.skip("rust-analyzer not installed on this system") result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() assert result is not None assert os.path.isfile(result) assert os.access(result, os.X_OK) ================================================ FILE: test/solidlsp/rust/test_rust_basic.py ================================================ import os import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils @pytest.mark.rust class TestRustLanguageServer: @pytest.mark.parametrize("language_server", [Language.RUST], indirect=True) def test_find_references_raw(self, language_server: SolidLanguageServer) -> None: # Directly test the request_references method for the add function file_path = os.path.join("src", "lib.rs") symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() add_symbol = None for sym in symbols[0]: if sym.get("name") == "add": add_symbol = sym break assert add_symbol is not None, "Could not find 'add' function symbol in lib.rs" sel_start = add_symbol["selectionRange"]["start"] refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) assert any( "main.rs" in ref.get("relativePath", "") for ref in refs ), "main.rs should reference add (raw, tried all positions in selectionRange)" @pytest.mark.parametrize("language_server", [Language.RUST], indirect=True) def test_find_symbol(self, language_server: SolidLanguageServer) -> None: symbols = language_server.request_full_symbol_tree() assert SymbolUtils.symbol_tree_contains_name(symbols, "main"), "main function not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "add"), "add function not found in symbol tree" # Add more as needed based on test_repo @pytest.mark.parametrize("language_server", [Language.RUST], indirect=True) def test_find_referencing_symbols(self, language_server: SolidLanguageServer) -> None: # Find references to 'add' defined in lib.rs, should be referenced from main.rs file_path = os.path.join("src", "lib.rs") symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() add_symbol = None for sym in symbols[0]: if sym.get("name") == "add": add_symbol = sym break assert add_symbol is not None, "Could not find 'add' function symbol in lib.rs" sel_start = add_symbol["selectionRange"]["start"] refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) assert any( "main.rs" in ref.get("relativePath", "") for ref in refs ), "main.rs should reference add (tried all positions in selectionRange)" @pytest.mark.parametrize("language_server", [Language.RUST], indirect=True) def test_overview_methods(self, language_server: SolidLanguageServer) -> None: symbols = language_server.request_full_symbol_tree() assert SymbolUtils.symbol_tree_contains_name(symbols, "main"), "main missing from overview" assert SymbolUtils.symbol_tree_contains_name(symbols, "add"), "add missing from overview" ================================================ FILE: test/solidlsp/scala/test_metals_db_utils.py ================================================ """ Unit tests for the metals_db_utils module. Tests the detection of Metals H2 database status and stale lock handling. """ import os from pathlib import Path from unittest.mock import MagicMock, patch import pytest from solidlsp.util.metals_db_utils import ( MetalsDbStatus, check_metals_db_status, cleanup_stale_lock, is_metals_process_alive, parse_h2_lock_file, ) @pytest.mark.scala class TestParseH2LockFile: """Tests for parse_h2_lock_file function.""" def test_returns_none_when_file_does_not_exist(self, tmp_path: Path) -> None: """Should return None when lock file doesn't exist.""" lock_path = tmp_path / "nonexistent.lock.db" result = parse_h2_lock_file(lock_path) assert result is None def test_parses_server_format_lock_file(self, tmp_path: Path) -> None: """Should parse lock file with server:host:port format.""" lock_path = tmp_path / "metals.mv.db.lock.db" lock_path.write_text("server:localhost:9092\n") result = parse_h2_lock_file(lock_path) assert result is not None assert result.port == 9092 assert result.lock_path == lock_path def test_parses_port_only_format(self, tmp_path: Path) -> None: """Should extract port from content containing a port number.""" lock_path = tmp_path / "metals.mv.db.lock.db" lock_path.write_text("some content 9123 more content\n") result = parse_h2_lock_file(lock_path) assert result is not None assert result.port == 9123 def test_parses_pid_format(self, tmp_path: Path) -> None: """Should extract PID from lock file content.""" lock_path = tmp_path / "metals.mv.db.lock.db" lock_path.write_text("pid=12345\nserver:localhost:9092\n") result = parse_h2_lock_file(lock_path) assert result is not None assert result.pid == 12345 assert result.port == 9092 def test_handles_unreadable_file(self, tmp_path: Path) -> None: """Should return None for unreadable files.""" lock_path = tmp_path / "metals.mv.db.lock.db" lock_path.write_text("content") # Make file unreadable (Unix only) if os.name != "nt": lock_path.chmod(0o000) try: result = parse_h2_lock_file(lock_path) assert result is None finally: lock_path.chmod(0o644) def test_truncates_raw_content(self, tmp_path: Path) -> None: """Should truncate raw_content to 200 chars.""" lock_path = tmp_path / "metals.mv.db.lock.db" long_content = "x" * 500 lock_path.write_text(long_content) result = parse_h2_lock_file(lock_path) assert result is not None assert len(result.raw_content) == 200 @pytest.mark.scala class TestIsMetalsProcessAlive: """Tests for is_metals_process_alive function.""" def test_returns_false_for_nonexistent_process(self) -> None: """Should return False for a PID that doesn't exist.""" # Use a very high PID that's unlikely to exist result = is_metals_process_alive(999999999) assert result is False def test_returns_true_for_metals_process(self) -> None: """Should return True for a running Metals process.""" import psutil with patch.object(psutil, "Process") as mock_process_class: mock_proc = MagicMock() mock_proc.is_running.return_value = True mock_proc.cmdline.return_value = [ "java", "-Dmetals.client=vscode", "-jar", "metals.jar", ] mock_process_class.return_value = mock_proc result = is_metals_process_alive(12345) assert result is True def test_returns_false_for_non_metals_java_process(self) -> None: """Should return False for a Java process that isn't Metals.""" import psutil with patch.object(psutil, "Process") as mock_process_class: mock_proc = MagicMock() mock_proc.is_running.return_value = True mock_proc.cmdline.return_value = [ "java", "-jar", "some-other-app.jar", ] mock_process_class.return_value = mock_proc result = is_metals_process_alive(12345) assert result is False def test_returns_false_for_non_running_process(self) -> None: """Should return False for a process that's not running.""" import psutil with patch.object(psutil, "Process") as mock_process_class: mock_proc = MagicMock() mock_proc.is_running.return_value = False mock_process_class.return_value = mock_proc result = is_metals_process_alive(12345) assert result is False def test_handles_no_such_process(self) -> None: """Should return False when process doesn't exist.""" import psutil with patch.object(psutil, "Process") as mock_process_class: mock_process_class.side_effect = psutil.NoSuchProcess(12345) result = is_metals_process_alive(12345) assert result is False @pytest.mark.scala class TestCheckMetalsDbStatus: """Tests for check_metals_db_status function.""" def test_returns_no_database_when_metals_dir_missing(self, tmp_path: Path) -> None: """Should return NO_DATABASE when .metals directory doesn't exist.""" status, lock_info = check_metals_db_status(tmp_path) assert status == MetalsDbStatus.NO_DATABASE assert lock_info is None def test_returns_no_database_when_db_missing(self, tmp_path: Path) -> None: """Should return NO_DATABASE when database file doesn't exist.""" metals_dir = tmp_path / ".metals" metals_dir.mkdir() status, lock_info = check_metals_db_status(tmp_path) assert status == MetalsDbStatus.NO_DATABASE assert lock_info is None def test_returns_no_lock_when_lock_file_missing(self, tmp_path: Path) -> None: """Should return NO_LOCK when database exists but lock doesn't.""" metals_dir = tmp_path / ".metals" metals_dir.mkdir() db_path = metals_dir / "metals.mv.db" db_path.touch() status, lock_info = check_metals_db_status(tmp_path) assert status == MetalsDbStatus.NO_LOCK assert lock_info is None def test_returns_active_instance_when_process_alive(self, tmp_path: Path) -> None: """Should return ACTIVE_INSTANCE when lock holder is running.""" import solidlsp.util.metals_db_utils as metals_utils metals_dir = tmp_path / ".metals" metals_dir.mkdir() db_path = metals_dir / "metals.mv.db" db_path.touch() lock_path = metals_dir / "metals.mv.db.lock.db" lock_path.write_text("pid=12345\nserver:localhost:9092\n") with patch.object(metals_utils, "is_metals_process_alive", return_value=True): status, lock_info = check_metals_db_status(tmp_path) assert status == MetalsDbStatus.ACTIVE_INSTANCE assert lock_info is not None assert lock_info.is_stale is False def test_returns_stale_lock_when_process_dead(self, tmp_path: Path) -> None: """Should return STALE_LOCK when lock holder is not running.""" import solidlsp.util.metals_db_utils as metals_utils metals_dir = tmp_path / ".metals" metals_dir.mkdir() db_path = metals_dir / "metals.mv.db" db_path.touch() lock_path = metals_dir / "metals.mv.db.lock.db" lock_path.write_text("pid=12345\nserver:localhost:9092\n") with patch.object(metals_utils, "is_metals_process_alive", return_value=False): status, lock_info = check_metals_db_status(tmp_path) assert status == MetalsDbStatus.STALE_LOCK assert lock_info is not None assert lock_info.is_stale is True @pytest.mark.scala class TestCleanupStaleLock: """Tests for cleanup_stale_lock function.""" def test_removes_lock_file(self, tmp_path: Path) -> None: """Should successfully remove a lock file.""" lock_path = tmp_path / "metals.mv.db.lock.db" lock_path.touch() result = cleanup_stale_lock(lock_path) assert result is True assert not lock_path.exists() def test_returns_true_when_file_already_removed(self, tmp_path: Path) -> None: """Should return True when file doesn't exist.""" lock_path = tmp_path / "nonexistent.lock.db" result = cleanup_stale_lock(lock_path) assert result is True def test_returns_false_on_permission_error(self, tmp_path: Path) -> None: """Should return False when file can't be removed due to permissions.""" if os.name == "nt": pytest.skip("Permission test not reliable on Windows") lock_path = tmp_path / "metals.mv.db.lock.db" lock_path.touch() # Make parent directory read-only tmp_path.chmod(0o555) try: result = cleanup_stale_lock(lock_path) assert result is False assert lock_path.exists() finally: tmp_path.chmod(0o755) ================================================ FILE: test/solidlsp/scala/test_scala_language_server.py ================================================ # type: ignore import os import pytest from solidlsp.language_servers.scala_language_server import ScalaLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.settings import SolidLSPSettings pytest.skip("Scala must be compiled for these tests to run through, which is a huge hassle", allow_module_level=True) MAIN_FILE_PATH = os.path.join("src", "main", "scala", "com", "example", "Main.scala") pytestmark = pytest.mark.scala @pytest.fixture(scope="module") def scala_ls(): repo_root = os.path.abspath("test/resources/repos/scala") config = LanguageServerConfig(code_language=Language.SCALA) solidlsp_settings = SolidLSPSettings() ls = ScalaLanguageServer(config, repo_root, solidlsp_settings) with ls.start_server(): yield ls def test_scala_document_symbols(scala_ls): """Test document symbols for Main.scala""" symbols, _ = scala_ls.request_document_symbols(MAIN_FILE_PATH).get_all_symbols_and_roots() symbol_names = [s["name"] for s in symbols] assert symbol_names[0] == "com.example" assert symbol_names[1] == "Main" assert symbol_names[2] == "main" assert symbol_names[3] == "result" assert symbol_names[4] == "sum" assert symbol_names[5] == "add" assert symbol_names[6] == "someMethod" assert symbol_names[7] == "str" assert symbol_names[8] == "Config" assert symbol_names[9] == "field1" # confirm https://github.com/oraios/serena/issues/688 def test_scala_references_within_same_file(scala_ls): """Test finding references within the same file.""" definitions = scala_ls.request_definition(MAIN_FILE_PATH, 12, 23) first_def = definitions[0] assert first_def["uri"].endswith("Main.scala") assert first_def["range"]["start"]["line"] == 16 assert first_def["range"]["start"]["character"] == 6 assert first_def["range"]["end"]["line"] == 16 assert first_def["range"]["end"]["character"] == 9 def test_scala_find_definition_and_references_across_files(scala_ls): definitions = scala_ls.request_definition(MAIN_FILE_PATH, 8, 25) assert len(definitions) == 1 first_def = definitions[0] assert first_def["uri"].endswith("Utils.scala") assert first_def["range"]["start"]["line"] == 7 assert first_def["range"]["start"]["character"] == 6 assert first_def["range"]["end"]["line"] == 7 assert first_def["range"]["end"]["character"] == 14 ================================================ FILE: test/solidlsp/scala/test_scala_stale_lock_handling.py ================================================ """ Tests for ScalaLanguageServer stale lock detection and handling modes. These tests verify the ScalaLanguageServer's behavior when detecting stale Metals locks. They use mocking to avoid requiring an actual Scala project or Metals server. """ import logging from pathlib import Path from typing import Any from unittest.mock import MagicMock, patch import pytest from _pytest.logging import LogCaptureFixture from solidlsp.language_servers.scala_language_server import ScalaLanguageServer from solidlsp.ls_config import Language from solidlsp.settings import SolidLSPSettings from solidlsp.util.metals_db_utils import MetalsDbStatus, MetalsLockInfo pytestmark = pytest.mark.scala class TestStaleLockHandling: """Tests for ScalaLanguageServer stale lock detection and handling modes.""" @pytest.fixture def sample_lock_info(self, tmp_path: Path) -> MetalsLockInfo: """Create a sample MetalsLockInfo for testing.""" lock_path = tmp_path / ".metals" / "metals.mv.db.lock.db" return MetalsLockInfo( pid=12345, port=9092, lock_path=lock_path, is_stale=True, raw_content="SERVER:localhost:9092:12345", ) @pytest.fixture def mock_setup_dependencies(self) -> Any: """Mock _setup_runtime_dependencies to avoid needing Java/Coursier.""" return patch.object( ScalaLanguageServer, "_setup_runtime_dependencies", return_value=["/fake/metals"], ) def test_auto_clean_mode_cleans_stale_lock( self, tmp_path: Path, sample_lock_info: MetalsLockInfo, mock_setup_dependencies: Any, caplog: LogCaptureFixture, ) -> None: """Test AUTO_CLEAN mode removes stale lock and proceeds.""" cleanup_mock = MagicMock(return_value=True) with ( patch( "solidlsp.util.metals_db_utils.check_metals_db_status", return_value=(MetalsDbStatus.STALE_LOCK, sample_lock_info), ), patch( "solidlsp.util.metals_db_utils.cleanup_stale_lock", cleanup_mock, ), mock_setup_dependencies, patch.object(ScalaLanguageServer, "__init__", lambda self, *args, **kwargs: None), ): # Create instance without calling __init__ ls = object.__new__(ScalaLanguageServer) settings = SolidLSPSettings(ls_specific_settings={Language.SCALA: {"on_stale_lock": "auto-clean"}}) # Call the method under test ls._check_metals_db_status(str(tmp_path), settings) # Verify cleanup was called cleanup_mock.assert_called_once_with(sample_lock_info.lock_path) def test_warn_mode_logs_warning_without_cleanup( self, tmp_path: Path, sample_lock_info: MetalsLockInfo, mock_setup_dependencies: Any, caplog: LogCaptureFixture, ) -> None: """Test WARN mode logs warning but does not clean up.""" cleanup_mock = MagicMock(return_value=True) with ( patch( "solidlsp.util.metals_db_utils.check_metals_db_status", return_value=(MetalsDbStatus.STALE_LOCK, sample_lock_info), ), patch( "solidlsp.util.metals_db_utils.cleanup_stale_lock", cleanup_mock, ), mock_setup_dependencies, caplog.at_level(logging.WARNING), ): ls = object.__new__(ScalaLanguageServer) settings = SolidLSPSettings(ls_specific_settings={Language.SCALA: {"on_stale_lock": "warn"}}) ls._check_metals_db_status(str(tmp_path), settings) # Verify cleanup was NOT called cleanup_mock.assert_not_called() # Verify warning was logged assert any("Stale Metals lock detected" in record.message for record in caplog.records) def test_fail_mode_raises_exception( self, tmp_path: Path, sample_lock_info: MetalsLockInfo, mock_setup_dependencies: Any, ) -> None: """Test FAIL mode raises MetalsStaleLockError.""" from solidlsp.ls_exceptions import MetalsStaleLockError with ( patch( "solidlsp.util.metals_db_utils.check_metals_db_status", return_value=(MetalsDbStatus.STALE_LOCK, sample_lock_info), ), mock_setup_dependencies, pytest.raises(MetalsStaleLockError) as exc_info, ): ls = object.__new__(ScalaLanguageServer) settings = SolidLSPSettings(ls_specific_settings={Language.SCALA: {"on_stale_lock": "fail"}}) ls._check_metals_db_status(str(tmp_path), settings) assert str(sample_lock_info.lock_path) in str(exc_info.value) def test_active_instance_logs_info_when_enabled( self, tmp_path: Path, mock_setup_dependencies: Any, caplog: LogCaptureFixture, ) -> None: """Test ACTIVE_INSTANCE logs info message when log_multi_instance_notice is true.""" active_lock_info = MetalsLockInfo( pid=99999, port=9092, lock_path=tmp_path / ".metals" / "metals.mv.db.lock.db", is_stale=False, raw_content="SERVER:localhost:9092:99999", ) with ( patch( "solidlsp.util.metals_db_utils.check_metals_db_status", return_value=(MetalsDbStatus.ACTIVE_INSTANCE, active_lock_info), ), mock_setup_dependencies, caplog.at_level(logging.INFO), ): ls = object.__new__(ScalaLanguageServer) settings = SolidLSPSettings( ls_specific_settings={ Language.SCALA: { "on_stale_lock": "auto-clean", "log_multi_instance_notice": True, } } ) ls._check_metals_db_status(str(tmp_path), settings) # Verify info about multi-instance was logged assert any("Another Metals instance detected" in record.message for record in caplog.records) def test_active_instance_silent_when_notice_disabled( self, tmp_path: Path, mock_setup_dependencies: Any, caplog: LogCaptureFixture, ) -> None: """Test ACTIVE_INSTANCE does not log when log_multi_instance_notice is false.""" active_lock_info = MetalsLockInfo( pid=99999, port=9092, lock_path=tmp_path / ".metals" / "metals.mv.db.lock.db", is_stale=False, raw_content="SERVER:localhost:9092:99999", ) with ( patch( "solidlsp.util.metals_db_utils.check_metals_db_status", return_value=(MetalsDbStatus.ACTIVE_INSTANCE, active_lock_info), ), mock_setup_dependencies, caplog.at_level(logging.INFO), ): ls = object.__new__(ScalaLanguageServer) settings = SolidLSPSettings( ls_specific_settings={ Language.SCALA: { "on_stale_lock": "auto-clean", "log_multi_instance_notice": False, } } ) ls._check_metals_db_status(str(tmp_path), settings) # Verify no multi-instance message was logged assert not any("Another Metals instance detected" in record.message for record in caplog.records) def test_no_database_proceeds_silently( self, tmp_path: Path, mock_setup_dependencies: Any, caplog: LogCaptureFixture, ) -> None: """Test NO_DATABASE status proceeds without any special handling.""" with ( patch( "solidlsp.util.metals_db_utils.check_metals_db_status", return_value=(MetalsDbStatus.NO_DATABASE, None), ), mock_setup_dependencies, caplog.at_level(logging.DEBUG), ): ls = object.__new__(ScalaLanguageServer) settings = SolidLSPSettings(ls_specific_settings={Language.SCALA: {"on_stale_lock": "auto-clean"}}) # Should complete without error ls._check_metals_db_status(str(tmp_path), settings) # No stale lock or multi-instance messages assert not any("Stale" in record.message for record in caplog.records) assert not any("Another Metals instance" in record.message for record in caplog.records) def test_no_lock_proceeds_silently( self, tmp_path: Path, mock_setup_dependencies: Any, caplog: LogCaptureFixture, ) -> None: """Test NO_LOCK status proceeds without any special handling.""" with ( patch( "solidlsp.util.metals_db_utils.check_metals_db_status", return_value=(MetalsDbStatus.NO_LOCK, None), ), mock_setup_dependencies, caplog.at_level(logging.DEBUG), ): ls = object.__new__(ScalaLanguageServer) settings = SolidLSPSettings(ls_specific_settings={Language.SCALA: {"on_stale_lock": "auto-clean"}}) # Should complete without error ls._check_metals_db_status(str(tmp_path), settings) # No stale lock or multi-instance messages assert not any("Stale" in record.message for record in caplog.records) assert not any("Another Metals instance" in record.message for record in caplog.records) ================================================ FILE: test/solidlsp/solidity/__init__.py ================================================ ================================================ FILE: test/solidlsp/solidity/test_solidity_basic.py ================================================ """ Basic integration tests for the Solidity language server. Tests validate symbol detection and reference finding using the Solidity test repository, which contains a simple ERC-20 Token contract, a SafeMath library, and an IERC20 interface. """ import re from pathlib import Path from typing import Optional import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language def _find_identifier_position(file_path: Path, symbol_name: str) -> Optional[tuple[int, int]]: """Return the (line, column) of the first occurrence of *symbol_name* as an identifier. Scans the file for a word-boundary match of *symbol_name* so that the position returned is the exact location of the identifier, regardless of what range the language server reports for the surrounding symbol. Returns None if not found. """ pattern = re.compile(r"\b" + re.escape(symbol_name) + r"\b") with file_path.open(encoding="utf-8") as fh: for line_idx, line in enumerate(fh): m = pattern.search(line) if m: return line_idx, m.start() return None @pytest.mark.solidity class TestSolidityLanguageServerBasics: """Test basic functionality of the Solidity language server.""" @pytest.mark.parametrize("language_server", [Language.SOLIDITY], indirect=True) @pytest.mark.parametrize("repo_path", [Language.SOLIDITY], indirect=True) def test_solidity_language_server_initialization(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test that the Solidity language server starts and initializes correctly.""" assert language_server is not None assert language_server.language == Language.SOLIDITY assert language_server.is_running() assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve() @pytest.mark.parametrize("language_server", [Language.SOLIDITY], indirect=True) @pytest.mark.parametrize("repo_path", [Language.SOLIDITY], indirect=True) def test_token_contract_symbols(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test that document symbols are found in Token.sol. Verifies contract, state variables, errors, events, and function symbols. """ all_symbols, root_symbols = language_server.request_document_symbols("contracts/Token.sol").get_all_symbols_and_roots() assert all_symbols is not None, "Should return symbols for Token.sol" assert len(all_symbols) > 0, f"Should find symbols in Token.sol, found {len(all_symbols)}" symbol_names = [sym.get("name") for sym in all_symbols] # Contract-level symbol assert "Token" in symbol_names, "Should detect the Token contract" # State variables assert "name" in symbol_names, "Should detect the 'name' state variable" assert "symbol" in symbol_names, "Should detect the 'symbol' state variable" assert "decimals" in symbol_names, "Should detect the 'decimals' state variable" # Custom errors assert "ZeroAddress" in symbol_names, "Should detect the 'ZeroAddress' custom error" assert "InsufficientBalance" in symbol_names, "Should detect the 'InsufficientBalance' custom error" # Functions assert "totalSupply" in symbol_names, "Should detect the 'totalSupply' function" assert "balanceOf" in symbol_names, "Should detect the 'balanceOf' function" assert "transfer" in symbol_names, "Should detect the 'transfer' function" assert "approve" in symbol_names, "Should detect the 'approve' function" assert "transferFrom" in symbol_names, "Should detect the 'transferFrom' function" @pytest.mark.parametrize("language_server", [Language.SOLIDITY], indirect=True) @pytest.mark.parametrize("repo_path", [Language.SOLIDITY], indirect=True) def test_interface_symbols(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test that document symbols are found in IERC20.sol.""" all_symbols, root_symbols = language_server.request_document_symbols("contracts/interfaces/IERC20.sol").get_all_symbols_and_roots() assert all_symbols is not None, "Should return symbols for IERC20.sol" assert len(all_symbols) > 0, f"Should find symbols in IERC20.sol, found {len(all_symbols)}" symbol_names = [sym.get("name") for sym in all_symbols] # Interface assert "IERC20" in symbol_names, "Should detect the IERC20 interface" # Events assert "Transfer" in symbol_names, "Should detect the Transfer event" assert "Approval" in symbol_names, "Should detect the Approval event" # View functions assert "totalSupply" in symbol_names, "Should detect totalSupply" assert "balanceOf" in symbol_names, "Should detect balanceOf" assert "allowance" in symbol_names, "Should detect allowance" # Mutating functions assert "transfer" in symbol_names, "Should detect transfer" assert "approve" in symbol_names, "Should detect approve" assert "transferFrom" in symbol_names, "Should detect transferFrom" @pytest.mark.parametrize("language_server", [Language.SOLIDITY], indirect=True) @pytest.mark.parametrize("repo_path", [Language.SOLIDITY], indirect=True) def test_library_symbols(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test that document symbols are found in SafeMath.sol.""" all_symbols, root_symbols = language_server.request_document_symbols("contracts/lib/SafeMath.sol").get_all_symbols_and_roots() assert all_symbols is not None, "Should return symbols for SafeMath.sol" assert len(all_symbols) > 0, f"Should find symbols in SafeMath.sol, found {len(all_symbols)}" symbol_names = [sym.get("name") for sym in all_symbols] # Library assert "SafeMath" in symbol_names, "Should detect the SafeMath library" # Library functions assert "add" in symbol_names, "Should detect the 'add' function" assert "sub" in symbol_names, "Should detect the 'sub' function" assert "mul" in symbol_names, "Should detect the 'mul' function" assert "div" in symbol_names, "Should detect the 'div' function" @pytest.mark.parametrize("language_server", [Language.SOLIDITY], indirect=True) @pytest.mark.parametrize("repo_path", [Language.SOLIDITY], indirect=True) def test_within_file_references(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test finding within-file references to the _transfer helper in Token.sol.""" # Use the file to find the exact identifier position: the Solidity LSP reports # the symbol range starting at the preceding whitespace/comment block, not the # function keyword, so we locate '_transfer' directly in the source. pos = _find_identifier_position(repo_path / "contracts/Token.sol", "_transfer") assert pos is not None, "Should find '_transfer' identifier in Token.sol" definition_line, definition_char = pos references = language_server.request_references("contracts/Token.sol", definition_line, definition_char) assert references is not None, "Should return references for '_transfer'" assert ( len(references) >= 2 ), f"'_transfer' should have at least 2 references (callers), found {len(references)}" # called in transfer() and transferFrom() ref_files = {ref.get("uri", "") for ref in references} assert any("Token.sol" in uri for uri in ref_files), "References should include Token.sol" @pytest.mark.parametrize("language_server", [Language.SOLIDITY], indirect=True) @pytest.mark.parametrize("repo_path", [Language.SOLIDITY], indirect=True) def test_cross_file_references(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test finding cross-file references: IERC20.transfer implemented in Token.sol.""" # Use 'transfer' in the interface — Token.sol inherits IERC20 and overrides it, # so the LSP resolves the implementation site in Token.sol as a cross-file reference. pos = _find_identifier_position(repo_path / "contracts/interfaces/IERC20.sol", "transfer") assert pos is not None, "Should find 'transfer' identifier in IERC20.sol" definition_line, definition_char = pos references = language_server.request_references("contracts/interfaces/IERC20.sol", definition_line, definition_char) assert references is not None, "Should return cross-file references for IERC20.transfer" assert len(references) >= 1, f"IERC20.transfer should be referenced at least once (in Token.sol), found {len(references)}" ref_files = {ref.get("uri", "") for ref in references} assert any("Token.sol" in uri for uri in ref_files), "IERC20.transfer references should include Token.sol" ================================================ FILE: test/solidlsp/swift/test_swift_basic.py ================================================ """ Basic integration tests for the Swift language server functionality. These tests validate the functionality of the language server APIs like request_references using the Swift test repository. """ import os import platform import pytest from serena.project import Project from serena.util.text_utils import LineType from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from test.conftest import is_ci # Skip Swift tests on Windows due to complex GitHub Actions configuration WINDOWS_SKIP = platform.system() == "Windows" WINDOWS_SKIP_REASON = "GitHub Actions configuration for Swift on Windows is complex, skipping for now." pytestmark = [pytest.mark.swift, pytest.mark.skipif(WINDOWS_SKIP, reason=WINDOWS_SKIP_REASON)] class TestSwiftLanguageServerBasics: """Test basic functionality of the Swift language server.""" @pytest.mark.parametrize("language_server", [Language.SWIFT], indirect=True) def test_goto_definition_calculator_class(self, language_server: SolidLanguageServer) -> None: """Test goto_definition on Calculator class usage.""" file_path = os.path.join("src", "main.swift") # Find the Calculator usage at line 5: let calculator = Calculator() # Position should be at the "Calculator()" call definitions = language_server.request_definition(file_path, 4, 23) # Position at Calculator() call assert isinstance(definitions, list), "Definitions should be a list" assert len(definitions) > 0, "Should find definition for Calculator class" # Verify the definition points to the Calculator class definition calculator_def = definitions[0] assert calculator_def.get("uri", "").endswith("main.swift"), "Definition should be in main.swift" # The Calculator class is defined starting at line 16 start_line = calculator_def.get("range", {}).get("start", {}).get("line") assert start_line == 15, f"Calculator class definition should be at line 16, got {start_line + 1}" @pytest.mark.parametrize("language_server", [Language.SWIFT], indirect=True) def test_goto_definition_user_struct(self, language_server: SolidLanguageServer) -> None: """Test goto_definition on User struct usage.""" file_path = os.path.join("src", "main.swift") # Find the User usage at line 9: let user = User(name: "Alice", age: 30) # Position should be at the "User(...)" call definitions = language_server.request_definition(file_path, 8, 18) # Position at User(...) call assert isinstance(definitions, list), "Definitions should be a list" assert len(definitions) > 0, "Should find definition for User struct" # Verify the definition points to the User struct definition user_def = definitions[0] assert user_def.get("uri", "").endswith("main.swift"), "Definition should be in main.swift" # The User struct is defined starting at line 26 start_line = user_def.get("range", {}).get("start", {}).get("line") assert start_line == 25, f"User struct definition should be at line 26, got {start_line + 1}" @pytest.mark.parametrize("language_server", [Language.SWIFT], indirect=True) def test_goto_definition_calculator_method(self, language_server: SolidLanguageServer) -> None: """Test goto_definition on Calculator method usage.""" file_path = os.path.join("src", "main.swift") # Find the add method usage at line 6: let result = calculator.add(5, 3) # Position should be at the "add" method call definitions = language_server.request_definition(file_path, 5, 28) # Position at add method call assert isinstance(definitions, list), "Definitions should be a list" # Verify the definition points to the add method definition add_def = definitions[0] assert add_def.get("uri", "").endswith("main.swift"), "Definition should be in main.swift" # The add method is defined starting at line 17 start_line = add_def.get("range", {}).get("start", {}).get("line") assert start_line == 16, f"add method definition should be at line 17, got {start_line + 1}" @pytest.mark.parametrize("language_server", [Language.SWIFT], indirect=True) def test_goto_definition_cross_file(self, language_server: SolidLanguageServer) -> None: """Test goto_definition across files - Utils struct.""" utils_file = os.path.join("src", "utils.swift") # First, let's check if Utils is used anywhere (it might not be in this simple test) # We'll test goto_definition on Utils struct itself symbols = language_server.request_document_symbols(utils_file).get_all_symbols_and_roots() utils_symbol = next(s for s in symbols[0] if s.get("name") == "Utils") sel_start = utils_symbol["selectionRange"]["start"] definitions = language_server.request_definition(utils_file, sel_start["line"], sel_start["character"]) assert isinstance(definitions, list), "Definitions should be a list" # Should find the Utils struct definition itself utils_def = definitions[0] assert utils_def.get("uri", "").endswith("utils.swift"), "Definition should be in utils.swift" @pytest.mark.xfail(is_ci, reason="Test is flaky in CI") # See #1040 @pytest.mark.parametrize("language_server", [Language.SWIFT], indirect=True) def test_request_references_calculator_class(self, language_server: SolidLanguageServer) -> None: """Test request_references on the Calculator class.""" # Get references to the Calculator class in main.swift file_path = os.path.join("src", "main.swift") symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() calculator_symbol = next(s for s in symbols[0] if s.get("name") == "Calculator") sel_start = calculator_symbol["selectionRange"]["start"] references = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) assert isinstance(references, list), "References should be a list" assert len(references) > 0, "Calculator class should be referenced" # Validate that Calculator is referenced in the main function calculator_refs = [ref for ref in references if ref.get("uri", "").endswith("main.swift")] assert len(calculator_refs) > 0, "Calculator class should be referenced in main.swift" # Check that one reference is at line 5 (let calculator = Calculator()) line_5_refs = [ref for ref in calculator_refs if ref.get("range", {}).get("start", {}).get("line") == 4] assert len(line_5_refs) > 0, "Calculator should be referenced at line 5" @pytest.mark.xfail(is_ci, reason="Test is flaky in CI") # See #1040 @pytest.mark.parametrize("language_server", [Language.SWIFT], indirect=True) def test_request_references_user_struct(self, language_server: SolidLanguageServer) -> None: """Test request_references on the User struct.""" # Get references to the User struct in main.swift file_path = os.path.join("src", "main.swift") symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() user_symbol = next(s for s in symbols[0] if s.get("name") == "User") sel_start = user_symbol["selectionRange"]["start"] references = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) assert isinstance(references, list), "References should be a list" # Validate that User is referenced in the main function user_refs = [ref for ref in references if ref.get("uri", "").endswith("main.swift")] assert len(user_refs) > 0, "User struct should be referenced in main.swift" # Check that one reference is at line 9 (let user = User(...)) line_9_refs = [ref for ref in user_refs if ref.get("range", {}).get("start", {}).get("line") == 8] assert len(line_9_refs) > 0, "User should be referenced at line 9" @pytest.mark.xfail(is_ci, reason="Test is flaky in CI") # See #1040 @pytest.mark.parametrize("language_server", [Language.SWIFT], indirect=True) def test_request_references_utils_struct(self, language_server: SolidLanguageServer) -> None: """Test request_references on the Utils struct.""" # Get references to the Utils struct in utils.swift file_path = os.path.join("src", "utils.swift") symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() utils_symbol = next((s for s in symbols[0] if s.get("name") == "Utils"), None) if not utils_symbol or "selectionRange" not in utils_symbol: raise AssertionError("Utils symbol or its selectionRange not found") sel_start = utils_symbol["selectionRange"]["start"] references = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) assert isinstance(references, list), "References should be a list" assert len(references) > 0, "Utils struct should be referenced" # Validate that Utils is referenced in main.swift utils_refs = [ref for ref in references if ref.get("uri", "").endswith("main.swift")] assert len(utils_refs) > 0, "Utils struct should be referenced in main.swift" # Check that one reference is at line 12 (Utils.calculateArea call) line_12_refs = [ref for ref in utils_refs if ref.get("range", {}).get("start", {}).get("line") == 11] assert len(line_12_refs) > 0, "Utils should be referenced at line 12" class TestSwiftProjectBasics: @pytest.mark.parametrize("project", [Language.SWIFT], indirect=True) def test_retrieve_content_around_line(self, project: Project) -> None: """Test retrieve_content_around_line functionality with various scenarios.""" file_path = os.path.join("src", "main.swift") # Scenario 1: Find Calculator class definition calculator_line = None for line_num in range(1, 50): # Search first 50 lines try: line_content = project.retrieve_content_around_line(file_path, line_num) if line_content.lines and "class Calculator" in line_content.lines[0].line_content: calculator_line = line_num break except: continue assert calculator_line is not None, "Calculator class not found" line_calc = project.retrieve_content_around_line(file_path, calculator_line) assert len(line_calc.lines) == 1 assert "class Calculator" in line_calc.lines[0].line_content assert line_calc.lines[0].line_number == calculator_line assert line_calc.lines[0].match_type == LineType.MATCH # Scenario 2: Context above and below Calculator class with_context_around_calculator = project.retrieve_content_around_line(file_path, calculator_line, 2, 2) assert len(with_context_around_calculator.lines) == 5 assert "class Calculator" in with_context_around_calculator.matched_lines[0].line_content assert with_context_around_calculator.num_matched_lines == 1 # Scenario 3: Search for struct definitions struct_pattern = r"struct\s+\w+" matches = project.search_source_files_for_pattern(struct_pattern) assert len(matches) > 0, "Should find struct definitions" # Should find User struct user_matches = [m for m in matches if "User" in str(m)] assert len(user_matches) > 0, "Should find User struct" # Scenario 4: Search for class definitions class_pattern = r"class\s+\w+" matches = project.search_source_files_for_pattern(class_pattern) assert len(matches) > 0, "Should find class definitions" # Should find Calculator and Circle classes calculator_matches = [m for m in matches if "Calculator" in str(m)] circle_matches = [m for m in matches if "Circle" in str(m)] assert len(calculator_matches) > 0, "Should find Calculator class" assert len(circle_matches) > 0, "Should find Circle class" # Scenario 5: Search for enum definitions enum_pattern = r"enum\s+\w+" matches = project.search_source_files_for_pattern(enum_pattern) assert len(matches) > 0, "Should find enum definitions" # Should find Status enum status_matches = [m for m in matches if "Status" in str(m)] assert len(status_matches) > 0, "Should find Status enum" ================================================ FILE: test/solidlsp/systemverilog/__init__.py ================================================ ================================================ FILE: test/solidlsp/systemverilog/test_systemverilog_basic.py ================================================ """ Basic tests for SystemVerilog language server integration (verible-verilog-ls). This module tests Language.SYSTEMVERILOG using verible-verilog-ls. Tests are skipped if the language server is not available. """ from typing import Any import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils def _find_symbol_by_name(language_server: SolidLanguageServer, file_path: str, name: str) -> dict[str, Any] | None: """Find a top-level symbol by name in a file's document symbols.""" symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() return next((s for s in symbols[0] if s.get("name") == name), None) def _get_symbol_selection_start(language_server: SolidLanguageServer, file_path: str, name: str) -> tuple[int, int]: """Get the (line, character) of a symbol's selectionRange start.""" symbol = _find_symbol_by_name(language_server, file_path, name) assert symbol is not None, f"Could not find symbol '{name}' in {file_path}" assert "selectionRange" in symbol, f"Symbol '{name}' has no selectionRange in {file_path}" sel_start = symbol["selectionRange"]["start"] return sel_start["line"], sel_start["character"] @pytest.mark.systemverilog class TestSystemVerilogSymbols: """Tests for document symbol extraction.""" @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True) def test_find_symbol(self, language_server: SolidLanguageServer) -> None: """Test that symbol tree contains expected modules.""" symbols = language_server.request_full_symbol_tree() assert SymbolUtils.symbol_tree_contains_name(symbols, "counter"), "Module 'counter' not found in symbol tree" @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True) def test_get_document_symbols(self, language_server: SolidLanguageServer) -> None: """Test document symbols for counter.sv.""" symbol = _find_symbol_by_name(language_server, "counter.sv", "counter") assert symbol is not None, "Expected 'counter' in document symbols" @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True) def test_find_top_module(self, language_server: SolidLanguageServer) -> None: """Test that top module is found (cross-file instantiation test).""" symbols = language_server.request_full_symbol_tree() assert SymbolUtils.symbol_tree_contains_name(symbols, "top"), "Module 'top' not found in symbol tree" @pytest.mark.systemverilog class TestSystemVerilogDefinition: """Tests for go-to-definition functionality.""" @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True) def test_goto_definition(self, language_server: SolidLanguageServer) -> None: """Test go to definition from signal usage to its declaration. Navigating from 'count' usage in always_ff (line 13) should jump to the output port declaration (line 7, char 29). """ # counter.sv line 13 (0-indexed): " count <= '0;" # 'count' at char 12 definitions = language_server.request_definition("counter.sv", 13, 12) assert len(definitions) >= 1, f"Expected at least 1 definition, got {len(definitions)}" def_in_counter = [d for d in definitions if "counter.sv" in (d.get("relativePath") or "")] assert len(def_in_counter) >= 1, f"Expected definition in counter.sv, got: {[d.get('relativePath') for d in definitions]}" assert ( def_in_counter[0]["range"]["start"]["line"] == 7 ), f"Expected definition at line 7 (output port count), got line {def_in_counter[0]['range']['start']['line']}" @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True) def test_goto_definition_cross_file(self, language_server: SolidLanguageServer) -> None: """Test go to definition from module instantiation in top.sv to counter.sv. This is the key cross-file test: navigating from an instantiation (counter in top.sv) to its definition (counter.sv). """ # top.sv line 17 (0-indexed: 16): " counter #(.WIDTH(8)) u_counter (" # "counter" starts at column 4 definitions = language_server.request_definition("top.sv", 16, 4) assert len(definitions) >= 1, f"Expected at least 1 definition, got {len(definitions)}" def_paths = [d.get("relativePath", "") for d in definitions] assert any("counter.sv" in p for p in def_paths), f"Expected definition in counter.sv, got: {def_paths}" counter_defs = [d for d in definitions if "counter.sv" in (d.get("relativePath") or "")] assert ( counter_defs[0]["range"]["start"]["line"] == 1 ), f"Expected definition at line 1 (module counter), got line {counter_defs[0]['range']['start']['line']}" @pytest.mark.systemverilog class TestSystemVerilogReferences: """Tests for find-references functionality.""" @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True) def test_find_references(self, language_server: SolidLanguageServer) -> None: """Test finding within-file references to a port signal. The 'count' output port is declared on line 7 and used in the always_ff block on lines 13 and 15 (twice), giving 3 within-file references — all inside counter.sv. """ # counter.sv line 8 (0-indexed: 7): " output logic [WIDTH-1:0] count" # 'count' starts at char 29 references = language_server.request_references("counter.sv", 7, 29) assert len(references) >= 1, f"Expected at least 1 reference, got {len(references)}" ref_paths = [r.get("relativePath", "") for r in references] refs_in_counter = [r for r in references if "counter.sv" in (r.get("relativePath") or "")] assert len(refs_in_counter) >= 1, f"Expected within-file references in counter.sv, got paths: {ref_paths}" ref_lines = sorted(r["range"]["start"]["line"] for r in refs_in_counter) # Line 13: count <= '0; Line 15: count <= count + 1'b1; (two refs) assert 13 in ref_lines, f"Expected reference at line 13 (count <= '0), got lines: {ref_lines}" assert 15 in ref_lines, f"Expected reference at line 15 (count <= count + 1'b1), got lines: {ref_lines}" @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True) def test_find_references_cross_file(self, language_server: SolidLanguageServer) -> None: """Test that references to counter include its instantiation in top.sv. Similar to Rust (lib.rs → main.rs) and C# (Program.cs → Models/Person.cs), this verifies that cross-file references are found. """ line, char = _get_symbol_selection_start(language_server, "counter.sv", "counter") references = language_server.request_references("counter.sv", line, char) ref_paths = [ref.get("relativePath", "") for ref in references] assert any("top.sv" in p for p in ref_paths), f"Expected reference from top.sv, got: {ref_paths}" refs_in_top = [r for r in references if "top.sv" in (r.get("relativePath") or "")] # top.sv line 17 (0-indexed: 16): " counter #(.WIDTH(8)) u_counter (" assert ( refs_in_top[0]["range"]["start"]["line"] == 16 ), f"Expected cross-file reference at line 16 (counter instantiation), got line {refs_in_top[0]['range']['start']['line']}" def _extract_hover_text(hover_info: dict[str, Any]) -> str: """Extract the text content from an LSP hover response.""" contents = hover_info["contents"] if isinstance(contents, dict): return contents.get("value", "") elif isinstance(contents, str): return contents return str(contents) @pytest.mark.systemverilog class TestSystemVerilogHover: """Tests for hover information.""" @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True) def test_hover(self, language_server: SolidLanguageServer) -> None: """Test hover information (experimental in verible, requires --lsp_enable_hover).""" line, char = _get_symbol_selection_start(language_server, "counter.sv", "counter") hover_info = language_server.request_hover("counter.sv", line, char) assert hover_info is not None, "Hover should return information for counter module" assert "contents" in hover_info, "Hover should have contents" hover_text = _extract_hover_text(hover_info) assert len(hover_text) > 0, "Hover text should not be empty" assert "counter" in hover_text.lower(), f"Hover should mention 'counter', got: {hover_text}" assert "module" in hover_text.lower(), f"Hover should identify 'counter' as a module, got: {hover_text}" @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True) def test_hover_includes_type_information(self, language_server: SolidLanguageServer) -> None: """Test that hover includes type information for a port signal. Hovering on 'count' output port should return its name and type (logic [WIDTH-1:0]), distinct from module-level hover. """ # counter.sv line 8 (0-indexed: 7): " output logic [WIDTH-1:0] count" # 'count' starts at char 29 hover_info = language_server.request_hover("counter.sv", 7, 29) assert hover_info is not None, "Hover should return information for 'count' port" assert "contents" in hover_info, "Hover should have contents" hover_text = _extract_hover_text(hover_info) assert "count" in hover_text.lower(), f"Hover should mention 'count', got: {hover_text}" assert "logic" in hover_text.lower(), f"Hover should include type 'logic', got: {hover_text}" def _extract_changes(workspace_edit: dict[str, Any]) -> dict[str, list[dict[str, Any]]]: """Extract file URI → edits mapping from a WorkspaceEdit, handling both formats.""" changes = workspace_edit.get("changes", {}) if not changes: doc_changes = workspace_edit.get("documentChanges", []) assert len(doc_changes) > 0, "WorkspaceEdit should have 'changes' or 'documentChanges'" changes = {dc["textDocument"]["uri"]: dc["edits"] for dc in doc_changes if "textDocument" in dc and "edits" in dc} return changes @pytest.mark.systemverilog class TestSystemVerilogRename: """Tests for rename functionality.""" @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True) def test_rename_signal_within_file(self, language_server: SolidLanguageServer) -> None: """Test renaming a port signal from its declaration updates within-file occurrences. The 'count' output port (line 7, char 29) is used in the always_ff block on lines 13 and 15. Renaming from the declaration site produces edits for all occurrences within counter.sv. """ workspace_edit = language_server.request_rename_symbol_edit("counter.sv", 7, 29, "cnt") assert workspace_edit is not None, "Rename should be supported for port signal 'count'" changes = _extract_changes(workspace_edit) counter_edits = [edits for uri, edits in changes.items() if "counter.sv" in uri] assert len(counter_edits) >= 1, f"Should have edits for counter.sv, got: {list(changes.keys())}" edits = counter_edits[0] assert len(edits) >= 2, f"Expected at least 2 edits (declaration + usage), got {len(edits)}" edit_lines = sorted(e["range"]["start"]["line"] for e in edits) assert 7 in edit_lines, f"Expected edit at line 7 (port declaration), got lines: {edit_lines}" assert 13 in edit_lines, f"Expected edit at line 13 (count <= '0), got lines: {edit_lines}" assert 15 in edit_lines, f"Expected edit at line 15 (count <= count + 1'b1), got lines: {edit_lines}" for edit in edits: assert edit["newText"] == "cnt", f"Expected newText 'cnt', got {edit['newText']}" @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True) def test_rename_signal_cross_file(self, language_server: SolidLanguageServer) -> None: """Test renaming a port signal from a usage site includes cross-file edits. Renaming 'count' from usage in always_ff (line 13, char 12) should produce edits in counter.sv (declaration + usages) and also in top.sv where the port is connected (.count(count) at line 20). """ workspace_edit = language_server.request_rename_symbol_edit("counter.sv", 13, 12, "cnt") assert workspace_edit is not None, "Rename should be supported for signal 'count' from usage site" changes = _extract_changes(workspace_edit) counter_uris = [uri for uri in changes if "counter.sv" in uri] top_uris = [uri for uri in changes if "top.sv" in uri] assert len(counter_uris) >= 1, f"Expected edits in counter.sv, got: {list(changes.keys())}" assert len(top_uris) >= 1, f"Expected cross-file edits in top.sv, got: {list(changes.keys())}" for uri, edits in changes.items(): for edit in edits: assert edit["newText"] == "cnt", f"Expected 'cnt' in {uri}, got {edit['newText']}" @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True) def test_rename_module_name(self, language_server: SolidLanguageServer) -> None: """Test renaming a module name at its declaration. The 'counter' module declaration (line 1, char 7) is renamed to 'my_counter'. Verible renames the identifier at the definition site. """ line, char = _get_symbol_selection_start(language_server, "counter.sv", "counter") workspace_edit = language_server.request_rename_symbol_edit("counter.sv", line, char, "my_counter") assert workspace_edit is not None, "Rename should be supported for module 'counter'" changes = _extract_changes(workspace_edit) assert len(changes) > 0, "WorkspaceEdit should have changes" counter_edits = [edits for uri, edits in changes.items() if "counter.sv" in uri] assert len(counter_edits) >= 1, f"Should have edits for counter.sv, got: {list(changes.keys())}" edits = counter_edits[0] edit_lines = sorted(e["range"]["start"]["line"] for e in edits) assert 1 in edit_lines, f"Expected edit at line 1 (module declaration), got lines: {edit_lines}" decl_edits = [e for e in edits if e["range"]["start"]["line"] == 1] assert ( decl_edits[0]["range"]["start"]["character"] == 7 ), f"Expected edit at char 7, got char {decl_edits[0]['range']['start']['character']}" for uri, file_edits in changes.items(): for edit in file_edits: assert edit["newText"] == "my_counter", f"Expected 'my_counter', got {edit['newText']}" ================================================ FILE: test/solidlsp/systemverilog/test_systemverilog_detection.py ================================================ """ Tests for verible-verilog-ls detection logic. These tests describe the expected behavior of SystemVerilogLanguageServer.DependencyProvider._get_or_install_core_dependency(): 1. System PATH should be checked FIRST (prefers user-installed verible) 2. Runtime download should be fallback when not in PATH 3. Version information should be logged when available 4. Version check failures should be handled gracefully 5. Helpful error messages when verible is not available on unsupported platforms WHY these tests matter: - Users install verible via conda, Homebrew, system packages, or GitHub releases - Detection failing means Serena is unusable for SystemVerilog, even when verible is correctly installed - Without these tests, the detection logic can silently break for users with system installations - Version logging helps debug compatibility issues """ import os import shutil import subprocess import tempfile from unittest.mock import MagicMock, Mock, patch import pytest from solidlsp.language_servers.systemverilog_server import SystemVerilogLanguageServer from solidlsp.settings import SolidLSPSettings DEFAULT_VERIBLE_VERSION = "v0.0-4051-g9fdb4057" class TestVeribleVerilogLsDetection: """Unit tests for verible-verilog-ls binary detection logic.""" @pytest.mark.systemverilog def test_detect_from_path_returns_system_verible(self): """ GIVEN verible-verilog-ls is in system PATH WHEN _get_or_install_core_dependency is called THEN it returns the system path without downloading WHY: Users with system-installed verible (via conda, Homebrew, apt) should use that version instead of downloading. This is faster and respects user's environment management. """ with tempfile.TemporaryDirectory() as temp_dir: custom_settings = SolidLSPSettings.CustomLSSettings({}) provider = SystemVerilogLanguageServer.DependencyProvider(custom_settings, temp_dir) with patch("shutil.which") as mock_which: mock_which.return_value = "/usr/local/bin/verible-verilog-ls" with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock( returncode=0, stdout="Verible v0.0-4051-g9fdb4057 (2024-01-01)\nCommit: 9fdb4057", stderr="", ) result = provider._get_or_install_core_dependency() assert result == "/usr/local/bin/verible-verilog-ls" mock_which.assert_called_once_with("verible-verilog-ls") mock_run.assert_called_once() assert mock_run.call_args[0][0] == ["/usr/local/bin/verible-verilog-ls", "--version"] @pytest.mark.systemverilog def test_detect_from_path_logs_version(self): """ GIVEN verible-verilog-ls is in PATH with version output WHEN detected THEN version info is logged WHY: Version information helps debug compatibility issues. Users and developers need to know which verible version is being used. """ with tempfile.TemporaryDirectory() as temp_dir: custom_settings = SolidLSPSettings.CustomLSSettings({}) provider = SystemVerilogLanguageServer.DependencyProvider(custom_settings, temp_dir) with patch("shutil.which", return_value="/usr/bin/verible-verilog-ls"): with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock(returncode=0, stdout="Verible v0.0-4051-g9fdb4057", stderr="") with patch("solidlsp.language_servers.systemverilog_server.log") as mock_log: result = provider._get_or_install_core_dependency() # Verify version check was called assert mock_run.call_args[0][0] == ["/usr/bin/verible-verilog-ls", "--version"] # Verify version was logged assert mock_log.info.called log_message = mock_log.info.call_args[0][0] assert "Verible v0.0-4051" in log_message assert result == "/usr/bin/verible-verilog-ls" @pytest.mark.systemverilog def test_detect_from_path_handles_version_failure_gracefully(self): """ GIVEN verible-verilog-ls is in PATH but --version fails (returncode=1) WHEN detected THEN it still returns the system path (graceful degradation) WHY: Some verible builds might not support --version or have different flags. Detection should not fail just because version check fails - the binary might still work fine for LSP operations. """ with tempfile.TemporaryDirectory() as temp_dir: custom_settings = SolidLSPSettings.CustomLSSettings({}) provider = SystemVerilogLanguageServer.DependencyProvider(custom_settings, temp_dir) with patch("shutil.which", return_value="/custom/bin/verible-verilog-ls"): with patch("subprocess.run") as mock_run: # Version check fails mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="Unknown option: --version") result = provider._get_or_install_core_dependency() # Should still return the path despite version check failure assert result == "/custom/bin/verible-verilog-ls" @pytest.mark.systemverilog def test_detect_from_path_handles_version_timeout_gracefully(self): """ GIVEN verible-verilog-ls is in PATH but --version times out WHEN detected THEN it still returns the system path (graceful degradation) WHY: Version check has a timeout to avoid hanging. If it times out, we should still use the detected binary. """ with tempfile.TemporaryDirectory() as temp_dir: custom_settings = SolidLSPSettings.CustomLSSettings({}) provider = SystemVerilogLanguageServer.DependencyProvider(custom_settings, temp_dir) with patch("shutil.which", return_value="/opt/verible/bin/verible-verilog-ls"): with patch("subprocess.run") as mock_run: # Version check times out mock_run.side_effect = subprocess.TimeoutExpired(cmd=["verible-verilog-ls", "--version"], timeout=5) result = provider._get_or_install_core_dependency() # Should still return the path despite timeout assert result == "/opt/verible/bin/verible-verilog-ls" @pytest.mark.systemverilog def test_error_message_when_not_found_anywhere(self): """ GIVEN verible is NOT in PATH AND platform is unsupported WHEN _get_or_install_core_dependency is called THEN raises FileNotFoundError with helpful installation instructions WHY: Users need clear guidance on how to install verible when it's missing. Error message should mention conda, Homebrew, and GitHub releases. """ with tempfile.TemporaryDirectory() as temp_dir: custom_settings = SolidLSPSettings.CustomLSSettings({}) provider = SystemVerilogLanguageServer.DependencyProvider(custom_settings, temp_dir) with patch("shutil.which", return_value=None): # Mock RuntimeDependencyCollection to raise RuntimeError for unsupported platform with patch("solidlsp.language_servers.systemverilog_server.RuntimeDependencyCollection") as mock_deps_class: mock_deps = Mock() mock_deps.get_single_dep_for_current_platform.side_effect = RuntimeError("Unsupported platform") mock_deps_class.return_value = mock_deps with pytest.raises(FileNotFoundError) as exc_info: provider._get_or_install_core_dependency() error_message = str(exc_info.value) # Error should mention installation methods assert "conda" in error_message.lower() assert "Homebrew" in error_message or "brew" in error_message.lower() assert "GitHub" in error_message or "github.com" in error_message.lower() assert "verible" in error_message.lower() @pytest.mark.systemverilog def test_downloads_when_not_in_path(self): """ GIVEN verible is NOT in PATH AND platform IS supported AND binary exists after download WHEN _get_or_install_core_dependency is called THEN returns the downloaded executable path WHY: When verible is not installed system-wide and platform is supported, Serena should auto-download it. This enables zero-setup experience. """ with tempfile.TemporaryDirectory() as temp_dir: custom_settings = SolidLSPSettings.CustomLSSettings({}) provider = SystemVerilogLanguageServer.DependencyProvider(custom_settings, temp_dir) expected_path = os.path.join(temp_dir, "verible-ls", f"verible-{DEFAULT_VERIBLE_VERSION}", "bin", "verible-verilog-ls") with patch("shutil.which", return_value=None): with patch("solidlsp.language_servers.systemverilog_server.RuntimeDependencyCollection") as mock_deps_class: # Create mock dependency and collection mock_dep = Mock() mock_dep.url = "https://github.com/chipsalliance/verible/releases/download/v0.0-4051/verible.tar.gz" mock_deps = Mock() mock_deps.get_single_dep_for_current_platform.return_value = mock_dep mock_deps.binary_path.return_value = expected_path mock_deps.install.return_value = expected_path mock_deps_class.return_value = mock_deps with patch("os.path.exists") as mock_exists: # Before download: binary doesn't exist yet → after download: binary exists mock_exists.side_effect = [False, True] with patch("os.chmod"): result = provider._get_or_install_core_dependency() assert result == expected_path mock_deps.install.assert_called_once() @pytest.mark.systemverilog def test_detection_prefers_path_over_download(self): """ GIVEN verible is in PATH AND download would also work WHEN _get_or_install_core_dependency is called THEN PATH version is used (download never attempted) WHY: System-installed verible should always take precedence. This respects user's environment and avoids unnecessary downloads. """ with tempfile.TemporaryDirectory() as temp_dir: custom_settings = SolidLSPSettings.CustomLSSettings({}) provider = SystemVerilogLanguageServer.DependencyProvider(custom_settings, temp_dir) with patch("shutil.which", return_value="/usr/bin/verible-verilog-ls"): with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock(returncode=0, stdout="Verible v0.0-4051", stderr="") with patch("solidlsp.language_servers.systemverilog_server.RuntimeDependencyCollection") as mock_deps_class: result = provider._get_or_install_core_dependency() # RuntimeDependencyCollection should never be instantiated mock_deps_class.assert_not_called() assert result == "/usr/bin/verible-verilog-ls" @pytest.mark.systemverilog def test_download_fails_if_binary_not_found_after_install(self): """ GIVEN verible is NOT in PATH AND platform IS supported WHEN download completes BUT binary still doesn't exist at expected path THEN raises FileNotFoundError WHY: If download/extraction fails silently, we should catch it and report clearly. """ with tempfile.TemporaryDirectory() as temp_dir: custom_settings = SolidLSPSettings.CustomLSSettings({}) provider = SystemVerilogLanguageServer.DependencyProvider(custom_settings, temp_dir) expected_path = os.path.join(temp_dir, "verible-ls", f"verible-{DEFAULT_VERIBLE_VERSION}", "bin", "verible-verilog-ls") with patch("shutil.which", return_value=None): with patch("solidlsp.language_servers.systemverilog_server.RuntimeDependencyCollection") as mock_deps_class: mock_dep = Mock() mock_deps = Mock() mock_deps.get_single_dep_for_current_platform.return_value = mock_dep mock_deps.binary_path.return_value = expected_path mock_deps.install.return_value = expected_path mock_deps_class.return_value = mock_deps # Binary never appears after install with patch("os.path.exists", return_value=False): with pytest.raises(FileNotFoundError) as exc_info: provider._get_or_install_core_dependency() error_message = str(exc_info.value) assert "verible-verilog-ls not found" in error_message assert expected_path in error_message @pytest.mark.systemverilog def test_uses_already_downloaded_binary_without_reinstalling(self): """ GIVEN verible is NOT in PATH AND platform IS supported AND binary already exists at download location WHEN _get_or_install_core_dependency is called THEN returns existing path without downloading again WHY: Avoid redundant downloads if verible was already downloaded in previous session. This speeds up subsequent runs. """ with tempfile.TemporaryDirectory() as temp_dir: custom_settings = SolidLSPSettings.CustomLSSettings({}) provider = SystemVerilogLanguageServer.DependencyProvider(custom_settings, temp_dir) expected_path = os.path.join(temp_dir, "verible-ls", f"verible-{DEFAULT_VERIBLE_VERSION}", "bin", "verible-verilog-ls") with patch("shutil.which", return_value=None): with patch("solidlsp.language_servers.systemverilog_server.RuntimeDependencyCollection") as mock_deps_class: mock_dep = Mock() mock_deps = Mock() mock_deps.get_single_dep_for_current_platform.return_value = mock_dep mock_deps.binary_path.return_value = expected_path mock_deps_class.return_value = mock_deps # Binary already exists with patch("os.path.exists", return_value=True): with patch("os.chmod"): result = provider._get_or_install_core_dependency() # Should NOT call install since binary already exists mock_deps.install.assert_not_called() assert result == expected_path class TestVeribleVerilogLsDetectionIntegration: """ Integration tests that verify detection works on the current system. These tests are skipped if verible-verilog-ls is not installed. """ @pytest.mark.systemverilog def test_integration_finds_installed_verible(self): """ GIVEN verible-verilog-ls is installed on this system (via any method) WHEN _get_or_install_core_dependency is called THEN it returns a valid executable path This test verifies the detection logic works end-to-end on the current system. """ # Skip if verible-verilog-ls is not installed if not shutil.which("verible-verilog-ls"): pytest.skip("verible-verilog-ls not installed on this system") with tempfile.TemporaryDirectory() as temp_dir: custom_settings = SolidLSPSettings.CustomLSSettings({}) provider = SystemVerilogLanguageServer.DependencyProvider(custom_settings, temp_dir) result = provider._get_or_install_core_dependency() assert result is not None assert os.path.isfile(result) assert os.access(result, os.X_OK) ================================================ FILE: test/solidlsp/terraform/test_terraform_basic.py ================================================ """ Basic integration tests for the Terraform language server functionality. These tests validate the functionality of the language server APIs like request_references using the test repository. """ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language @pytest.mark.terraform class TestLanguageServerBasics: """Test basic functionality of the Terraform language server.""" @pytest.mark.parametrize("language_server", [Language.TERRAFORM], indirect=True) def test_basic_definition(self, language_server: SolidLanguageServer) -> None: """Test basic definition lookup functionality.""" # Simple test to verify the language server is working file_path = "main.tf" # Just try to get document symbols - this should work without hanging symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() assert len(symbols) > 0, "Should find at least some symbols in main.tf" @pytest.mark.parametrize("language_server", [Language.TERRAFORM], indirect=True) def test_request_references_aws_instance(self, language_server: SolidLanguageServer) -> None: """Test request_references on an aws_instance resource.""" # Get references to an aws_instance resource in main.tf file_path = "main.tf" # Find aws_instance resources symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() aws_instance_symbol = next((s for s in symbols[0] if s.get("name") == 'resource "aws_instance" "web_server"'), None) if not aws_instance_symbol or "selectionRange" not in aws_instance_symbol: raise AssertionError("aws_instance symbol or its selectionRange not found") sel_start = aws_instance_symbol["selectionRange"]["start"] references = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) assert len(references) >= 1, "aws_instance should be referenced at least once" @pytest.mark.parametrize("language_server", [Language.TERRAFORM], indirect=True) def test_request_references_variable(self, language_server: SolidLanguageServer) -> None: """Test request_references on a variable.""" # Get references to a variable in variables.tf file_path = "variables.tf" # Find variable definitions symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() var_symbol = next((s for s in symbols[0] if s.get("name") == 'variable "instance_type"'), None) if not var_symbol or "selectionRange" not in var_symbol: raise AssertionError("variable symbol or its selectionRange not found") sel_start = var_symbol["selectionRange"]["start"] references = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) assert len(references) >= 1, "variable should be referenced at least once" ================================================ FILE: test/solidlsp/test_ls_common.py ================================================ import os import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language class TestLanguageServerCommonFunctionality: """Test common functionality of SolidLanguageServer base implementation (not language-specific behaviour).""" @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) def test_open_file_cache_invalidate(self, language_server: SolidLanguageServer) -> None: """ Tests that the file buffer cache is invalidated when the file is changed on disk. """ file_path = os.path.join(language_server.repository_root_path, "test_open_file.py") test_string1 = "# foo" test_string2 = "# bar" with open(file_path, "w") as f: f.write(test_string1) try: with language_server.open_file(file_path) as fb: assert fb.contents == test_string1 # apply external change to file with open(file_path, "w") as f: f.write(test_string2) # Explicitly bump mtime into the future so the cache sees a change. # Relying on natural mtime advancement is flaky because many filesystems # (ext4, tmpfs) have only 1-second mtime granularity, and both writes # can land in the same second. stat = os.stat(file_path) os.utime(file_path, (stat.st_atime, stat.st_mtime + 2)) # check that the file buffer has been invalidated and reloaded assert fb.contents == test_string2 finally: os.remove(file_path) ================================================ FILE: test/solidlsp/test_lsp_protocol_handler_server.py ================================================ """ Tests for JSON-RPC 2.0 params field handling in LSP protocol. These tests verify the correct handling of the params field in LSP requests and notifications, specifically ensuring: - Void-type methods (shutdown, exit) omit params field entirely - Methods with explicit params include them unchanged - Methods with None params receive params: {} for Delphi/FPC compatibility Reference: JSON-RPC 2.0 spec - params field is optional but must be object/array when present. """ from typing import Any import pytest from solidlsp.lsp_protocol_handler.server import make_notification, make_request # ============================================================================= # Shared Assertion Helpers (DRY extraction per AI Panel recommendation) # ============================================================================= def assert_jsonrpc_structure( result: dict[str, Any], expected_method: str, expected_keys: set[str], *, expected_id: Any | None = None, ) -> None: """Verify JSON-RPC 2.0 structural requirements with 5-point error messages. Args: result: The dict returned by make_request/make_notification expected_method: The method name that should be in the result expected_keys: Exact set of keys that should be present expected_id: If provided, verify the id field matches (for requests) """ # Verify jsonrpc field assert "jsonrpc" in result, ( f"STRUCTURE ERROR: Missing required 'jsonrpc' field.\n" f"Expected: jsonrpc='2.0'\n" f"Actual keys: {list(result.keys())}\n" f"GUIDANCE: All JSON-RPC 2.0 messages must include jsonrpc field." ) assert result["jsonrpc"] == "2.0", ( f"STRUCTURE ERROR: Invalid jsonrpc version.\n" f"Expected: '2.0'\n" f"Actual: {result['jsonrpc']!r}\n" f"GUIDANCE: JSON-RPC 2.0 requires jsonrpc='2.0' exactly." ) # Verify method field assert "method" in result, ( f"STRUCTURE ERROR: Missing required 'method' field.\n" f"Expected: method='{expected_method}'\n" f"Actual keys: {list(result.keys())}\n" f"GUIDANCE: All requests/notifications must include method field." ) assert result["method"] == expected_method, ( f"STRUCTURE ERROR: Method mismatch.\n" f"Expected: '{expected_method}'\n" f"Actual: {result['method']!r}\n" f"GUIDANCE: Method field must match the requested method name." ) # Verify id field if expected (requests only) if expected_id is not None: assert "id" in result, ( f"STRUCTURE ERROR: Missing required 'id' field for request.\n" f"Expected: id={expected_id!r}\n" f"Actual keys: {list(result.keys())}\n" f"GUIDANCE: JSON-RPC 2.0 requests must include id field." ) assert result["id"] == expected_id, ( f"STRUCTURE ERROR: Request ID mismatch.\n" f"Expected: {expected_id!r}\n" f"Actual: {result['id']!r}\n" f"GUIDANCE: Request ID must be preserved exactly as provided." ) # Verify exact key set actual_keys = set(result.keys()) if actual_keys != expected_keys: extra = sorted(actual_keys - expected_keys) missing = sorted(expected_keys - actual_keys) pytest.fail( f"STRUCTURE ERROR: Key set mismatch for method '{expected_method}'.\n" f"Expected keys: {sorted(expected_keys)}\n" f"Actual keys: {sorted(actual_keys)}\n" f"Extra keys: {extra}\n" f"Missing keys: {missing}\n" f"GUIDANCE: Verify key construction logic for Void-type vs normal methods." ) def assert_params_omitted(result: dict[str, Any], method: str, req_id: str, input_params: Any = None) -> None: """Assert that params field is NOT present (for Void-type methods). Args: result: The dict returned by make_request/make_notification method: Method name for error message context req_id: Requirement ID (e.g., 'REQ-1', 'REQ-AI-PANEL-GAP') input_params: If provided, shows what params were passed (for explicit params tests) """ if "params" in result: input_note = f"\nInput params: {input_params}" if input_params is not None else "" pytest.fail( f"{req_id} VIOLATED: {method} method MUST omit params field entirely.{input_note}\n" f"Expected: No 'params' key in result\n" f"Actual: params={result.get('params')!r}\n" f"Actual keys: {list(result.keys())}\n" f"REASON: HLS/rust-analyzer Void types reject any params field (even empty object).\n" f"GUIDANCE: Void-type constraint takes precedence - implementation must omit params entirely." ) def assert_params_equal(result: dict[str, Any], expected_params: Any, req_id: str) -> None: """Assert that params field equals expected value. Args: result: The dict returned by make_request/make_notification expected_params: The exact params value expected req_id: Requirement ID for error message context """ if "params" not in result: pytest.fail( f"{req_id} VIOLATED: params field missing.\n" f"Expected: params={expected_params!r}\n" f"Actual keys: {list(result.keys())}\n" f"GUIDANCE: Non-Void methods must include params field." ) if result["params"] != expected_params: pytest.fail( f"{req_id} VIOLATED: params value mismatch.\n" f"Expected: {expected_params!r}\n" f"Actual: {result['params']!r}\n" f"GUIDANCE: Params must be included exactly as provided (or {{}} for None)." ) class TestMakeNotificationParamsHandling: """Test make_notification() params field handling per JSON-RPC 2.0 spec.""" def test_shutdown_method_omits_params_entirely(self) -> None: """REQ-1: Void-type method 'shutdown' MUST omit params field entirely.""" result = make_notification("shutdown", None) assert_jsonrpc_structure(result, "shutdown", {"jsonrpc", "method"}) assert_params_omitted(result, "shutdown", "REQ-1") def test_exit_method_omits_params_entirely(self) -> None: """REQ-1: Void-type method 'exit' MUST omit params field entirely.""" result = make_notification("exit", None) assert_jsonrpc_structure(result, "exit", {"jsonrpc", "method"}) assert_params_omitted(result, "exit", "REQ-1") def test_notification_with_explicit_params_dict(self) -> None: """REQ-2: Methods with explicit params MUST include them unchanged.""" test_params = {"uri": "file:///test.py", "languageId": "python"} result = make_notification("textDocument/didOpen", test_params) assert_jsonrpc_structure(result, "textDocument/didOpen", {"jsonrpc", "method", "params"}) assert_params_equal(result, test_params, "REQ-2") def test_notification_with_explicit_params_list(self) -> None: """REQ-2: Methods with explicit params (list) MUST include them unchanged.""" test_params = ["arg1", "arg2", "arg3"] result = make_notification("custom/method", test_params) assert_jsonrpc_structure(result, "custom/method", {"jsonrpc", "method", "params"}) assert_params_equal(result, test_params, "REQ-2") def test_notification_with_none_params_sends_empty_dict(self) -> None: """REQ-3: Methods with None params MUST send params: {} (Delphi/FPC compat).""" result = make_notification("textDocument/didChange", None) assert_jsonrpc_structure(result, "textDocument/didChange", {"jsonrpc", "method", "params"}) assert_params_equal(result, {}, "REQ-3") def test_notification_with_empty_dict_params(self) -> None: """REQ-2: Explicit empty dict params MUST be included unchanged.""" result = make_notification("custom/notify", {}) assert_jsonrpc_structure(result, "custom/notify", {"jsonrpc", "method", "params"}) assert_params_equal(result, {}, "REQ-2") class TestMakeRequestParamsHandling: """Test make_request() params field handling per JSON-RPC 2.0 spec.""" def test_shutdown_request_omits_params_entirely(self) -> None: """REQ-1: Void-type method 'shutdown' MUST omit params field entirely (requests).""" result = make_request("shutdown", request_id=1, params=None) assert_jsonrpc_structure(result, "shutdown", {"jsonrpc", "method", "id"}, expected_id=1) assert_params_omitted(result, "shutdown", "REQ-1") def test_request_with_explicit_params_dict(self) -> None: """REQ-2: Requests with explicit params MUST include them unchanged.""" test_params = {"textDocument": {"uri": "file:///test.py"}, "position": {"line": 10, "character": 5}} result = make_request("textDocument/hover", request_id=42, params=test_params) assert_jsonrpc_structure(result, "textDocument/hover", {"jsonrpc", "method", "id", "params"}, expected_id=42) assert_params_equal(result, test_params, "REQ-2") def test_request_with_none_params_sends_empty_dict(self) -> None: """REQ-3: Requests with None params MUST send params: {} (Delphi/FPC compat).""" result = make_request("workspace/configuration", request_id=100, params=None) assert_jsonrpc_structure(result, "workspace/configuration", {"jsonrpc", "method", "id", "params"}, expected_id=100) assert_params_equal(result, {}, "REQ-3") def test_request_id_preservation(self) -> None: """Verify request_id is correctly included in result (string ID).""" test_id = "unique-request-123" result = make_request("custom/request", request_id=test_id, params={"key": "value"}) assert_jsonrpc_structure(result, "custom/request", {"jsonrpc", "method", "id", "params"}, expected_id=test_id) def test_request_with_explicit_params_list(self) -> None: """REQ-2: Requests with explicit params (list) MUST include them unchanged.""" test_params = [1, 2, 3] result = make_request("custom/sum", request_id=99, params=test_params) assert_jsonrpc_structure(result, "custom/sum", {"jsonrpc", "method", "id", "params"}, expected_id=99) assert_params_equal(result, test_params, "REQ-2") class TestVoidMethodsExhaustive: """Test all methods that should be treated as Void-type (no params).""" def test_shutdown_request_ignores_explicit_params_dict(self) -> None: """REQ-AI-PANEL-GAP: shutdown MUST omit params even when caller explicitly provides params.""" explicit_params = {"key": "value", "another": "param"} result = make_request("shutdown", request_id=1, params=explicit_params) assert_jsonrpc_structure(result, "shutdown", {"jsonrpc", "method", "id"}, expected_id=1) assert_params_omitted(result, "shutdown", "REQ-AI-PANEL-GAP", input_params=explicit_params) def test_exit_notification_ignores_explicit_params(self) -> None: """REQ-AI-PANEL-GAP: exit MUST omit params even when caller explicitly provides params.""" explicit_params = {"unexpected": "params"} result = make_notification("exit", explicit_params) assert_jsonrpc_structure(result, "exit", {"jsonrpc", "method"}) assert_params_omitted(result, "exit", "REQ-AI-PANEL-GAP", input_params=explicit_params) def test_only_shutdown_and_exit_are_void_methods(self) -> None: """REQ-BOUNDARY: Verify EXACTLY shutdown/exit are Void-type - no more, no less.""" # Positive verification: shutdown and exit MUST omit params shutdown_notif = make_notification("shutdown", None) exit_notif = make_notification("exit", None) shutdown_req = make_request("shutdown", 1, None) assert "params" not in shutdown_notif, "shutdown notification should omit params" assert "params" not in exit_notif, "exit notification should omit params" assert "params" not in shutdown_req, "shutdown request should omit params" # Negative verification: other methods MUST include params (even when None -> {}) non_void_methods = [ "initialize", "initialized", "textDocument/didOpen", "textDocument/didChange", "textDocument/didClose", "workspace/didChangeConfiguration", "workspace/didChangeWatchedFiles", ] for method in non_void_methods: result_notif = make_notification(method, None) result_req = make_request(method, 1, None) if "params" not in result_notif: pytest.fail( f"BOUNDARY VIOLATION: '{method}' notification treated as Void-type.\n" f"Expected: params field present (should be {{}})\n" f"Actual keys: {list(result_notif.keys())}\n" f"GUIDANCE: Only 'shutdown' and 'exit' should omit params field." ) assert_params_equal(result_notif, {}, f"REQ-3 ({method} notification)") if "params" not in result_req: pytest.fail( f"BOUNDARY VIOLATION: '{method}' request treated as Void-type.\n" f"Expected: params field present (should be {{}})\n" f"Actual keys: {list(result_req.keys())}\n" f"GUIDANCE: Only 'shutdown' and 'exit' should omit params field." ) assert_params_equal(result_req, {}, f"REQ-3 ({method} request)") ================================================ FILE: test/solidlsp/test_rename_didopen.py ================================================ from unittest.mock import MagicMock from solidlsp.ls import SolidLanguageServer class DummyLanguageServer(SolidLanguageServer): def _start_server(self) -> None: raise AssertionError("Not used in this test") def test_request_rename_symbol_edit_opens_file_before_rename(tmp_path) -> None: (tmp_path / "index.ts").write_text("export const x = 1;\n", encoding="utf-8") events: list[str] = [] notify = MagicMock() notify.did_open_text_document.side_effect = lambda *_args, **_kwargs: events.append("didOpen") notify.did_close_text_document.side_effect = lambda *_args, **_kwargs: events.append("didClose") send = MagicMock() send.rename.side_effect = lambda *_args, **_kwargs: events.append("rename") server = MagicMock() server.notify = notify server.send = send language_server = object.__new__(DummyLanguageServer) language_server.repository_root_path = str(tmp_path) language_server.server_started = True language_server.open_file_buffers = {} language_server._encoding = "utf-8" language_server.language_id = "typescript" language_server.server = server result = language_server.request_rename_symbol_edit( relative_file_path="index.ts", line=0, column=0, new_name="y", ) assert result is None assert events == ["didOpen", "rename", "didClose"] ================================================ FILE: test/solidlsp/toml/__init__.py ================================================ """TOML language server tests.""" ================================================ FILE: test/solidlsp/toml/test_toml_basic.py ================================================ """ Basic integration tests for the TOML language server functionality. These tests validate the functionality of the Taplo language server APIs like request_document_symbols using the TOML test repository. """ from pathlib import Path import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language @pytest.mark.toml class TestTomlLanguageServerBasics: """Test basic functionality of the TOML language server (Taplo).""" @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) def test_toml_language_server_initialization(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test that TOML language server can be initialized successfully.""" assert language_server is not None assert language_server.language == Language.TOML assert language_server.is_running() assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve() @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) def test_toml_cargo_file_symbols(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test document symbols detection in Cargo.toml with specific symbol verification.""" all_symbols, root_symbols = language_server.request_document_symbols("Cargo.toml").get_all_symbols_and_roots() assert all_symbols is not None, "Should return symbols for Cargo.toml" assert len(all_symbols) > 0, f"Should find symbols in Cargo.toml, found {len(all_symbols)}" # Verify specific top-level table names are detected symbol_names = [sym.get("name") for sym in all_symbols] assert "package" in symbol_names, "Should detect 'package' table in Cargo.toml" assert "dependencies" in symbol_names, "Should detect 'dependencies' table in Cargo.toml" assert "dev-dependencies" in symbol_names, "Should detect 'dev-dependencies' table in Cargo.toml" assert "features" in symbol_names, "Should detect 'features' table in Cargo.toml" assert "workspace" in symbol_names, "Should detect 'workspace' table in Cargo.toml" # Verify nested symbols exist (keys under 'package') assert "name" in symbol_names, "Should detect nested 'name' key" assert "version" in symbol_names, "Should detect nested 'version' key" assert "edition" in symbol_names, "Should detect nested 'edition' key" # Check symbol kind for tables - Taplo uses kind 19 (object) for TOML tables package_symbol = next((s for s in all_symbols if s.get("name") == "package"), None) assert package_symbol is not None, "Should find 'package' symbol" assert package_symbol.get("kind") == 19, "Top-level table should have kind 19 (object)" dependencies_symbol = next((s for s in all_symbols if s.get("name") == "dependencies"), None) assert dependencies_symbol is not None, "Should find 'dependencies' symbol" assert dependencies_symbol.get("kind") == 19, "'dependencies' table should have kind 19 (object)" @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) def test_toml_pyproject_file_symbols(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test document symbols detection in pyproject.toml.""" all_symbols, root_symbols = language_server.request_document_symbols("pyproject.toml").get_all_symbols_and_roots() assert all_symbols is not None, "Should return symbols for pyproject.toml" assert len(all_symbols) > 0, f"Should find symbols in pyproject.toml, found {len(all_symbols)}" # Verify specific top-level table names symbol_names = [sym.get("name") for sym in all_symbols] assert "project" in symbol_names, "Should detect 'project' table" assert "build-system" in symbol_names, "Should detect 'build-system' table" # Verify tool sections (nested tables) # These could appear as 'tool' or 'tool.ruff' depending on Taplo's parsing has_tool_section = any("tool" in name for name in symbol_names if name) assert has_tool_section, "Should detect tool sections" # Verify nested keys under project assert "name" in symbol_names, "Should detect 'name' under project" assert "version" in symbol_names, "Should detect 'version' under project" assert "requires-python" in symbol_names or "dependencies" in symbol_names, "Should detect project dependencies" # Check symbol kind for tables - Taplo uses kind 19 (object) for TOML tables project_symbol = next((s for s in all_symbols if s.get("name") == "project"), None) assert project_symbol is not None, "Should find 'project' symbol" assert project_symbol.get("kind") == 19, "'project' table should have kind 19 (object)" @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) def test_toml_symbol_kinds(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test that TOML symbols have appropriate LSP kinds for different value types.""" all_symbols, root_symbols = language_server.request_document_symbols("Cargo.toml").get_all_symbols_and_roots() assert all_symbols is not None assert len(all_symbols) > 0 # Check boolean symbol kind (lto = true at line 22) # LSP kind 17 = boolean lto_symbol = next((s for s in all_symbols if s.get("name") == "lto"), None) assert lto_symbol is not None, "Should find 'lto' boolean symbol" assert lto_symbol.get("kind") == 17, "'lto' should have kind 17 (boolean)" # Check number symbol kind (opt-level = 3 at line 23) # LSP kind 16 = number opt_level_symbol = next((s for s in all_symbols if s.get("name") == "opt-level"), None) assert opt_level_symbol is not None, "Should find 'opt-level' number symbol" assert opt_level_symbol.get("kind") == 16, "'opt-level' should have kind 16 (number)" # Check string symbol kind (name = "test_project" at line 2) # LSP kind 15 = string name_symbols = [s for s in all_symbols if s.get("name") == "name"] assert len(name_symbols) > 0, "Should find 'name' symbols" # At least one should be a string string_name_symbol = next((s for s in name_symbols if s.get("kind") == 15), None) assert string_name_symbol is not None, "Should find 'name' with kind 15 (string)" # Check array symbol kind (default = ["feature1"] at line 17) # LSP kind 18 = array default_symbol = next((s for s in all_symbols if s.get("name") == "default"), None) assert default_symbol is not None, "Should find 'default' array symbol" assert default_symbol.get("kind") == 18, "'default' should have kind 18 (array)" @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) def test_toml_symbols_with_body(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test request_document_symbols with body extraction.""" all_symbols, root_symbols = language_server.request_document_symbols("Cargo.toml").get_all_symbols_and_roots() assert all_symbols is not None, "Should return symbols for Cargo.toml" assert len(all_symbols) > 0, "Should have symbols" # Find the 'package' symbol and verify its body package_symbol = next((s for s in all_symbols if s.get("name") == "package"), None) assert package_symbol is not None, "Should find 'package' symbol" # Check that body exists and contains expected content # Note: Taplo includes the section header in the body assert "body" in package_symbol, "'package' symbol should have body" package_body = package_symbol["body"].get_text() assert 'name = "test_project"' in package_body, "Body should contain 'name' field" assert 'version = "0.1.0"' in package_body, "Body should contain 'version' field" assert 'edition = "2021"' in package_body, "Body should contain 'edition' field" # Find the dependencies symbol and check its body deps_symbol = next((s for s in all_symbols if s.get("name") == "dependencies"), None) assert deps_symbol is not None, "Should find 'dependencies' symbol" assert "body" in deps_symbol, "'dependencies' symbol should have body" deps_body = deps_symbol["body"].get_text() assert "serde" in deps_body, "Body should contain serde dependency" assert "tokio" in deps_body, "Body should contain tokio dependency" # Find the top-level [features] section (not the nested 'features' in serde dependency) # The [features] section should be kind 19 (object) and at line 15 (0-indexed) features_symbols = [s for s in all_symbols if s.get("name") == "features"] # Find the top-level one - should be kind 19 (object) with children features_symbol = next( (s for s in features_symbols if s.get("kind") == 19 and s.get("children")), None, ) assert features_symbol is not None, "Should find top-level 'features' table symbol" assert "body" in features_symbol, "'features' symbol should have body" features_body = features_symbol["body"].get_text() assert "default" in features_body, "Body should contain 'default' feature" @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) def test_toml_symbol_ranges(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test that symbols have proper range information.""" all_symbols, root_symbols = language_server.request_document_symbols("Cargo.toml").get_all_symbols_and_roots() assert all_symbols is not None assert len(all_symbols) > 0 # Check the 'package' symbol range - should start at line 0 (0-indexed, actual line 1) package_symbol = next((s for s in all_symbols if s.get("name") == "package"), None) assert package_symbol is not None, "Should find 'package' symbol" assert "range" in package_symbol, "'package' symbol should have range" package_range = package_symbol["range"] assert "start" in package_range, "Range should have start" assert "end" in package_range, "Range should have end" assert package_range["start"]["line"] == 0, "'package' should start at line 0 (0-indexed, actual line 1)" # Package block spans from line 1 to line 7 in file (1-indexed) # In 0-indexed LSP coordinates: line 0 (start) to line 6 or 7 (end) assert package_range["end"]["line"] >= 6, "'package' should end at or after line 6 (0-indexed)" # Check a nested symbol range - 'name' under package at line 2 (1-indexed), line 1 (0-indexed) name_symbols = [s for s in all_symbols if s.get("name") == "name"] assert len(name_symbols) > 0, "Should find 'name' symbols" # Find the one under 'package' (should be at line 1 in 0-indexed) package_name = next((s for s in name_symbols if s["range"]["start"]["line"] == 1), None) assert package_name is not None, "Should find 'name' under 'package'" # Check the dependencies range - starts at line 9 (1-indexed), line 8 (0-indexed) deps_symbol = next((s for s in all_symbols if s.get("name") == "dependencies"), None) assert deps_symbol is not None, "Should find 'dependencies' symbol" deps_range = deps_symbol["range"] assert deps_range["start"]["line"] == 8, "'dependencies' should start at line 8 (0-indexed, actual line 9)" # Check that range includes line and character positions assert "line" in package_range["start"], "Start should have line" assert "character" in package_range["start"], "Start should have character" assert "line" in package_range["end"], "End should have line" assert "character" in package_range["end"], "End should have character" @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) def test_toml_nested_table_symbols(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test detection of nested table symbols like profile.release and tool.ruff.""" # Test Cargo.toml for profile.release cargo_symbols, _ = language_server.request_document_symbols("Cargo.toml").get_all_symbols_and_roots() assert cargo_symbols is not None symbol_names = [sym.get("name") for sym in cargo_symbols] # Should detect profile.release or profile section has_profile = any("profile" in name for name in symbol_names if name) assert has_profile, "Should detect profile section in Cargo.toml" # Test pyproject.toml for tool sections pyproject_symbols, _ = language_server.request_document_symbols("pyproject.toml").get_all_symbols_and_roots() assert pyproject_symbols is not None pyproject_names = [sym.get("name") for sym in pyproject_symbols] # Should detect tool.ruff, tool.mypy sections has_ruff = any("ruff" in name for name in pyproject_names if name) has_mypy = any("mypy" in name for name in pyproject_names if name) assert has_ruff or has_mypy, "Should detect tool sections in pyproject.toml" # Verify pyproject has expected boolean: strict = true strict_symbol = next((s for s in pyproject_symbols if s.get("name") == "strict"), None) if strict_symbol: assert strict_symbol.get("kind") == 17, "'strict' should have kind 17 (boolean)" ================================================ FILE: test/solidlsp/toml/test_toml_edge_cases.py ================================================ """ Tests for TOML language server edge cases and advanced features. These tests cover: - Inline tables - Multiline strings - Arrays of tables - Nested tables - Various TOML data types """ from pathlib import Path import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language pytestmark = pytest.mark.toml class TestTomlEdgeCases: """Test TOML language server handling of edge cases and advanced features.""" @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) def test_inline_table_detection(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test that inline tables are properly detected.""" all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() assert all_symbols is not None assert len(all_symbols) > 0 symbol_names = [sym.get("name") for sym in all_symbols] # The inline table 'endpoint' should be detected assert "endpoint" in symbol_names, "Should detect 'endpoint' inline table" # Find the endpoint symbol and check its properties endpoint_symbol = next((s for s in all_symbols if s.get("name") == "endpoint"), None) assert endpoint_symbol is not None # Inline tables should be kind 19 (object) assert endpoint_symbol.get("kind") == 19, "Inline table should have kind 19 (object)" @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) def test_nested_table_detection(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test that deeply nested tables are properly detected.""" all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() symbol_names = [sym.get("name") for sym in all_symbols] # Should detect nested tables like server.ssl and database.pool has_ssl = any("ssl" in str(name).lower() for name in symbol_names if name) has_pool = any("pool" in str(name).lower() for name in symbol_names if name) assert has_ssl, f"Should detect 'server.ssl' nested table, got: {symbol_names}" assert has_pool, f"Should detect 'database.pool' nested table, got: {symbol_names}" @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) def test_array_of_tables_detection(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test that [[array_of_tables]] syntax is properly detected.""" all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() symbol_names = [sym.get("name") for sym in all_symbols] # Should detect [[endpoints]] array of tables assert "endpoints" in symbol_names, f"Should detect '[[endpoints]]' array of tables, got: {symbol_names}" # Find the endpoints symbol endpoints_symbol = next((s for s in all_symbols if s.get("name") == "endpoints"), None) assert endpoints_symbol is not None # Array of tables should be kind 18 (array) assert endpoints_symbol.get("kind") == 18, "Array of tables should have kind 18 (array)" @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) def test_multiline_string_handling(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test that multiline strings are handled correctly.""" all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() symbol_names = [sym.get("name") for sym in all_symbols] # Should detect connection_string and multiline fields assert "connection_string" in symbol_names, "Should detect 'connection_string' with multiline value" assert "multiline" in symbol_names, "Should detect 'multiline' literal string" # Find connection_string and verify it's a string type conn_symbol = next((s for s in all_symbols if s.get("name") == "connection_string"), None) assert conn_symbol is not None # String type should be kind 15 assert conn_symbol.get("kind") == 15, "Multiline string should have kind 15 (string)" @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) def test_array_value_detection(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test that array values are properly detected.""" all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() symbol_names = [sym.get("name") for sym in all_symbols] # Should detect 'outputs' and 'methods' arrays assert "outputs" in symbol_names, "Should detect 'outputs' array" assert "methods" in symbol_names, "Should detect 'methods' array" # Find outputs array and verify kind outputs_symbol = next((s for s in all_symbols if s.get("name") == "outputs"), None) assert outputs_symbol is not None # Arrays should have kind 18 assert outputs_symbol.get("kind") == 18, "'outputs' should have kind 18 (array)" @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) def test_float_value_detection(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test that float values are properly detected.""" all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() symbol_names = [sym.get("name") for sym in all_symbols] # Should detect 'timeout' which has a float value (30.5) assert "timeout" in symbol_names, "Should detect 'timeout' float value" # Find timeout and verify it's a number timeout_symbol = next((s for s in all_symbols if s.get("name") == "timeout"), None) assert timeout_symbol is not None # Numbers should have kind 16 assert timeout_symbol.get("kind") == 16, "'timeout' should have kind 16 (number)" @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) def test_datetime_value_detection(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test that datetime values are detected.""" all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() symbol_names = [sym.get("name") for sym in all_symbols] # Should detect metadata section with datetime values assert "metadata" in symbol_names, "Should detect 'metadata' section" assert "created" in symbol_names, "Should detect 'created' datetime field" assert "updated" in symbol_names, "Should detect 'updated' datetime field" @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) def test_symbol_body_with_inline_table(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test that symbol bodies include inline table content.""" all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() # Find the endpoint symbol with body endpoint_symbol = next((s for s in all_symbols if s.get("name") == "endpoint"), None) assert endpoint_symbol is not None if "body" in endpoint_symbol: body = endpoint_symbol["body"].get_text() # Body should contain the inline table syntax assert "url" in body or "version" in body, f"Body should contain inline table contents, got: {body}" @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) def test_symbol_ranges_in_config(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test that symbol ranges are correct in config.toml.""" all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() # Find the server symbol server_symbol = next((s for s in all_symbols if s.get("name") == "server"), None) assert server_symbol is not None assert "range" in server_symbol # Server should start near the beginning (line 2 is [server], 0-indexed: line 2) server_range = server_symbol["range"] assert server_range["start"]["line"] >= 0, "Server should start at or near the beginning" assert server_range["end"]["line"] > server_range["start"]["line"], "Server block should span multiple lines" @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) def test_comment_handling(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test that comments don't interfere with symbol detection.""" all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() symbol_names = [sym.get("name") for sym in all_symbols] # File has comments but symbols should still be detected correctly expected_sections = {"server", "database", "logging", "endpoints", "metadata", "messages"} found_sections = expected_sections.intersection(set(symbol_names)) assert len(found_sections) >= 4, f"Should find most sections despite comments, found: {found_sections}" @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) def test_special_characters_in_strings(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test that strings with escape sequences are handled.""" all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() symbol_names = [sym.get("name") for sym in all_symbols] # Should detect the messages section with special strings assert "messages" in symbol_names, "Should detect 'messages' section" assert "with_escapes" in symbol_names, "Should detect 'with_escapes' field" assert "welcome" in symbol_names, "Should detect 'welcome' field" class TestTomlDependencyTables: """Test handling of dependency-style tables common in Cargo.toml and pyproject.toml.""" @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) def test_complex_dependency_inline_table(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test detection of complex inline table dependencies like serde = { version = "1.0", features = ["derive"] }.""" all_symbols, root_symbols = language_server.request_document_symbols("Cargo.toml").get_all_symbols_and_roots() symbol_names = [sym.get("name") for sym in all_symbols] # Should detect serde and tokio dependencies assert "serde" in symbol_names, "Should detect 'serde' dependency" assert "tokio" in symbol_names, "Should detect 'tokio' dependency" # Find serde symbol serde_symbol = next((s for s in all_symbols if s.get("name") == "serde"), None) assert serde_symbol is not None # Dependency with inline table should be kind 19 (object) assert serde_symbol.get("kind") == 19, "Complex dependency should have kind 19 (object)" @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) def test_simple_dependency_string(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test detection of simple string dependencies like proptest = "1.0".""" all_symbols, root_symbols = language_server.request_document_symbols("Cargo.toml").get_all_symbols_and_roots() symbol_names = [sym.get("name") for sym in all_symbols] # Should detect proptest dev-dependency assert "proptest" in symbol_names, "Should detect 'proptest' dependency" # Find proptest symbol proptest_symbol = next((s for s in all_symbols if s.get("name") == "proptest"), None) assert proptest_symbol is not None # Simple string dependency should be kind 15 (string) assert proptest_symbol.get("kind") == 15, "Simple string dependency should have kind 15 (string)" @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) def test_pyproject_dependencies_array(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test detection of pyproject.toml dependencies array.""" all_symbols, root_symbols = language_server.request_document_symbols("pyproject.toml").get_all_symbols_and_roots() symbol_names = [sym.get("name") for sym in all_symbols] # Should detect dependencies array assert "dependencies" in symbol_names, "Should detect 'dependencies' array" # Find dependencies symbol deps_symbol = next((s for s in all_symbols if s.get("name") == "dependencies"), None) assert deps_symbol is not None # Dependencies array should be kind 18 (array) assert deps_symbol.get("kind") == 18, "Dependencies array should have kind 18 (array)" @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) def test_optional_dependencies_table(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test detection of optional-dependencies in pyproject.toml.""" all_symbols, root_symbols = language_server.request_document_symbols("pyproject.toml").get_all_symbols_and_roots() symbol_names = [sym.get("name") for sym in all_symbols] # Should detect optional-dependencies or its nested form has_optional_deps = any("optional" in str(name).lower() for name in symbol_names if name) has_dev = "dev" in symbol_names assert has_optional_deps or has_dev, f"Should detect optional-dependencies or dev group, got: {symbol_names}" ================================================ FILE: test/solidlsp/toml/test_toml_ignored_dirs.py ================================================ """ Tests for TOML language server directory ignoring functionality. These tests validate that the Taplo language server correctly ignores TOML-specific directories like target, .cargo, and node_modules. """ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language pytestmark = pytest.mark.toml @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) class TestTomlIgnoredDirectories: """Test TOML-specific directory ignoring behavior.""" def test_default_ignored_directories(self, language_server: SolidLanguageServer) -> None: """Test that default TOML directories are ignored.""" # Test that TOML/Rust/Node-specific directories are ignored by default assert language_server.is_ignored_dirname("target"), "target should be ignored" assert language_server.is_ignored_dirname(".cargo"), ".cargo should be ignored" assert language_server.is_ignored_dirname("node_modules"), "node_modules should be ignored" # Directories starting with . are ignored by base class assert language_server.is_ignored_dirname(".git"), ".git should be ignored" assert language_server.is_ignored_dirname(".venv"), ".venv should be ignored" def test_important_directories_not_ignored(self, language_server: SolidLanguageServer) -> None: """Test that important directories are not ignored.""" # Common project directories should not be ignored assert not language_server.is_ignored_dirname("src"), "src should not be ignored" assert not language_server.is_ignored_dirname("crates"), "crates should not be ignored" assert not language_server.is_ignored_dirname("lib"), "lib should not be ignored" assert not language_server.is_ignored_dirname("tests"), "tests should not be ignored" assert not language_server.is_ignored_dirname("config"), "config should not be ignored" def test_cargo_related_directories(self, language_server: SolidLanguageServer) -> None: """Test Cargo/Rust-related directory handling.""" # Rust build directories should be ignored assert language_server.is_ignored_dirname("target"), "target (Rust build) should be ignored" assert language_server.is_ignored_dirname(".cargo"), ".cargo should be ignored" # But important Rust directories should not be ignored assert not language_server.is_ignored_dirname("benches"), "benches should not be ignored" assert not language_server.is_ignored_dirname("examples"), "examples should not be ignored" def test_various_cache_directories(self, language_server: SolidLanguageServer) -> None: """Test various cache and temporary directories are ignored.""" # Directories starting with . are ignored by base class assert language_server.is_ignored_dirname(".cache"), ".cache should be ignored" # IDE directories (start with .) assert language_server.is_ignored_dirname(".idea"), ".idea should be ignored" assert language_server.is_ignored_dirname(".vscode"), ".vscode should be ignored" # Note: __pycache__ is NOT ignored by TOML server (only Python servers ignore it) assert not language_server.is_ignored_dirname("__pycache__"), "__pycache__ is not TOML-specific" ================================================ FILE: test/solidlsp/toml/test_toml_symbol_retrieval.py ================================================ """ Tests for TOML language server symbol retrieval functionality. These tests focus on advanced symbol operations: - request_containing_symbol - request_document_overview - request_full_symbol_tree - request_dir_overview """ from pathlib import Path import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language pytestmark = pytest.mark.toml class TestTomlSymbolRetrieval: """Test advanced symbol retrieval functionality for TOML files.""" @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) def test_request_containing_symbol_behavior(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test request_containing_symbol behavior for TOML files. Note: Taplo LSP doesn't support definition/containing symbol lookups for TOML files since TOML is a configuration format, not code. This test verifies the behavior. """ # Line 2 (0-indexed: 1) is inside the [package] table containing_symbol = language_server.request_containing_symbol("Cargo.toml", 1, 5) # Taplo doesn't support containing symbol lookup - returns None # This is expected behavior for a configuration file format assert containing_symbol is None, "TOML LSP doesn't support containing symbol lookup" @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) def test_request_document_overview_cargo(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test request_document_overview for Cargo.toml.""" overview = language_server.request_document_overview("Cargo.toml") assert overview is not None assert len(overview) > 0 # Get symbol names from overview symbol_names = {symbol.get("name") for symbol in overview if "name" in symbol} # Verify expected top-level tables appear expected_tables = {"package", "dependencies", "dev-dependencies", "features", "workspace"} assert expected_tables.issubset(symbol_names), f"Missing expected tables in overview: {expected_tables - symbol_names}" @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) def test_request_document_overview_pyproject(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test request_document_overview for pyproject.toml.""" overview = language_server.request_document_overview("pyproject.toml") assert overview is not None assert len(overview) > 0 # Get symbol names from overview symbol_names = {symbol.get("name") for symbol in overview if "name" in symbol} # Verify expected top-level tables appear assert "project" in symbol_names, "Should detect 'project' table" assert "build-system" in symbol_names, "Should detect 'build-system' table" @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) def test_request_full_symbol_tree(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test request_full_symbol_tree returns TOML files.""" symbol_tree = language_server.request_full_symbol_tree() assert symbol_tree is not None assert len(symbol_tree) > 0 # The root should be test_repo root = symbol_tree[0] assert root["name"] == "test_repo" assert "children" in root # Children should include TOML files child_names = {child["name"] for child in root.get("children", [])} # Note: File names are stripped of extension in some cases assert ( "Cargo" in child_names or "Cargo.toml" in child_names or any("cargo" in name.lower() for name in child_names) ), f"Should find Cargo.toml in tree, got: {child_names}" @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) def test_request_dir_overview(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test request_dir_overview returns symbols for TOML files.""" overview = language_server.request_dir_overview(".") assert overview is not None assert len(overview) > 0 # Should have entries for both Cargo.toml and pyproject.toml file_paths = list(overview.keys()) assert any("Cargo.toml" in path for path in file_paths), f"Should find Cargo.toml in overview, got: {file_paths}" assert any("pyproject.toml" in path for path in file_paths), f"Should find pyproject.toml in overview, got: {file_paths}" @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) def test_symbol_hierarchy_in_cargo(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test that symbol hierarchy is properly preserved in Cargo.toml.""" all_symbols, root_symbols = language_server.request_document_symbols("Cargo.toml").get_all_symbols_and_roots() # Find the 'package' table package_symbol = next((s for s in root_symbols if s.get("name") == "package"), None) assert package_symbol is not None, "Should find 'package' as root symbol" # Verify it has children (nested keys) assert "children" in package_symbol, "'package' should have children" child_names = {child.get("name") for child in package_symbol.get("children", [])} # Package should have name, version, edition at minimum assert "name" in child_names, "'package' should have 'name' child" assert "version" in child_names, "'package' should have 'version' child" assert "edition" in child_names, "'package' should have 'edition' child" @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) def test_symbol_hierarchy_in_pyproject(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test that symbol hierarchy is properly preserved in pyproject.toml.""" all_symbols, root_symbols = language_server.request_document_symbols("pyproject.toml").get_all_symbols_and_roots() # Find the 'project' table project_symbol = next((s for s in root_symbols if s.get("name") == "project"), None) assert project_symbol is not None, "Should find 'project' as root symbol" # Verify it has children assert "children" in project_symbol, "'project' should have children" child_names = {child.get("name") for child in project_symbol.get("children", [])} # Project should have name, version, dependencies at minimum assert "name" in child_names, "'project' should have 'name' child" assert "version" in child_names, "'project' should have 'version' child" @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) def test_tool_section_hierarchy(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test that tool sections in pyproject.toml are properly structured.""" all_symbols, root_symbols = language_server.request_document_symbols("pyproject.toml").get_all_symbols_and_roots() # Get all symbol names all_names = [s.get("name") for s in all_symbols] # Should detect tool.ruff, tool.mypy, or tool.pytest has_ruff = any("ruff" in name.lower() for name in all_names if name) has_mypy = any("mypy" in name.lower() for name in all_names if name) has_pytest = any("pytest" in name.lower() for name in all_names if name) assert has_ruff or has_mypy or has_pytest, f"Should detect tool sections, got names: {all_names}" @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) def test_array_of_tables_symbol(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test that [[bin]] array of tables is detected.""" all_symbols, root_symbols = language_server.request_document_symbols("Cargo.toml").get_all_symbols_and_roots() # Get all symbol names all_names = [s.get("name") for s in all_symbols] # Should detect bin array of tables has_bin = "bin" in all_names assert has_bin, f"Should detect [[bin]] array of tables, got names: {all_names}" # Find the bin symbol and verify its structure bin_symbol = next((s for s in all_symbols if s.get("name") == "bin"), None) assert bin_symbol is not None, "Should find bin symbol" # Array of tables should be kind 18 (array) assert bin_symbol.get("kind") == 18, "[[bin]] should have kind 18 (array)" # Children of array of tables are indexed by position ('0', '1', etc.) if "children" in bin_symbol: bin_children = bin_symbol.get("children", []) assert len(bin_children) > 0, "[[bin]] should have at least one child element" # First child is index '0' first_child = bin_children[0] assert first_child.get("name") == "0", f"First array element should be named '0', got: {first_child.get('name')}" # The '0' element should contain name and path as grandchildren if "children" in first_child: grandchild_names = {gc.get("name") for gc in first_child.get("children", [])} assert "name" in grandchild_names, f"[[bin]] element should have 'name' field, got: {grandchild_names}" assert "path" in grandchild_names, f"[[bin]] element should have 'path' field, got: {grandchild_names}" ================================================ FILE: test/solidlsp/typescript/test_typescript_basic.py ================================================ import os import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils @pytest.mark.typescript class TestTypescriptLanguageServer: @pytest.mark.parametrize("language_server", [Language.TYPESCRIPT], indirect=True) def test_find_symbol(self, language_server: SolidLanguageServer) -> None: symbols = language_server.request_full_symbol_tree() assert SymbolUtils.symbol_tree_contains_name(symbols, "DemoClass"), "DemoClass not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "helperFunction"), "helperFunction not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "printValue"), "printValue method not found in symbol tree" @pytest.mark.parametrize("language_server", [Language.TYPESCRIPT], indirect=True) def test_find_referencing_symbols(self, language_server: SolidLanguageServer) -> None: file_path = os.path.join("index.ts") symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() helper_symbol = None for sym in symbols[0]: if sym.get("name") == "helperFunction": helper_symbol = sym break assert helper_symbol is not None, "Could not find 'helperFunction' symbol in index.ts" sel_start = helper_symbol["selectionRange"]["start"] refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) assert any( "index.ts" in ref.get("relativePath", "") for ref in refs ), "index.ts should reference helperFunction (tried all positions in selectionRange)" ================================================ FILE: test/solidlsp/util/test_zip.py ================================================ import sys import zipfile from pathlib import Path import pytest from solidlsp.util.zip import SafeZipExtractor @pytest.fixture def temp_zip_file(tmp_path: Path) -> Path: """Create a temporary ZIP file for testing.""" zip_path = tmp_path / "test.zip" with zipfile.ZipFile(zip_path, "w") as zipf: zipf.writestr("file1.txt", "Hello World 1") zipf.writestr("file2.txt", "Hello World 2") zipf.writestr("folder/file3.txt", "Hello World 3") return zip_path def test_extract_all_success(temp_zip_file: Path, tmp_path: Path) -> None: """All files should extract without error.""" dest_dir = tmp_path / "extracted" extractor = SafeZipExtractor(temp_zip_file, dest_dir, verbose=False) extractor.extract_all() assert (dest_dir / "file1.txt").read_text() == "Hello World 1" assert (dest_dir / "file2.txt").read_text() == "Hello World 2" assert (dest_dir / "folder" / "file3.txt").read_text() == "Hello World 3" def test_include_patterns(temp_zip_file: Path, tmp_path: Path) -> None: """Only files matching include_patterns should be extracted.""" dest_dir = tmp_path / "extracted" extractor = SafeZipExtractor(temp_zip_file, dest_dir, verbose=False, include_patterns=["*.txt"]) extractor.extract_all() assert (dest_dir / "file1.txt").exists() assert (dest_dir / "file2.txt").exists() assert (dest_dir / "folder" / "file3.txt").exists() def test_exclude_patterns(temp_zip_file: Path, tmp_path: Path) -> None: """Files matching exclude_patterns should be skipped.""" dest_dir = tmp_path / "extracted" extractor = SafeZipExtractor(temp_zip_file, dest_dir, verbose=False, exclude_patterns=["file2.txt"]) extractor.extract_all() assert (dest_dir / "file1.txt").exists() assert not (dest_dir / "file2.txt").exists() assert (dest_dir / "folder" / "file3.txt").exists() def test_include_and_exclude_patterns(temp_zip_file: Path, tmp_path: Path) -> None: """Exclude should override include if both match.""" dest_dir = tmp_path / "extracted" extractor = SafeZipExtractor( temp_zip_file, dest_dir, verbose=False, include_patterns=["*.txt"], exclude_patterns=["file1.txt"], ) extractor.extract_all() assert not (dest_dir / "file1.txt").exists() assert (dest_dir / "file2.txt").exists() assert (dest_dir / "folder" / "file3.txt").exists() def test_skip_on_error(monkeypatch, temp_zip_file: Path, tmp_path: Path) -> None: """Should skip a file that raises an error and continue extracting others.""" dest_dir = tmp_path / "extracted" original_open = zipfile.ZipFile.open def failing_open(self, member, *args, **kwargs): if member.filename == "file2.txt": raise OSError("Simulated failure") return original_open(self, member, *args, **kwargs) # Patch the method on the class, not on an instance monkeypatch.setattr(zipfile.ZipFile, "open", failing_open) extractor = SafeZipExtractor(temp_zip_file, dest_dir, verbose=False) extractor.extract_all() assert (dest_dir / "file1.txt").exists() assert not (dest_dir / "file2.txt").exists() assert (dest_dir / "folder" / "file3.txt").exists() @pytest.mark.skipif(not sys.platform.startswith("win"), reason="Windows-only test") def test_long_path_normalization(temp_zip_file: Path, tmp_path: Path) -> None: r"""Ensure _normalize_path adds \\?\\ prefix on Windows.""" dest_dir = tmp_path / ("a" * 250) # Simulate long path extractor = SafeZipExtractor(temp_zip_file, dest_dir, verbose=False) norm_path = extractor._normalize_path(dest_dir / "file.txt") assert str(norm_path).startswith("\\\\?\\") ================================================ FILE: test/solidlsp/vue/__init__.py ================================================ """Vue language server tests.""" import shutil def _test_npm_available() -> str: """Test if npm is available and return error reason if not.""" # Check if npm is installed if not shutil.which("npm"): return "npm is not installed or not in PATH" return "" # No error, npm is available NPM_UNAVAILABLE_REASON = _test_npm_available() NPM_UNAVAILABLE = bool(NPM_UNAVAILABLE_REASON) ================================================ FILE: test/solidlsp/vue/test_vue_basic.py ================================================ import os import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils @pytest.mark.vue class TestVueLanguageServer: @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) def test_vue_files_in_symbol_tree(self, language_server: SolidLanguageServer) -> None: symbols = language_server.request_full_symbol_tree() assert SymbolUtils.symbol_tree_contains_name(symbols, "App"), "App not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "CalculatorButton"), "CalculatorButton not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "CalculatorInput"), "CalculatorInput not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "CalculatorDisplay"), "CalculatorDisplay not found in symbol tree" @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) def test_find_referencing_symbols(self, language_server: SolidLanguageServer) -> None: store_file = os.path.join("src", "stores", "calculator.ts") symbols = language_server.request_document_symbols(store_file).get_all_symbols_and_roots() # Find useCalculatorStore function store_symbol = None for sym in symbols[0]: if sym.get("name") == "useCalculatorStore": store_symbol = sym break assert store_symbol is not None, "useCalculatorStore function not found" # Get references sel_start = store_symbol["selectionRange"]["start"] refs = language_server.request_references(store_file, sel_start["line"], sel_start["character"]) # Should have multiple references: definition + usage in App.vue, CalculatorInput.vue, CalculatorDisplay.vue assert len(refs) >= 4, f"useCalculatorStore should have at least 4 references (definition + 3 usages), got {len(refs)}" # Verify we have references from .vue files vue_refs = [ref for ref in refs if ".vue" in ref.get("relativePath", "")] assert len(vue_refs) >= 3, f"Should have at least 3 Vue component references, got {len(vue_refs)}" @pytest.mark.vue class TestVueDualLspArchitecture: @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) def test_typescript_server_coordination(self, language_server: SolidLanguageServer) -> None: ts_file = os.path.join("src", "stores", "calculator.ts") ts_symbols = language_server.request_document_symbols(ts_file).get_all_symbols_and_roots() ts_symbol_names = [s.get("name") for s in ts_symbols[0]] assert len(ts_symbols[0]) >= 5, f"TypeScript server should return multiple symbols for calculator.ts, got {len(ts_symbols[0])}" assert "useCalculatorStore" in ts_symbol_names, "TypeScript server should extract store function" # Verify Vue server can parse .vue files vue_file = os.path.join("src", "App.vue") vue_symbols = language_server.request_document_symbols(vue_file).get_all_symbols_and_roots() vue_symbol_names = [s.get("name") for s in vue_symbols[0]] assert len(vue_symbols[0]) >= 15, f"Vue server should return at least 15 symbols for App.vue, got {len(vue_symbols[0])}" assert "appTitle" in vue_symbol_names, "Vue server should extract ref declarations from script setup" @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) def test_cross_file_references_vue_to_typescript(self, language_server: SolidLanguageServer) -> None: store_file = os.path.join("src", "stores", "calculator.ts") store_symbols = language_server.request_document_symbols(store_file).get_all_symbols_and_roots() store_symbol = None for sym in store_symbols[0]: if sym.get("name") == "useCalculatorStore": store_symbol = sym break if not store_symbol or "selectionRange" not in store_symbol: pytest.skip("useCalculatorStore symbol not found - test fixture may need updating") # Request references for this symbol sel_start = store_symbol["selectionRange"]["start"] refs = language_server.request_references(store_file, sel_start["line"], sel_start["character"]) # Verify we found references: definition + usage in App.vue, CalculatorInput.vue, CalculatorDisplay.vue assert len(refs) >= 4, f"useCalculatorStore should have at least 4 references (definition + 3 usages), found {len(refs)} references" # Verify references include .vue files (components that import the store) vue_refs = [ref for ref in refs if ".vue" in ref.get("uri", "")] assert ( len(vue_refs) >= 3 ), f"Should find at least 3 references in Vue components, found {len(vue_refs)}: {[ref.get('uri', '') for ref in vue_refs]}" # Verify specific components that use the store expected_vue_files = ["App.vue", "CalculatorInput.vue", "CalculatorDisplay.vue"] found_components = [] for expected_file in expected_vue_files: matching_refs = [ref for ref in vue_refs if expected_file in ref.get("uri", "")] if matching_refs: found_components.append(expected_file) assert len(found_components) > 0, ( f"Should find references in at least one component that uses the store. " f"Expected any of {expected_vue_files}, found references in: {[ref.get('uri', '') for ref in vue_refs]}" ) @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) def test_cross_file_references_typescript_to_vue(self, language_server: SolidLanguageServer) -> None: types_file = os.path.join("src", "types", "index.ts") types_symbols = language_server.request_document_symbols(types_file).get_all_symbols_and_roots() types_symbol_names = [s.get("name") for s in types_symbols[0]] # Operation type is used in calculator.ts and CalculatorInput.vue assert "Operation" in types_symbol_names, "Operation type should exist in types file" operation_symbol = None for sym in types_symbols[0]: if sym.get("name") == "Operation": operation_symbol = sym break if not operation_symbol or "selectionRange" not in operation_symbol: pytest.skip("Operation type symbol not found - test fixture may need updating") # Request references for the Operation type sel_start = operation_symbol["selectionRange"]["start"] refs = language_server.request_references(types_file, sel_start["line"], sel_start["character"]) # Verify we found references: definition + usage in calculator.ts and Vue files assert len(refs) >= 2, f"Operation type should have at least 2 references (definition + usages), found {len(refs)} references" # The Operation type should be referenced in both .ts files (calculator.ts) and potentially .vue files all_ref_uris = [ref.get("uri", "") for ref in refs] has_ts_refs = any(".ts" in uri and "types" not in uri for uri in all_ref_uris) assert ( has_ts_refs ), f"Operation type should be referenced in TypeScript files like calculator.ts. Found references in: {all_ref_uris}" @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) def test_reference_deduplication(self, language_server: SolidLanguageServer) -> None: store_file = os.path.join("src", "stores", "calculator.ts") store_symbols = language_server.request_document_symbols(store_file).get_all_symbols_and_roots() # Find a commonly-used symbol (useCalculatorStore) store_symbol = None for sym in store_symbols[0]: if sym.get("name") == "useCalculatorStore": store_symbol = sym break if not store_symbol or "selectionRange" not in store_symbol: pytest.skip("useCalculatorStore symbol not found - test fixture may need updating") # Request references sel_start = store_symbol["selectionRange"]["start"] refs = language_server.request_references(store_file, sel_start["line"], sel_start["character"]) # Check for duplicate references (same file, line, and character) seen_locations = set() duplicates = [] for ref in refs: # Create a unique key for this reference location uri = ref.get("uri", "") if "range" in ref: line = ref["range"]["start"]["line"] character = ref["range"]["start"]["character"] location_key = (uri, line, character) if location_key in seen_locations: duplicates.append(location_key) else: seen_locations.add(location_key) assert len(duplicates) == 0, ( f"Found {len(duplicates)} duplicate reference locations. " f"The dual-LSP architecture should deduplicate references from both servers. " f"Duplicates: {duplicates}" ) @pytest.mark.vue class TestVueEdgeCases: @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) def test_symbol_tree_structure(self, language_server: SolidLanguageServer) -> None: full_tree = language_server.request_full_symbol_tree() # Helper to extract all file paths from symbol tree def extract_paths_from_tree(symbols, paths=None): """Recursively extract file paths from symbol tree.""" if paths is None: paths = [] if isinstance(symbols, list): for symbol in symbols: extract_paths_from_tree(symbol, paths) elif isinstance(symbols, dict): # Check if this symbol has a location if "location" in symbols and "uri" in symbols["location"]: uri = symbols["location"]["uri"] # Extract the path after file:// if uri.startswith("file://"): file_path = uri[7:] # Remove "file://" paths.append(file_path) # Recurse into children if "children" in symbols: extract_paths_from_tree(symbols["children"], paths) return paths all_paths = extract_paths_from_tree(full_tree) # Verify we have files from expected directories # Note: Symbol tree may include duplicate paths (one per symbol in file) components_files = list({p for p in all_paths if "components" in p and ".vue" in p}) stores_files = list({p for p in all_paths if "stores" in p and ".ts" in p}) composables_files = list({p for p in all_paths if "composables" in p and ".ts" in p}) assert len(components_files) == 3, ( f"Symbol tree should include exactly 3 unique Vue components (CalculatorButton, CalculatorInput, CalculatorDisplay). " f"Found {len(components_files)} unique component files: {[p.split('/')[-1] for p in sorted(components_files)]}" ) assert len(stores_files) == 1, ( f"Symbol tree should include exactly 1 unique store file (calculator.ts). " f"Found {len(stores_files)} unique store files: {[p.split('/')[-1] for p in sorted(stores_files)]}" ) assert len(composables_files) == 2, ( f"Symbol tree should include exactly 2 unique composable files (useFormatter.ts, useTheme.ts). " f"Found {len(composables_files)} unique composable files: {[p.split('/')[-1] for p in sorted(composables_files)]}" ) # Verify specific expected files exist in the tree expected_files = [ "CalculatorButton.vue", "CalculatorInput.vue", "CalculatorDisplay.vue", "App.vue", "calculator.ts", "useFormatter.ts", "useTheme.ts", ] for expected_file in expected_files: matching_files = [p for p in all_paths if expected_file in p] assert len(matching_files) > 0, f"Expected file '{expected_file}' should be in symbol tree. All paths: {all_paths}" @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) def test_document_overview(self, language_server: SolidLanguageServer) -> None: app_file = os.path.join("src", "App.vue") overview = language_server.request_document_overview(app_file) # Overview should return a list of top-level symbols assert isinstance(overview, list), f"Overview should be a list, got: {type(overview)}" assert len(overview) >= 1, f"App.vue should have at least 1 top-level symbol in overview, got {len(overview)}" # Extract symbol names from overview symbol_names = [s.get("name") for s in overview if isinstance(s, dict)] # Vue LSP returns SFC structure (template/script/style sections) for .vue files # This is expected behavior - overview shows the file's high-level structure assert ( len(symbol_names) >= 1 ), f"Should have at least 1 symbol name in overview (e.g., 'App' or SFC section), got {len(symbol_names)}: {symbol_names}" # Test overview for a TypeScript file store_file = os.path.join("src", "stores", "calculator.ts") store_overview = language_server.request_document_overview(store_file) assert isinstance(store_overview, list), f"Store overview should be a list, got: {type(store_overview)}" assert len(store_overview) >= 1, f"calculator.ts should have at least 1 top-level symbol in overview, got {len(store_overview)}" store_symbol_names = [s.get("name") for s in store_overview if isinstance(s, dict)] assert ( "useCalculatorStore" in store_symbol_names ), f"useCalculatorStore should be in store file overview. Found {len(store_symbol_names)} symbols: {store_symbol_names}" # Test overview for another Vue component button_file = os.path.join("src", "components", "CalculatorButton.vue") button_overview = language_server.request_document_overview(button_file) assert isinstance(button_overview, list), f"Button overview should be a list, got: {type(button_overview)}" assert ( len(button_overview) >= 1 ), f"CalculatorButton.vue should have at least 1 top-level symbol in overview, got {len(button_overview)}" # For Vue files, overview provides SFC structure which is useful for navigation # The detailed symbols are available via request_document_symbols button_symbol_names = [s.get("name") for s in button_overview if isinstance(s, dict)] assert len(button_symbol_names) >= 1, ( f"CalculatorButton.vue should have at least 1 symbol in overview (e.g., 'CalculatorButton' or SFC section). " f"Found {len(button_symbol_names)} symbols: {button_symbol_names}" ) @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) def test_directory_overview(self, language_server: SolidLanguageServer) -> None: components_dir = os.path.join("src", "components") dir_overview = language_server.request_dir_overview(components_dir) # Directory overview should be a dict mapping file paths to symbol lists assert isinstance(dir_overview, dict), f"Directory overview should be a dict, got: {type(dir_overview)}" assert len(dir_overview) == 3, f"src/components directory should have exactly 3 files in overview, got {len(dir_overview)}" # Verify all component files are included expected_components = ["CalculatorButton.vue", "CalculatorInput.vue", "CalculatorDisplay.vue"] for expected_component in expected_components: # Find files that match this component name matching_files = [path for path in dir_overview.keys() if expected_component in path] assert len(matching_files) == 1, ( f"Component '{expected_component}' should appear exactly once in directory overview. " f"Found {len(matching_files)} matches. All files: {list(dir_overview.keys())}" ) # Verify the matched file has symbols file_path = matching_files[0] symbols = dir_overview[file_path] assert isinstance(symbols, list), f"Symbols for {file_path} should be a list, got {type(symbols)}" assert len(symbols) >= 1, f"Component {expected_component} should have at least 1 symbol in overview, got {len(symbols)}" # Test overview for stores directory stores_dir = os.path.join("src", "stores") stores_overview = language_server.request_dir_overview(stores_dir) assert isinstance(stores_overview, dict), f"Stores overview should be a dict, got: {type(stores_overview)}" assert ( len(stores_overview) == 1 ), f"src/stores directory should have exactly 1 file (calculator.ts) in overview, got {len(stores_overview)}" # Verify calculator.ts is included calculator_files = [path for path in stores_overview.keys() if "calculator.ts" in path] assert len(calculator_files) == 1, ( f"calculator.ts should appear exactly once in stores directory overview. " f"Found {len(calculator_files)} matches. All files: {list(stores_overview.keys())}" ) # Verify the store file has symbols store_path = calculator_files[0] store_symbols = stores_overview[store_path] store_symbol_names = [s.get("name") for s in store_symbols if isinstance(s, dict)] assert ( "useCalculatorStore" in store_symbol_names ), f"calculator.ts should have useCalculatorStore in overview. Found {len(store_symbol_names)} symbols: {store_symbol_names}" # Test overview for composables directory composables_dir = os.path.join("src", "composables") composables_overview = language_server.request_dir_overview(composables_dir) assert isinstance(composables_overview, dict), f"Composables overview should be a dict, got: {type(composables_overview)}" assert ( len(composables_overview) == 2 ), f"src/composables directory should have exactly 2 files in overview, got {len(composables_overview)}" # Verify composable files are included expected_composables = ["useFormatter.ts", "useTheme.ts"] for expected_composable in expected_composables: matching_files = [path for path in composables_overview.keys() if expected_composable in path] assert len(matching_files) == 1, ( f"Composable '{expected_composable}' should appear exactly once in directory overview. " f"Found {len(matching_files)} matches. All files: {list(composables_overview.keys())}" ) ================================================ FILE: test/solidlsp/vue/test_vue_error_cases.py ================================================ import os import sys import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language pytestmark = pytest.mark.vue IS_WINDOWS = sys.platform == "win32" class TypeScriptServerBehavior: """Platform-specific TypeScript language server behavior for invalid positions. On Windows: TS server returns empty results for invalid positions On macOS/Linux: TS server raises exceptions with "Bad line number" or "Debug Failure" """ @staticmethod def raises_on_invalid_position() -> bool: return not IS_WINDOWS @staticmethod def returns_empty_on_invalid_position() -> bool: return IS_WINDOWS class TestVueInvalidPositions: @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) def test_negative_line_number(self, language_server: SolidLanguageServer) -> None: file_path = os.path.join("src", "components", "CalculatorInput.vue") result = language_server.request_containing_symbol(file_path, -1, 0) assert result is None or result == {}, f"Negative line number should return None or empty dict, got: {result}" @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) def test_negative_character_number(self, language_server: SolidLanguageServer) -> None: """Test requesting containing symbol with negative character number. Expected behavior: Should return None or empty dict, not crash. """ file_path = os.path.join("src", "components", "CalculatorInput.vue") # Request containing symbol at invalid negative character result = language_server.request_containing_symbol(file_path, 10, -1) # Should handle gracefully - return None or empty dict assert result is None or result == {}, f"Negative character number should return None or empty dict, got: {result}" @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) def test_line_number_beyond_file_length(self, language_server: SolidLanguageServer) -> None: """Test requesting containing symbol beyond file length. Expected behavior: Raises IndexError when trying to access line beyond file bounds. This happens in the wrapper code before even reaching the language server. """ file_path = os.path.join("src", "components", "CalculatorInput.vue") # Request containing symbol at line 99999 (way beyond file length) # The wrapper code will raise an IndexError when checking if the line is empty with pytest.raises(IndexError) as exc_info: language_server.request_containing_symbol(file_path, 99999, 0) # Verify it's an index error for list access assert "list index out of range" in str(exc_info.value), f"Expected 'list index out of range' error, got: {exc_info.value}" @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) def test_character_number_beyond_line_length(self, language_server: SolidLanguageServer) -> None: """Test requesting containing symbol beyond line length. Expected behavior: Should return None or empty dict, not crash. """ file_path = os.path.join("src", "components", "CalculatorInput.vue") # Request containing symbol at character 99999 (way beyond line length) result = language_server.request_containing_symbol(file_path, 10, 99999) # Should handle gracefully - return None or empty dict assert result is None or result == {}, f"Character beyond line length should return None or empty dict, got: {result}" @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) def test_references_at_negative_line(self, language_server: SolidLanguageServer) -> None: """Test requesting references with negative line number.""" from solidlsp.ls_exceptions import SolidLSPException file_path = os.path.join("src", "components", "CalculatorInput.vue") if TypeScriptServerBehavior.returns_empty_on_invalid_position(): result = language_server.request_references(file_path, -1, 0) assert result == [], f"Expected empty list on Windows, got: {result}" else: with pytest.raises(SolidLSPException) as exc_info: language_server.request_references(file_path, -1, 0) assert "Bad line number" in str(exc_info.value) or "Debug Failure" in str(exc_info.value) @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) def test_definition_at_invalid_position(self, language_server: SolidLanguageServer) -> None: """Test requesting definition at invalid position.""" from solidlsp.ls_exceptions import SolidLSPException file_path = os.path.join("src", "components", "CalculatorInput.vue") if TypeScriptServerBehavior.returns_empty_on_invalid_position(): result = language_server.request_definition(file_path, -1, 0) assert result == [], f"Expected empty list on Windows, got: {result}" else: with pytest.raises(SolidLSPException) as exc_info: language_server.request_definition(file_path, -1, 0) assert "Bad line number" in str(exc_info.value) or "Debug Failure" in str(exc_info.value) class TestVueNonExistentFiles: """Tests for handling non-existent files.""" @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) def test_document_symbols_nonexistent_file(self, language_server: SolidLanguageServer) -> None: """Test requesting document symbols from non-existent file. Expected behavior: Should raise FileNotFoundError or return empty result. """ nonexistent_file = os.path.join("src", "components", "NonExistent.vue") # Should raise an appropriate exception or return empty result try: result = language_server.request_document_symbols(nonexistent_file) # If no exception, verify result is empty or indicates file not found symbols = result.get_all_symbols_and_roots() assert len(symbols[0]) == 0, f"Non-existent file should return empty symbols, got {len(symbols[0])} symbols" except (FileNotFoundError, Exception) as e: # Expected - file doesn't exist assert True, f"Appropriately raised exception for non-existent file: {e}" @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) def test_containing_symbol_nonexistent_file(self, language_server: SolidLanguageServer) -> None: """Test requesting containing symbol from non-existent file. Expected behavior: Should raise FileNotFoundError or return None. """ nonexistent_file = os.path.join("src", "components", "NonExistent.vue") # Should raise an appropriate exception or return None try: result = language_server.request_containing_symbol(nonexistent_file, 10, 10) # If no exception, verify result indicates file not found assert result is None or result == {}, f"Non-existent file should return None or empty dict, got: {result}" except (FileNotFoundError, Exception) as e: # Expected - file doesn't exist assert True, f"Appropriately raised exception for non-existent file: {e}" @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) def test_references_nonexistent_file(self, language_server: SolidLanguageServer) -> None: """Test requesting references from non-existent file. Expected behavior: Should raise FileNotFoundError or return empty list. """ nonexistent_file = os.path.join("src", "components", "NonExistent.vue") # Should raise an appropriate exception or return empty list try: result = language_server.request_references(nonexistent_file, 10, 10) # If no exception, verify result is empty assert result is None or isinstance(result, list), f"Non-existent file should return None or list, got: {result}" if isinstance(result, list): assert len(result) == 0, f"Non-existent file should return empty list, got {len(result)} references" except (FileNotFoundError, Exception) as e: # Expected - file doesn't exist assert True, f"Appropriately raised exception for non-existent file: {e}" @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) def test_definition_nonexistent_file(self, language_server: SolidLanguageServer) -> None: """Test requesting definition from non-existent file. Expected behavior: Should raise FileNotFoundError or return empty list. """ nonexistent_file = os.path.join("src", "components", "NonExistent.vue") # Should raise an appropriate exception or return empty list try: result = language_server.request_definition(nonexistent_file, 10, 10) # If no exception, verify result is empty assert isinstance(result, list), f"request_definition should return a list, got: {type(result)}" assert len(result) == 0, f"Non-existent file should return empty list, got {len(result)} definitions" except (FileNotFoundError, Exception) as e: # Expected - file doesn't exist assert True, f"Appropriately raised exception for non-existent file: {e}" class TestVueUndefinedSymbols: """Tests for handling undefined or unreferenced symbols.""" @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) def test_references_for_unreferenced_symbol(self, language_server: SolidLanguageServer) -> None: """Test requesting references for a symbol that has no references. Expected behavior: Should return empty list (only the definition itself if include_self=True). """ # Find a symbol that likely has no external references file_path = os.path.join("src", "components", "CalculatorButton.vue") # Get document symbols symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() # Find pressCount - this is exposed but may not be referenced elsewhere press_count_symbol = next((s for s in symbols[0] if s.get("name") == "pressCount"), None) if not press_count_symbol or "selectionRange" not in press_count_symbol: pytest.skip("pressCount symbol not found - test fixture may need updating") # Request references without include_self sel_start = press_count_symbol["selectionRange"]["start"] refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) # Should return a list (may be empty or contain only definition) assert isinstance(refs, list), f"request_references should return a list, got {type(refs)}" # For an unreferenced symbol, should have 0-1 references (0 without include_self, 1 with) # The exact count depends on the language server implementation assert len(refs) <= 5, ( f"pressCount should have few or no external references. " f"Got {len(refs)} references. This is not necessarily an error, just documenting behavior." ) @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) def test_containing_symbol_at_whitespace_only_line(self, language_server: SolidLanguageServer) -> None: """Test requesting containing symbol at a whitespace-only line. Expected behavior: Should return None, empty dict, or the parent symbol. """ file_path = os.path.join("src", "components", "CalculatorInput.vue") # Try position at line 1 (typically a blank line or template start in Vue SFCs) result = language_server.request_containing_symbol(file_path, 1, 0) # Should handle gracefully - return None, empty dict, or a valid parent symbol assert ( result is None or result == {} or isinstance(result, dict) ), f"Whitespace line should return None, empty dict, or valid symbol. Got: {result}" @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) def test_definition_at_keyword_position(self, language_server: SolidLanguageServer) -> None: """Test requesting definition at language keyword position. Expected behavior: Should return empty list or handle gracefully. """ file_path = os.path.join("src", "components", "CalculatorInput.vue") # Try to get definition at a keyword like "const", "import", etc. # Line 2 typically has "import" statement - try position on "import" keyword result = language_server.request_definition(file_path, 2, 0) # Should handle gracefully - return empty list or valid definitions assert isinstance(result, list), f"request_definition should return a list, got {type(result)}" class TestVueEdgeCasePositions: """Tests for edge case positions (0,0 and file boundaries).""" @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) def test_containing_symbol_at_file_start(self, language_server: SolidLanguageServer) -> None: """Test requesting containing symbol at position (0,0). Expected behavior: Should return None, empty dict, or a valid symbol. This position typically corresponds to the start of the file (e.g.,