This is page 9 of 14. Use http://codebase.md/oraios/serena?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .devcontainer │ └── devcontainer.json ├── .dockerignore ├── .env.example ├── .github │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE │ │ ├── config.yml │ │ ├── feature_request.md │ │ └── issue--bug--performance-problem--question-.md │ └── workflows │ ├── codespell.yml │ ├── docker.yml │ ├── junie.yml │ ├── lint_and_docs.yaml │ ├── publish.yml │ └── pytest.yml ├── .gitignore ├── .serena │ ├── memories │ │ ├── adding_new_language_support_guide.md │ │ ├── serena_core_concepts_and_architecture.md │ │ ├── serena_repository_structure.md │ │ └── suggested_commands.md │ └── project.yml ├── .vscode │ └── settings.json ├── CHANGELOG.md ├── CLAUDE.md ├── compose.yaml ├── CONTRIBUTING.md ├── docker_build_and_run.sh ├── DOCKER.md ├── Dockerfile ├── docs │ ├── custom_agent.md │ └── serena_on_chatgpt.md ├── flake.lock ├── flake.nix ├── lessons_learned.md ├── LICENSE ├── llms-install.md ├── public │ └── .gitignore ├── pyproject.toml ├── README.md ├── resources │ ├── serena-icons.cdr │ ├── serena-logo-dark-mode.svg │ ├── serena-logo.cdr │ ├── serena-logo.svg │ └── vscode_sponsor_logo.png ├── roadmap.md ├── scripts │ ├── agno_agent.py │ ├── demo_run_tools.py │ ├── gen_prompt_factory.py │ ├── mcp_server.py │ ├── print_mode_context_options.py │ └── print_tool_overview.py ├── src │ ├── interprompt │ │ ├── __init__.py │ │ ├── .syncCommitId.remote │ │ ├── .syncCommitId.this │ │ ├── jinja_template.py │ │ ├── multilang_prompt.py │ │ ├── prompt_factory.py │ │ └── util │ │ ├── __init__.py │ │ └── class_decorators.py │ ├── README.md │ ├── serena │ │ ├── __init__.py │ │ ├── agent.py │ │ ├── agno.py │ │ ├── analytics.py │ │ ├── cli.py │ │ ├── code_editor.py │ │ ├── config │ │ │ ├── __init__.py │ │ │ ├── context_mode.py │ │ │ └── serena_config.py │ │ ├── constants.py │ │ ├── dashboard.py │ │ ├── generated │ │ │ └── generated_prompt_factory.py │ │ ├── gui_log_viewer.py │ │ ├── mcp.py │ │ ├── project.py │ │ ├── prompt_factory.py │ │ ├── resources │ │ │ ├── config │ │ │ │ ├── contexts │ │ │ │ │ ├── agent.yml │ │ │ │ │ ├── chatgpt.yml │ │ │ │ │ ├── codex.yml │ │ │ │ │ ├── context.template.yml │ │ │ │ │ ├── desktop-app.yml │ │ │ │ │ ├── ide-assistant.yml │ │ │ │ │ └── oaicompat-agent.yml │ │ │ │ ├── internal_modes │ │ │ │ │ └── jetbrains.yml │ │ │ │ ├── modes │ │ │ │ │ ├── editing.yml │ │ │ │ │ ├── interactive.yml │ │ │ │ │ ├── mode.template.yml │ │ │ │ │ ├── no-onboarding.yml │ │ │ │ │ ├── onboarding.yml │ │ │ │ │ ├── one-shot.yml │ │ │ │ │ └── planning.yml │ │ │ │ └── prompt_templates │ │ │ │ ├── simple_tool_outputs.yml │ │ │ │ └── system_prompt.yml │ │ │ ├── dashboard │ │ │ │ ├── dashboard.js │ │ │ │ ├── index.html │ │ │ │ ├── jquery.min.js │ │ │ │ ├── serena-icon-16.png │ │ │ │ ├── serena-icon-32.png │ │ │ │ ├── serena-icon-48.png │ │ │ │ ├── serena-logs-dark-mode.png │ │ │ │ └── serena-logs.png │ │ │ ├── project.template.yml │ │ │ └── serena_config.template.yml │ │ ├── symbol.py │ │ ├── text_utils.py │ │ ├── tools │ │ │ ├── __init__.py │ │ │ ├── cmd_tools.py │ │ │ ├── config_tools.py │ │ │ ├── file_tools.py │ │ │ ├── jetbrains_plugin_client.py │ │ │ ├── jetbrains_tools.py │ │ │ ├── memory_tools.py │ │ │ ├── symbol_tools.py │ │ │ ├── tools_base.py │ │ │ └── workflow_tools.py │ │ └── util │ │ ├── class_decorators.py │ │ ├── exception.py │ │ ├── file_system.py │ │ ├── general.py │ │ ├── git.py │ │ ├── inspection.py │ │ ├── logging.py │ │ ├── shell.py │ │ └── thread.py │ └── solidlsp │ ├── __init__.py │ ├── .gitignore │ ├── language_servers │ │ ├── al_language_server.py │ │ ├── bash_language_server.py │ │ ├── clangd_language_server.py │ │ ├── clojure_lsp.py │ │ ├── common.py │ │ ├── csharp_language_server.py │ │ ├── dart_language_server.py │ │ ├── eclipse_jdtls.py │ │ ├── elixir_tools │ │ │ ├── __init__.py │ │ │ ├── elixir_tools.py │ │ │ └── README.md │ │ ├── elm_language_server.py │ │ ├── erlang_language_server.py │ │ ├── gopls.py │ │ ├── intelephense.py │ │ ├── jedi_server.py │ │ ├── kotlin_language_server.py │ │ ├── lua_ls.py │ │ ├── marksman.py │ │ ├── nixd_ls.py │ │ ├── omnisharp │ │ │ ├── initialize_params.json │ │ │ ├── runtime_dependencies.json │ │ │ └── workspace_did_change_configuration.json │ │ ├── omnisharp.py │ │ ├── perl_language_server.py │ │ ├── pyright_server.py │ │ ├── r_language_server.py │ │ ├── regal_server.py │ │ ├── ruby_lsp.py │ │ ├── rust_analyzer.py │ │ ├── solargraph.py │ │ ├── sourcekit_lsp.py │ │ ├── terraform_ls.py │ │ ├── typescript_language_server.py │ │ ├── vts_language_server.py │ │ └── zls.py │ ├── ls_config.py │ ├── ls_exceptions.py │ ├── ls_handler.py │ ├── ls_logger.py │ ├── ls_request.py │ ├── ls_types.py │ ├── ls_utils.py │ ├── ls.py │ ├── lsp_protocol_handler │ │ ├── lsp_constants.py │ │ ├── lsp_requests.py │ │ ├── lsp_types.py │ │ └── server.py │ ├── settings.py │ └── util │ ├── subprocess_util.py │ └── zip.py ├── test │ ├── __init__.py │ ├── conftest.py │ ├── resources │ │ └── repos │ │ ├── al │ │ │ └── test_repo │ │ │ ├── app.json │ │ │ └── src │ │ │ ├── Codeunits │ │ │ │ ├── CustomerMgt.Codeunit.al │ │ │ │ └── PaymentProcessorImpl.Codeunit.al │ │ │ ├── Enums │ │ │ │ └── CustomerType.Enum.al │ │ │ ├── Interfaces │ │ │ │ └── IPaymentProcessor.Interface.al │ │ │ ├── Pages │ │ │ │ ├── CustomerCard.Page.al │ │ │ │ └── CustomerList.Page.al │ │ │ ├── TableExtensions │ │ │ │ └── Item.TableExt.al │ │ │ └── Tables │ │ │ └── Customer.Table.al │ │ ├── bash │ │ │ └── test_repo │ │ │ ├── config.sh │ │ │ ├── main.sh │ │ │ └── utils.sh │ │ ├── clojure │ │ │ └── test_repo │ │ │ ├── deps.edn │ │ │ └── src │ │ │ └── test_app │ │ │ ├── core.clj │ │ │ └── utils.clj │ │ ├── csharp │ │ │ └── test_repo │ │ │ ├── .gitignore │ │ │ ├── Models │ │ │ │ └── Person.cs │ │ │ ├── Program.cs │ │ │ ├── serena.sln │ │ │ └── TestProject.csproj │ │ ├── dart │ │ │ └── test_repo │ │ │ ├── .gitignore │ │ │ ├── lib │ │ │ │ ├── helper.dart │ │ │ │ ├── main.dart │ │ │ │ └── models.dart │ │ │ └── pubspec.yaml │ │ ├── elixir │ │ │ └── test_repo │ │ │ ├── .gitignore │ │ │ ├── lib │ │ │ │ ├── examples.ex │ │ │ │ ├── ignored_dir │ │ │ │ │ └── ignored_module.ex │ │ │ │ ├── models.ex │ │ │ │ ├── services.ex │ │ │ │ ├── test_repo.ex │ │ │ │ └── utils.ex │ │ │ ├── mix.exs │ │ │ ├── mix.lock │ │ │ ├── scripts │ │ │ │ └── build_script.ex │ │ │ └── test │ │ │ ├── models_test.exs │ │ │ └── test_repo_test.exs │ │ ├── elm │ │ │ └── test_repo │ │ │ ├── elm.json │ │ │ ├── Main.elm │ │ │ └── Utils.elm │ │ ├── erlang │ │ │ └── test_repo │ │ │ ├── hello.erl │ │ │ ├── ignored_dir │ │ │ │ └── ignored_module.erl │ │ │ ├── include │ │ │ │ ├── records.hrl │ │ │ │ └── types.hrl │ │ │ ├── math_utils.erl │ │ │ ├── rebar.config │ │ │ ├── src │ │ │ │ ├── app.erl │ │ │ │ ├── models.erl │ │ │ │ ├── services.erl │ │ │ │ └── utils.erl │ │ │ └── test │ │ │ ├── models_tests.erl │ │ │ └── utils_tests.erl │ │ ├── go │ │ │ └── test_repo │ │ │ └── main.go │ │ ├── java │ │ │ └── test_repo │ │ │ ├── pom.xml │ │ │ └── src │ │ │ └── main │ │ │ └── java │ │ │ └── test_repo │ │ │ ├── Main.java │ │ │ ├── Model.java │ │ │ ├── ModelUser.java │ │ │ └── Utils.java │ │ ├── kotlin │ │ │ └── test_repo │ │ │ ├── .gitignore │ │ │ ├── build.gradle.kts │ │ │ └── src │ │ │ └── main │ │ │ └── kotlin │ │ │ └── test_repo │ │ │ ├── Main.kt │ │ │ ├── Model.kt │ │ │ ├── ModelUser.kt │ │ │ └── Utils.kt │ │ ├── lua │ │ │ └── test_repo │ │ │ ├── .gitignore │ │ │ ├── main.lua │ │ │ ├── src │ │ │ │ ├── calculator.lua │ │ │ │ └── utils.lua │ │ │ └── tests │ │ │ └── test_calculator.lua │ │ ├── markdown │ │ │ └── test_repo │ │ │ ├── api.md │ │ │ ├── CONTRIBUTING.md │ │ │ ├── guide.md │ │ │ └── README.md │ │ ├── nix │ │ │ └── test_repo │ │ │ ├── .gitignore │ │ │ ├── default.nix │ │ │ ├── flake.nix │ │ │ ├── lib │ │ │ │ └── utils.nix │ │ │ ├── modules │ │ │ │ └── example.nix │ │ │ └── scripts │ │ │ └── hello.sh │ │ ├── perl │ │ │ └── test_repo │ │ │ ├── helper.pl │ │ │ └── main.pl │ │ ├── php │ │ │ └── test_repo │ │ │ ├── helper.php │ │ │ ├── index.php │ │ │ └── simple_var.php │ │ ├── python │ │ │ └── test_repo │ │ │ ├── .gitignore │ │ │ ├── custom_test │ │ │ │ ├── __init__.py │ │ │ │ └── advanced_features.py │ │ │ ├── examples │ │ │ │ ├── __init__.py │ │ │ │ └── user_management.py │ │ │ ├── ignore_this_dir_with_postfix │ │ │ │ └── ignored_module.py │ │ │ ├── scripts │ │ │ │ ├── __init__.py │ │ │ │ └── run_app.py │ │ │ └── test_repo │ │ │ ├── __init__.py │ │ │ ├── complex_types.py │ │ │ ├── models.py │ │ │ ├── name_collisions.py │ │ │ ├── nested_base.py │ │ │ ├── nested.py │ │ │ ├── overloaded.py │ │ │ ├── services.py │ │ │ ├── utils.py │ │ │ └── variables.py │ │ ├── r │ │ │ └── test_repo │ │ │ ├── .Rbuildignore │ │ │ ├── DESCRIPTION │ │ │ ├── examples │ │ │ │ └── analysis.R │ │ │ ├── NAMESPACE │ │ │ └── R │ │ │ ├── models.R │ │ │ └── utils.R │ │ ├── rego │ │ │ └── test_repo │ │ │ ├── policies │ │ │ │ ├── authz.rego │ │ │ │ └── validation.rego │ │ │ └── utils │ │ │ └── helpers.rego │ │ ├── ruby │ │ │ └── test_repo │ │ │ ├── .solargraph.yml │ │ │ ├── examples │ │ │ │ └── user_management.rb │ │ │ ├── lib.rb │ │ │ ├── main.rb │ │ │ ├── models.rb │ │ │ ├── nested.rb │ │ │ ├── services.rb │ │ │ └── variables.rb │ │ ├── rust │ │ │ ├── test_repo │ │ │ │ ├── Cargo.lock │ │ │ │ ├── Cargo.toml │ │ │ │ └── src │ │ │ │ ├── lib.rs │ │ │ │ └── main.rs │ │ │ └── test_repo_2024 │ │ │ ├── Cargo.lock │ │ │ ├── Cargo.toml │ │ │ └── src │ │ │ ├── lib.rs │ │ │ └── main.rs │ │ ├── swift │ │ │ └── test_repo │ │ │ ├── Package.swift │ │ │ └── src │ │ │ ├── main.swift │ │ │ └── utils.swift │ │ ├── terraform │ │ │ └── test_repo │ │ │ ├── data.tf │ │ │ ├── main.tf │ │ │ ├── outputs.tf │ │ │ └── variables.tf │ │ ├── typescript │ │ │ └── test_repo │ │ │ ├── .serena │ │ │ │ └── project.yml │ │ │ ├── index.ts │ │ │ ├── tsconfig.json │ │ │ └── use_helper.ts │ │ └── zig │ │ └── test_repo │ │ ├── .gitignore │ │ ├── build.zig │ │ ├── src │ │ │ ├── calculator.zig │ │ │ ├── main.zig │ │ │ └── math_utils.zig │ │ └── zls.json │ ├── serena │ │ ├── __init__.py │ │ ├── __snapshots__ │ │ │ └── test_symbol_editing.ambr │ │ ├── config │ │ │ ├── __init__.py │ │ │ └── test_serena_config.py │ │ ├── test_edit_marker.py │ │ ├── test_mcp.py │ │ ├── test_serena_agent.py │ │ ├── test_symbol_editing.py │ │ ├── test_symbol.py │ │ ├── test_text_utils.py │ │ ├── test_tool_parameter_types.py │ │ └── util │ │ ├── test_exception.py │ │ └── test_file_system.py │ └── solidlsp │ ├── al │ │ └── test_al_basic.py │ ├── bash │ │ ├── __init__.py │ │ └── test_bash_basic.py │ ├── clojure │ │ ├── __init__.py │ │ └── test_clojure_basic.py │ ├── csharp │ │ └── test_csharp_basic.py │ ├── dart │ │ ├── __init__.py │ │ └── test_dart_basic.py │ ├── elixir │ │ ├── __init__.py │ │ ├── conftest.py │ │ ├── test_elixir_basic.py │ │ ├── test_elixir_ignored_dirs.py │ │ ├── test_elixir_integration.py │ │ └── test_elixir_symbol_retrieval.py │ ├── elm │ │ └── test_elm_basic.py │ ├── erlang │ │ ├── __init__.py │ │ ├── conftest.py │ │ ├── test_erlang_basic.py │ │ ├── test_erlang_ignored_dirs.py │ │ └── test_erlang_symbol_retrieval.py │ ├── go │ │ └── test_go_basic.py │ ├── java │ │ └── test_java_basic.py │ ├── kotlin │ │ └── test_kotlin_basic.py │ ├── lua │ │ └── test_lua_basic.py │ ├── markdown │ │ ├── __init__.py │ │ └── test_markdown_basic.py │ ├── nix │ │ └── test_nix_basic.py │ ├── perl │ │ └── test_perl_basic.py │ ├── php │ │ └── test_php_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_basic.py │ ├── swift │ │ └── test_swift_basic.py │ ├── terraform │ │ └── test_terraform_basic.py │ ├── typescript │ │ └── test_typescript_basic.py │ ├── util │ │ └── test_zip.py │ └── zig │ └── test_zig_basic.py └── uv.lock ``` # Files -------------------------------------------------------------------------------- /src/solidlsp/language_servers/omnisharp/initialize_params.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "_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", 3 | "processId": "os.getpid()", 4 | "clientInfo": { 5 | "name": "Visual Studio Code - Insiders", 6 | "version": "1.82.0-insider" 7 | }, 8 | "locale": "en", 9 | "rootPath": "$rootPath", 10 | "rootUri": "$rootUri", 11 | "capabilities": { 12 | "workspace": { 13 | "applyEdit": true, 14 | "workspaceEdit": { 15 | "documentChanges": true, 16 | "resourceOperations": [ 17 | "create", 18 | "rename", 19 | "delete" 20 | ], 21 | "failureHandling": "textOnlyTransactional", 22 | "normalizesLineEndings": true, 23 | "changeAnnotationSupport": { 24 | "groupsOnLabel": true 25 | } 26 | }, 27 | "configuration": false, 28 | "didChangeWatchedFiles": { 29 | "dynamicRegistration": true, 30 | "relativePatternSupport": true 31 | }, 32 | "symbol": { 33 | "dynamicRegistration": true, 34 | "symbolKind": { 35 | "valueSet": [ 36 | 1, 37 | 2, 38 | 3, 39 | 4, 40 | 5, 41 | 6, 42 | 7, 43 | 8, 44 | 9, 45 | 10, 46 | 11, 47 | 12, 48 | 13, 49 | 14, 50 | 15, 51 | 16, 52 | 17, 53 | 18, 54 | 19, 55 | 20, 56 | 21, 57 | 22, 58 | 23, 59 | 24, 60 | 25, 61 | 26 62 | ] 63 | }, 64 | "tagSupport": { 65 | "valueSet": [ 66 | 1 67 | ] 68 | }, 69 | "resolveSupport": { 70 | "properties": [ 71 | "location.range" 72 | ] 73 | } 74 | }, 75 | "codeLens": { 76 | "refreshSupport": true 77 | }, 78 | "executeCommand": { 79 | "dynamicRegistration": true 80 | }, 81 | "didChangeConfiguration": { 82 | "dynamicRegistration": true 83 | }, 84 | "workspaceFolders": true, 85 | "semanticTokens": { 86 | "refreshSupport": true 87 | }, 88 | "fileOperations": { 89 | "dynamicRegistration": true, 90 | "didCreate": true, 91 | "didRename": true, 92 | "didDelete": true, 93 | "willCreate": true, 94 | "willRename": true, 95 | "willDelete": true 96 | }, 97 | "inlineValue": { 98 | "refreshSupport": true 99 | }, 100 | "inlayHint": { 101 | "refreshSupport": true 102 | }, 103 | "diagnostics": { 104 | "refreshSupport": true 105 | } 106 | }, 107 | "textDocument": { 108 | "publishDiagnostics": { 109 | "relatedInformation": true, 110 | "versionSupport": false, 111 | "tagSupport": { 112 | "valueSet": [ 113 | 1, 114 | 2 115 | ] 116 | }, 117 | "codeDescriptionSupport": true, 118 | "dataSupport": true 119 | }, 120 | "synchronization": { 121 | "dynamicRegistration": true, 122 | "willSave": true, 123 | "willSaveWaitUntil": true, 124 | "didSave": true 125 | }, 126 | "completion": { 127 | "dynamicRegistration": true, 128 | "contextSupport": true, 129 | "completionItem": { 130 | "snippetSupport": true, 131 | "commitCharactersSupport": true, 132 | "documentationFormat": [ 133 | "markdown", 134 | "plaintext" 135 | ], 136 | "deprecatedSupport": true, 137 | "preselectSupport": true, 138 | "tagSupport": { 139 | "valueSet": [ 140 | 1 141 | ] 142 | }, 143 | "insertReplaceSupport": true, 144 | "resolveSupport": { 145 | "properties": [ 146 | "documentation", 147 | "detail", 148 | "additionalTextEdits" 149 | ] 150 | }, 151 | "insertTextModeSupport": { 152 | "valueSet": [ 153 | 1, 154 | 2 155 | ] 156 | }, 157 | "labelDetailsSupport": true 158 | }, 159 | "insertTextMode": 2, 160 | "completionItemKind": { 161 | "valueSet": [ 162 | 1, 163 | 2, 164 | 3, 165 | 4, 166 | 5, 167 | 6, 168 | 7, 169 | 8, 170 | 9, 171 | 10, 172 | 11, 173 | 12, 174 | 13, 175 | 14, 176 | 16, 177 | 17, 178 | 18, 179 | 19, 180 | 20, 181 | 21, 182 | 22, 183 | 23, 184 | 24, 185 | 25 186 | ] 187 | }, 188 | "completionList": { 189 | "itemDefaults": [ 190 | "commitCharacters", 191 | "editRange", 192 | "insertTextFormat", 193 | "insertTextMode" 194 | ] 195 | } 196 | }, 197 | "hover": { 198 | "dynamicRegistration": true, 199 | "contentFormat": [ 200 | "markdown", 201 | "plaintext" 202 | ] 203 | }, 204 | "signatureHelp": { 205 | "dynamicRegistration": true, 206 | "signatureInformation": { 207 | "documentationFormat": [ 208 | "markdown", 209 | "plaintext" 210 | ], 211 | "parameterInformation": { 212 | "labelOffsetSupport": true 213 | }, 214 | "activeParameterSupport": true 215 | }, 216 | "contextSupport": true 217 | }, 218 | "definition": { 219 | "dynamicRegistration": true, 220 | "linkSupport": true 221 | }, 222 | "references": { 223 | "dynamicRegistration": true 224 | }, 225 | "documentHighlight": { 226 | "dynamicRegistration": true 227 | }, 228 | "documentSymbol": { 229 | "dynamicRegistration": true, 230 | "symbolKind": { 231 | "valueSet": [ 232 | 1, 233 | 2, 234 | 3, 235 | 4, 236 | 5, 237 | 6, 238 | 7, 239 | 8, 240 | 9, 241 | 10, 242 | 11, 243 | 12, 244 | 13, 245 | 14, 246 | 15, 247 | 16, 248 | 17, 249 | 18, 250 | 19, 251 | 20, 252 | 21, 253 | 22, 254 | 23, 255 | 24, 256 | 25, 257 | 26 258 | ] 259 | }, 260 | "hierarchicalDocumentSymbolSupport": true, 261 | "tagSupport": { 262 | "valueSet": [ 263 | 1 264 | ] 265 | }, 266 | "labelSupport": true 267 | }, 268 | "codeAction": { 269 | "dynamicRegistration": true, 270 | "isPreferredSupport": true, 271 | "disabledSupport": true, 272 | "dataSupport": true, 273 | "resolveSupport": { 274 | "properties": [ 275 | "edit" 276 | ] 277 | }, 278 | "codeActionLiteralSupport": { 279 | "codeActionKind": { 280 | "valueSet": [ 281 | "", 282 | "quickfix", 283 | "refactor", 284 | "refactor.extract", 285 | "refactor.inline", 286 | "refactor.rewrite", 287 | "source", 288 | "source.organizeImports" 289 | ] 290 | } 291 | }, 292 | "honorsChangeAnnotations": false 293 | }, 294 | "codeLens": { 295 | "dynamicRegistration": true 296 | }, 297 | "formatting": { 298 | "dynamicRegistration": true 299 | }, 300 | "rangeFormatting": { 301 | "dynamicRegistration": true 302 | }, 303 | "onTypeFormatting": { 304 | "dynamicRegistration": true 305 | }, 306 | "rename": { 307 | "dynamicRegistration": true, 308 | "prepareSupport": true, 309 | "prepareSupportDefaultBehavior": 1, 310 | "honorsChangeAnnotations": true 311 | }, 312 | "documentLink": { 313 | "dynamicRegistration": true, 314 | "tooltipSupport": true 315 | }, 316 | "typeDefinition": { 317 | "dynamicRegistration": true, 318 | "linkSupport": true 319 | }, 320 | "implementation": { 321 | "dynamicRegistration": true, 322 | "linkSupport": true 323 | }, 324 | "colorProvider": { 325 | "dynamicRegistration": true 326 | }, 327 | "foldingRange": { 328 | "dynamicRegistration": true, 329 | "rangeLimit": 5000, 330 | "lineFoldingOnly": true, 331 | "foldingRangeKind": { 332 | "valueSet": [ 333 | "comment", 334 | "imports", 335 | "region" 336 | ] 337 | }, 338 | "foldingRange": { 339 | "collapsedText": false 340 | } 341 | }, 342 | "declaration": { 343 | "dynamicRegistration": true, 344 | "linkSupport": true 345 | }, 346 | "selectionRange": { 347 | "dynamicRegistration": true 348 | }, 349 | "callHierarchy": { 350 | "dynamicRegistration": true 351 | }, 352 | "semanticTokens": { 353 | "dynamicRegistration": true, 354 | "tokenTypes": [ 355 | "namespace", 356 | "type", 357 | "class", 358 | "enum", 359 | "interface", 360 | "struct", 361 | "typeParameter", 362 | "parameter", 363 | "variable", 364 | "property", 365 | "enumMember", 366 | "event", 367 | "function", 368 | "method", 369 | "macro", 370 | "keyword", 371 | "modifier", 372 | "comment", 373 | "string", 374 | "number", 375 | "regexp", 376 | "operator", 377 | "decorator" 378 | ], 379 | "tokenModifiers": [ 380 | "declaration", 381 | "definition", 382 | "readonly", 383 | "static", 384 | "deprecated", 385 | "abstract", 386 | "async", 387 | "modification", 388 | "documentation", 389 | "defaultLibrary" 390 | ], 391 | "formats": [ 392 | "relative" 393 | ], 394 | "requests": { 395 | "range": true, 396 | "full": { 397 | "delta": true 398 | } 399 | }, 400 | "multilineTokenSupport": false, 401 | "overlappingTokenSupport": false, 402 | "serverCancelSupport": true, 403 | "augmentsSyntaxTokens": false 404 | }, 405 | "linkedEditingRange": { 406 | "dynamicRegistration": true 407 | }, 408 | "typeHierarchy": { 409 | "dynamicRegistration": true 410 | }, 411 | "inlineValue": { 412 | "dynamicRegistration": true 413 | }, 414 | "inlayHint": { 415 | "dynamicRegistration": true, 416 | "resolveSupport": { 417 | "properties": [ 418 | "tooltip", 419 | "textEdits", 420 | "label.tooltip", 421 | "label.location", 422 | "label.command" 423 | ] 424 | } 425 | }, 426 | "diagnostic": { 427 | "dynamicRegistration": true, 428 | "relatedDocumentSupport": false 429 | } 430 | }, 431 | "window": { 432 | "showMessage": { 433 | "messageActionItem": { 434 | "additionalPropertiesSupport": true 435 | } 436 | }, 437 | "showDocument": { 438 | "support": true 439 | }, 440 | "workDoneProgress": true 441 | }, 442 | "general": { 443 | "staleRequestSupport": { 444 | "cancel": true, 445 | "retryOnContentModified": [ 446 | "textDocument/semanticTokens/full", 447 | "textDocument/semanticTokens/range", 448 | "textDocument/semanticTokens/full/delta" 449 | ] 450 | }, 451 | "regularExpressions": { 452 | "engine": "ECMAScript", 453 | "version": "ES2020" 454 | }, 455 | "markdown": { 456 | "parser": "marked", 457 | "version": "1.1.0", 458 | "allowedTags": [ 459 | "ul", 460 | "li", 461 | "p", 462 | "code", 463 | "blockquote", 464 | "ol", 465 | "h1", 466 | "h2", 467 | "h3", 468 | "h4", 469 | "h5", 470 | "h6", 471 | "hr", 472 | "em", 473 | "pre", 474 | "table", 475 | "thead", 476 | "tbody", 477 | "tr", 478 | "th", 479 | "td", 480 | "div", 481 | "del", 482 | "a", 483 | "strong", 484 | "br", 485 | "img", 486 | "span" 487 | ] 488 | }, 489 | "positionEncodings": [ 490 | "utf-16" 491 | ] 492 | }, 493 | "notebookDocument": { 494 | "synchronization": { 495 | "dynamicRegistration": true, 496 | "executionSummarySupport": true 497 | } 498 | }, 499 | "experimental": { 500 | "snippetTextEdit": true, 501 | "codeActionGroup": true, 502 | "hoverActions": true, 503 | "serverStatusNotification": true, 504 | "colorDiagnosticOutput": true, 505 | "openServerLogs": true, 506 | "commands": { 507 | "commands": [ 508 | "editor.action.triggerParameterHints" 509 | ] 510 | } 511 | } 512 | }, 513 | "initializationOptions": { 514 | "RoslynExtensionsOptions": { 515 | "EnableDecompilationSupport": false, 516 | "EnableAnalyzersSupport": true, 517 | "EnableImportCompletion": true, 518 | "EnableAsyncCompletion": false, 519 | "DocumentAnalysisTimeoutMs": 30000, 520 | "DiagnosticWorkersThreadCount": 18, 521 | "AnalyzeOpenDocumentsOnly": true, 522 | "InlayHintsOptions": { 523 | "EnableForParameters": false, 524 | "ForLiteralParameters": false, 525 | "ForIndexerParameters": false, 526 | "ForObjectCreationParameters": false, 527 | "ForOtherParameters": false, 528 | "SuppressForParametersThatDifferOnlyBySuffix": false, 529 | "SuppressForParametersThatMatchMethodIntent": false, 530 | "SuppressForParametersThatMatchArgumentName": false, 531 | "EnableForTypes": false, 532 | "ForImplicitVariableTypes": false, 533 | "ForLambdaParameterTypes": false, 534 | "ForImplicitObjectCreation": false 535 | }, 536 | "LocationPaths": null 537 | }, 538 | "FormattingOptions": { 539 | "OrganizeImports": false, 540 | "EnableEditorConfigSupport": true, 541 | "NewLine": "\n", 542 | "UseTabs": false, 543 | "TabSize": 4, 544 | "IndentationSize": 4, 545 | "SpacingAfterMethodDeclarationName": false, 546 | "SeparateImportDirectiveGroups": false, 547 | "SpaceWithinMethodDeclarationParenthesis": false, 548 | "SpaceBetweenEmptyMethodDeclarationParentheses": false, 549 | "SpaceAfterMethodCallName": false, 550 | "SpaceWithinMethodCallParentheses": false, 551 | "SpaceBetweenEmptyMethodCallParentheses": false, 552 | "SpaceAfterControlFlowStatementKeyword": true, 553 | "SpaceWithinExpressionParentheses": false, 554 | "SpaceWithinCastParentheses": false, 555 | "SpaceWithinOtherParentheses": false, 556 | "SpaceAfterCast": false, 557 | "SpaceBeforeOpenSquareBracket": false, 558 | "SpaceBetweenEmptySquareBrackets": false, 559 | "SpaceWithinSquareBrackets": false, 560 | "SpaceAfterColonInBaseTypeDeclaration": true, 561 | "SpaceAfterComma": true, 562 | "SpaceAfterDot": false, 563 | "SpaceAfterSemicolonsInForStatement": true, 564 | "SpaceBeforeColonInBaseTypeDeclaration": true, 565 | "SpaceBeforeComma": false, 566 | "SpaceBeforeDot": false, 567 | "SpaceBeforeSemicolonsInForStatement": false, 568 | "SpacingAroundBinaryOperator": "single", 569 | "IndentBraces": false, 570 | "IndentBlock": true, 571 | "IndentSwitchSection": true, 572 | "IndentSwitchCaseSection": true, 573 | "IndentSwitchCaseSectionWhenBlock": true, 574 | "LabelPositioning": "oneLess", 575 | "WrappingPreserveSingleLine": true, 576 | "WrappingKeepStatementsOnSingleLine": true, 577 | "NewLinesForBracesInTypes": true, 578 | "NewLinesForBracesInMethods": true, 579 | "NewLinesForBracesInProperties": true, 580 | "NewLinesForBracesInAccessors": true, 581 | "NewLinesForBracesInAnonymousMethods": true, 582 | "NewLinesForBracesInControlBlocks": true, 583 | "NewLinesForBracesInAnonymousTypes": true, 584 | "NewLinesForBracesInObjectCollectionArrayInitializers": true, 585 | "NewLinesForBracesInLambdaExpressionBody": true, 586 | "NewLineForElse": true, 587 | "NewLineForCatch": true, 588 | "NewLineForFinally": true, 589 | "NewLineForMembersInObjectInit": true, 590 | "NewLineForMembersInAnonymousTypes": true, 591 | "NewLineForClausesInQuery": true 592 | }, 593 | "FileOptions": { 594 | "SystemExcludeSearchPatterns": [ 595 | "**/node_modules/**/*", 596 | "**/bin/**/*", 597 | "**/obj/**/*", 598 | "**/.git/**/*", 599 | "**/.git", 600 | "**/.svn", 601 | "**/.hg", 602 | "**/CVS", 603 | "**/.DS_Store", 604 | "**/Thumbs.db" 605 | ], 606 | "ExcludeSearchPatterns": [] 607 | }, 608 | "RenameOptions": { 609 | "RenameOverloads": false, 610 | "RenameInStrings": false, 611 | "RenameInComments": false 612 | }, 613 | "ImplementTypeOptions": { 614 | "InsertionBehavior": 0, 615 | "PropertyGenerationBehavior": 0 616 | }, 617 | "DotNetCliOptions": { 618 | "LocationPaths": null 619 | }, 620 | "Plugins": { 621 | "LocationPaths": null 622 | } 623 | }, 624 | "trace": "verbose", 625 | "workspaceFolders": [ 626 | { 627 | "uri": "$uri", 628 | "name": "$name" 629 | } 630 | ] 631 | } ``` -------------------------------------------------------------------------------- /test/solidlsp/erlang/test_erlang_symbol_retrieval.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Tests for the Erlang language server symbol-related functionality. 3 | 4 | These tests focus on the following methods: 5 | - request_containing_symbol 6 | - request_referencing_symbols 7 | - request_defining_symbol 8 | """ 9 | 10 | import os 11 | 12 | import pytest 13 | 14 | from solidlsp import SolidLanguageServer 15 | from solidlsp.ls_config import Language 16 | from solidlsp.ls_types import SymbolKind 17 | 18 | from . import ERLANG_LS_UNAVAILABLE, ERLANG_LS_UNAVAILABLE_REASON 19 | 20 | # These marks will be applied to all tests in this module 21 | pytestmark = [ 22 | pytest.mark.erlang, 23 | pytest.mark.skipif(ERLANG_LS_UNAVAILABLE, reason=f"Erlang LS not available: {ERLANG_LS_UNAVAILABLE_REASON}"), 24 | ] 25 | 26 | 27 | class TestErlangLanguageServerSymbols: 28 | """Test the Erlang language server's symbol-related functionality.""" 29 | 30 | @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) 31 | def test_request_containing_symbol_function(self, language_server: SolidLanguageServer) -> None: 32 | """Test request_containing_symbol for a function.""" 33 | # Test for a position inside the create_user function 34 | file_path = os.path.join("src", "models.erl") 35 | 36 | # Find the create_user function in the file 37 | content = language_server.retrieve_full_file_content(file_path) 38 | lines = content.split("\n") 39 | create_user_line = None 40 | for i, line in enumerate(lines): 41 | if "create_user(" in line and "-spec" not in line: 42 | create_user_line = i + 1 # Go inside the function body 43 | break 44 | 45 | if create_user_line is None: 46 | pytest.skip("Could not find create_user function") 47 | 48 | containing_symbol = language_server.request_containing_symbol(file_path, create_user_line, 10, include_body=True) 49 | 50 | # Verify that we found the containing symbol 51 | if containing_symbol: 52 | assert "create_user" in containing_symbol["name"] 53 | assert containing_symbol["kind"] == SymbolKind.Method or containing_symbol["kind"] == SymbolKind.Function 54 | if "body" in containing_symbol: 55 | assert "create_user" in containing_symbol["body"] 56 | 57 | @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) 58 | def test_request_containing_symbol_module(self, language_server: SolidLanguageServer) -> None: 59 | """Test request_containing_symbol for a module.""" 60 | # Test for a position inside the models module but outside any function 61 | file_path = os.path.join("src", "models.erl") 62 | 63 | # Find the module definition 64 | content = language_server.retrieve_full_file_content(file_path) 65 | lines = content.split("\n") 66 | module_line = None 67 | for i, line in enumerate(lines): 68 | if "-module(models)" in line: 69 | module_line = i + 2 # Go inside the module 70 | break 71 | 72 | if module_line is None: 73 | pytest.skip("Could not find models module") 74 | 75 | containing_symbol = language_server.request_containing_symbol(file_path, module_line, 5) 76 | 77 | # Verify that we found the containing symbol 78 | if containing_symbol: 79 | assert "models" in containing_symbol["name"] or "module" in containing_symbol["name"].lower() 80 | assert containing_symbol["kind"] == SymbolKind.Module or containing_symbol["kind"] == SymbolKind.Class 81 | 82 | @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) 83 | def test_request_containing_symbol_nested(self, language_server: SolidLanguageServer) -> None: 84 | """Test request_containing_symbol with nested scopes.""" 85 | # Test for a position inside a function which is inside a module 86 | file_path = os.path.join("src", "models.erl") 87 | 88 | # Find a function inside models module 89 | content = language_server.retrieve_full_file_content(file_path) 90 | lines = content.split("\n") 91 | function_body_line = None 92 | for i, line in enumerate(lines): 93 | if "create_user(" in line and "-spec" not in line: 94 | # Go deeper into the function body where there might be case expressions 95 | for j in range(i + 1, min(i + 10, len(lines))): 96 | if lines[j].strip() and not lines[j].strip().startswith("%"): 97 | function_body_line = j 98 | break 99 | break 100 | 101 | if function_body_line is None: 102 | pytest.skip("Could not find function body") 103 | 104 | containing_symbol = language_server.request_containing_symbol(file_path, function_body_line, 15) 105 | 106 | # Verify that we found the innermost containing symbol (the function) 107 | if containing_symbol: 108 | expected_names = ["create_user", "models"] 109 | assert any(name in containing_symbol["name"] for name in expected_names) 110 | 111 | @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) 112 | def test_request_containing_symbol_none(self, language_server: SolidLanguageServer) -> None: 113 | """Test request_containing_symbol for a position with no containing symbol.""" 114 | # Test for a position outside any function/module (e.g., in comments) 115 | file_path = os.path.join("src", "models.erl") 116 | # Line 1-2 are likely module declaration or comments 117 | containing_symbol = language_server.request_containing_symbol(file_path, 2, 10) 118 | 119 | # Should return None or an empty dictionary, or the top-level module 120 | # This is acceptable behavior for module-level positions 121 | assert containing_symbol is None or containing_symbol == {} or "models" in str(containing_symbol) 122 | 123 | @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) 124 | def test_request_referencing_symbols_record(self, language_server: SolidLanguageServer) -> None: 125 | """Test request_referencing_symbols for a record.""" 126 | # Test referencing symbols for user record 127 | file_path = os.path.join("include", "records.hrl") 128 | 129 | symbols = language_server.request_document_symbols(file_path) 130 | user_symbol = None 131 | for symbol_group in symbols: 132 | user_symbol = next((s for s in symbol_group if "user" in s.get("name", "")), None) 133 | if user_symbol: 134 | break 135 | 136 | if not user_symbol or "selectionRange" not in user_symbol: 137 | pytest.skip("User record symbol or its selectionRange not found") 138 | 139 | sel_start = user_symbol["selectionRange"]["start"] 140 | ref_symbols = [ 141 | ref.symbol for ref in language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"]) 142 | ] 143 | 144 | if ref_symbols: 145 | models_references = [ 146 | symbol 147 | for symbol in ref_symbols 148 | if "location" in symbol and "uri" in symbol["location"] and "models.erl" in symbol["location"]["uri"] 149 | ] 150 | # We expect some references from models.erl 151 | assert len(models_references) >= 0 # At least attempt to find references 152 | 153 | @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) 154 | def test_request_referencing_symbols_function(self, language_server: SolidLanguageServer) -> None: 155 | """Test request_referencing_symbols for a function.""" 156 | # Test referencing symbols for create_user function 157 | file_path = os.path.join("src", "models.erl") 158 | 159 | symbols = language_server.request_document_symbols(file_path) 160 | create_user_symbol = None 161 | for symbol_group in symbols: 162 | create_user_symbol = next((s for s in symbol_group if "create_user" in s.get("name", "")), None) 163 | if create_user_symbol: 164 | break 165 | 166 | if not create_user_symbol or "selectionRange" not in create_user_symbol: 167 | pytest.skip("create_user function symbol or its selectionRange not found") 168 | 169 | sel_start = create_user_symbol["selectionRange"]["start"] 170 | ref_symbols = [ 171 | ref.symbol for ref in language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"]) 172 | ] 173 | 174 | if ref_symbols: 175 | # We might find references from services.erl or test files 176 | service_references = [ 177 | symbol 178 | for symbol in ref_symbols 179 | if "location" in symbol 180 | and "uri" in symbol["location"] 181 | and ("services.erl" in symbol["location"]["uri"] or "test" in symbol["location"]["uri"]) 182 | ] 183 | assert len(service_references) >= 0 # At least attempt to find references 184 | 185 | @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) 186 | def test_request_referencing_symbols_none(self, language_server: SolidLanguageServer) -> None: 187 | """Test request_referencing_symbols for a position with no symbol.""" 188 | file_path = os.path.join("src", "models.erl") 189 | # Line 3 is likely a blank line or comment 190 | try: 191 | ref_symbols = [ref.symbol for ref in language_server.request_referencing_symbols(file_path, 3, 0)] 192 | # If we get here, make sure we got an empty result 193 | assert ref_symbols == [] or ref_symbols is None 194 | except Exception: 195 | # The method might raise an exception for invalid positions 196 | # which is acceptable behavior 197 | pass 198 | 199 | # Tests for request_defining_symbol 200 | @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) 201 | def test_request_defining_symbol_function_call(self, language_server: SolidLanguageServer) -> None: 202 | """Test request_defining_symbol for a function call.""" 203 | # Find a place where models:create_user is called in services.erl 204 | file_path = os.path.join("src", "services.erl") 205 | content = language_server.retrieve_full_file_content(file_path) 206 | lines = content.split("\n") 207 | models_call_line = None 208 | for i, line in enumerate(lines): 209 | if "models:create_user(" in line: 210 | models_call_line = i 211 | break 212 | 213 | if models_call_line is None: 214 | pytest.skip("Could not find models:create_user call") 215 | 216 | # Try to find the definition of models:create_user 217 | defining_symbol = language_server.request_defining_symbol(file_path, models_call_line, 20) 218 | 219 | if defining_symbol: 220 | assert "create_user" in defining_symbol.get("name", "") or "models" in defining_symbol.get("name", "") 221 | if "location" in defining_symbol and "uri" in defining_symbol["location"]: 222 | assert "models.erl" in defining_symbol["location"]["uri"] 223 | 224 | @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) 225 | def test_request_defining_symbol_record_usage(self, language_server: SolidLanguageServer) -> None: 226 | """Test request_defining_symbol for a record usage.""" 227 | # Find a place where #user{} record is used in models.erl 228 | file_path = os.path.join("src", "models.erl") 229 | content = language_server.retrieve_full_file_content(file_path) 230 | lines = content.split("\n") 231 | record_usage_line = None 232 | for i, line in enumerate(lines): 233 | if "#user{" in line: 234 | record_usage_line = i 235 | break 236 | 237 | if record_usage_line is None: 238 | pytest.skip("Could not find #user{} record usage") 239 | 240 | defining_symbol = language_server.request_defining_symbol(file_path, record_usage_line, 10) 241 | 242 | if defining_symbol: 243 | assert "user" in defining_symbol.get("name", "").lower() 244 | if "location" in defining_symbol and "uri" in defining_symbol["location"]: 245 | assert "records.hrl" in defining_symbol["location"]["uri"] 246 | 247 | @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) 248 | def test_request_defining_symbol_module_call(self, language_server: SolidLanguageServer) -> None: 249 | """Test request_defining_symbol for a module function call.""" 250 | # Find a place where utils:validate_input is called 251 | file_path = os.path.join("src", "models.erl") 252 | content = language_server.retrieve_full_file_content(file_path) 253 | lines = content.split("\n") 254 | utils_call_line = None 255 | for i, line in enumerate(lines): 256 | if "validate_email(" in line: 257 | utils_call_line = i 258 | break 259 | 260 | if utils_call_line is None: 261 | pytest.skip("Could not find function call in models.erl") 262 | 263 | defining_symbol = language_server.request_defining_symbol(file_path, utils_call_line, 15) 264 | 265 | if defining_symbol: 266 | assert "validate" in defining_symbol.get("name", "") or "email" in defining_symbol.get("name", "") 267 | 268 | @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) 269 | def test_request_defining_symbol_none(self, language_server: SolidLanguageServer) -> None: 270 | """Test request_defining_symbol for a position with no symbol.""" 271 | # Test for a position with no symbol (e.g., whitespace or comment) 272 | file_path = os.path.join("src", "models.erl") 273 | # Line 3 is likely a blank line or comment 274 | defining_symbol = language_server.request_defining_symbol(file_path, 3, 0) 275 | 276 | # Should return None or empty 277 | assert defining_symbol is None or defining_symbol == {} 278 | 279 | @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) 280 | def test_symbol_methods_integration(self, language_server: SolidLanguageServer) -> None: 281 | """Test integration between different symbol methods.""" 282 | file_path = os.path.join("src", "models.erl") 283 | 284 | # Find create_user function definition 285 | content = language_server.retrieve_full_file_content(file_path) 286 | lines = content.split("\n") 287 | create_user_line = None 288 | for i, line in enumerate(lines): 289 | if "create_user(" in line and "-spec" not in line: 290 | create_user_line = i 291 | break 292 | 293 | if create_user_line is None: 294 | pytest.skip("Could not find create_user function") 295 | 296 | # Test containing symbol 297 | containing = language_server.request_containing_symbol(file_path, create_user_line + 2, 10) 298 | 299 | if containing: 300 | # Test that we can find references to this symbol 301 | if "location" in containing and "range" in containing["location"]: 302 | start_pos = containing["location"]["range"]["start"] 303 | refs = [ 304 | ref.symbol for ref in language_server.request_referencing_symbols(file_path, start_pos["line"], start_pos["character"]) 305 | ] 306 | # We should find some references or none (both are valid outcomes) 307 | assert isinstance(refs, list) 308 | 309 | @pytest.mark.timeout(120) # Add explicit timeout for this complex test 310 | @pytest.mark.xfail( 311 | reason="Known intermittent timeout issue in Erlang LS in CI environments. " 312 | "May pass locally but can timeout on slower CI systems.", 313 | strict=False, 314 | ) 315 | @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) 316 | def test_symbol_tree_structure(self, language_server: SolidLanguageServer) -> None: 317 | """Test that symbol tree structure is correctly built.""" 318 | symbol_tree = language_server.request_full_symbol_tree() 319 | 320 | # Should get a tree structure 321 | assert len(symbol_tree) > 0 322 | 323 | # Should have our test repository structure 324 | root = symbol_tree[0] 325 | assert "children" in root 326 | 327 | # Look for src directory 328 | src_dir = None 329 | for child in root["children"]: 330 | if child["name"] == "src": 331 | src_dir = child 332 | break 333 | 334 | if src_dir: 335 | # Check for our Erlang modules 336 | file_names = [child["name"] for child in src_dir.get("children", [])] 337 | expected_modules = ["models", "services", "utils", "app"] 338 | found_modules = [name for name in expected_modules if any(name in fname for fname in file_names)] 339 | assert len(found_modules) > 0, f"Expected to find some modules from {expected_modules}, but got {file_names}" 340 | 341 | @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) 342 | def test_request_dir_overview(self, language_server: SolidLanguageServer) -> None: 343 | """Test request_dir_overview functionality.""" 344 | src_overview = language_server.request_dir_overview("src") 345 | 346 | # Should get an overview of the src directory 347 | assert src_overview is not None 348 | overview_keys = list(src_overview.keys()) if hasattr(src_overview, "keys") else [] 349 | src_files = [key for key in overview_keys if key.startswith("src/") or "src" in key] 350 | assert len(src_files) > 0, f"Expected to find src/ files in overview keys: {overview_keys}" 351 | 352 | # Should contain information about our modules 353 | overview_text = str(src_overview).lower() 354 | expected_terms = ["models", "services", "user", "create_user", "gen_server"] 355 | found_terms = [term for term in expected_terms if term in overview_text] 356 | assert len(found_terms) > 0, f"Expected to find some terms from {expected_terms} in overview" 357 | 358 | @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) 359 | def test_containing_symbol_of_record_field(self, language_server: SolidLanguageServer) -> None: 360 | """Test containing symbol for record field access.""" 361 | file_path = os.path.join("src", "models.erl") 362 | 363 | # Find a record field access like User#user.name 364 | content = language_server.retrieve_full_file_content(file_path) 365 | lines = content.split("\n") 366 | record_field_line = None 367 | for i, line in enumerate(lines): 368 | if "#user{" in line and ("name" in line or "email" in line or "id" in line): 369 | record_field_line = i 370 | break 371 | 372 | if record_field_line is None: 373 | pytest.skip("Could not find record field access") 374 | 375 | containing_symbol = language_server.request_containing_symbol(file_path, record_field_line, 10) 376 | 377 | if containing_symbol: 378 | # Should be contained within a function 379 | assert "name" in containing_symbol 380 | expected_names = ["create_user", "update_user", "format_user_info"] 381 | assert any(name in containing_symbol["name"] for name in expected_names) 382 | 383 | @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) 384 | def test_containing_symbol_of_spec(self, language_server: SolidLanguageServer) -> None: 385 | """Test containing symbol for function specs.""" 386 | file_path = os.path.join("src", "models.erl") 387 | 388 | # Find a -spec directive 389 | content = language_server.retrieve_full_file_content(file_path) 390 | lines = content.split("\n") 391 | spec_line = None 392 | for i, line in enumerate(lines): 393 | if line.strip().startswith("-spec") and "create_user" in line: 394 | spec_line = i 395 | break 396 | 397 | if spec_line is None: 398 | pytest.skip("Could not find -spec directive") 399 | 400 | containing_symbol = language_server.request_containing_symbol(file_path, spec_line, 5) 401 | 402 | if containing_symbol: 403 | # Should be contained within the module or the function it specifies 404 | assert "name" in containing_symbol 405 | expected_names = ["models", "create_user"] 406 | assert any(name in containing_symbol["name"] for name in expected_names) 407 | 408 | @pytest.mark.timeout(90) # Add explicit timeout 409 | @pytest.mark.xfail( 410 | reason="Known intermittent timeout issue in Erlang LS in CI environments. " 411 | "May pass locally but can timeout on slower CI systems, especially macOS. " 412 | "Similar to known Next LS timeout issues.", 413 | strict=False, 414 | ) 415 | @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) 416 | def test_referencing_symbols_across_files(self, language_server: SolidLanguageServer) -> None: 417 | """Test finding references across different files.""" 418 | # Test that we can find references to models module functions in services.erl 419 | file_path = os.path.join("src", "models.erl") 420 | 421 | symbols = language_server.request_document_symbols(file_path) 422 | create_user_symbol = None 423 | for symbol_group in symbols: 424 | create_user_symbol = next((s for s in symbol_group if "create_user" in s.get("name", "")), None) 425 | if create_user_symbol: 426 | break 427 | 428 | if not create_user_symbol or "selectionRange" not in create_user_symbol: 429 | pytest.skip("create_user function symbol not found") 430 | 431 | sel_start = create_user_symbol["selectionRange"]["start"] 432 | ref_symbols = [ 433 | ref.symbol for ref in language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"]) 434 | ] 435 | 436 | # Look for cross-file references 437 | cross_file_refs = [ 438 | symbol 439 | for symbol in ref_symbols 440 | if "location" in symbol and "uri" in symbol["location"] and not symbol["location"]["uri"].endswith("models.erl") 441 | ] 442 | 443 | # We might find references in services.erl or test files 444 | if cross_file_refs: 445 | assert len(cross_file_refs) > 0, "Should find some cross-file references" 446 | ``` -------------------------------------------------------------------------------- /src/solidlsp/language_servers/ruby_lsp.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Ruby LSP Language Server implementation using Shopify's ruby-lsp. 3 | Provides modern Ruby language server capabilities with improved performance. 4 | """ 5 | 6 | import json 7 | import logging 8 | import os 9 | import pathlib 10 | import shutil 11 | import subprocess 12 | import threading 13 | 14 | from overrides import override 15 | 16 | from solidlsp.ls import SolidLanguageServer 17 | from solidlsp.ls_config import LanguageServerConfig 18 | from solidlsp.ls_logger import LanguageServerLogger 19 | from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams 20 | from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo 21 | from solidlsp.settings import SolidLSPSettings 22 | 23 | 24 | class RubyLsp(SolidLanguageServer): 25 | """ 26 | Provides Ruby specific instantiation of the LanguageServer class using ruby-lsp. 27 | Contains various configurations and settings specific to Ruby with modern LSP features. 28 | """ 29 | 30 | def __init__( 31 | self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings 32 | ): 33 | """ 34 | Creates a RubyLsp instance. This class is not meant to be instantiated directly. 35 | Use LanguageServer.create() instead. 36 | """ 37 | ruby_lsp_executable = self._setup_runtime_dependencies(logger, config, repository_root_path) 38 | super().__init__( 39 | config, 40 | logger, 41 | repository_root_path, 42 | ProcessLaunchInfo(cmd=ruby_lsp_executable, cwd=repository_root_path), 43 | "ruby", 44 | solidlsp_settings, 45 | ) 46 | self.analysis_complete = threading.Event() 47 | self.service_ready_event = threading.Event() 48 | 49 | # Set timeout for ruby-lsp requests - ruby-lsp is fast 50 | self.set_request_timeout(30.0) # 30 seconds for initialization and requests 51 | 52 | @override 53 | def is_ignored_dirname(self, dirname: str) -> bool: 54 | """Override to ignore Ruby-specific directories that cause performance issues.""" 55 | ruby_ignored_dirs = [ 56 | "vendor", # Ruby vendor directory 57 | ".bundle", # Bundler cache 58 | "tmp", # Temporary files 59 | "log", # Log files 60 | "coverage", # Test coverage reports 61 | ".yardoc", # YARD documentation cache 62 | "doc", # Generated documentation 63 | "node_modules", # Node modules (for Rails with JS) 64 | "storage", # Active Storage files (Rails) 65 | "public/packs", # Webpacker output 66 | "public/webpack", # Webpack output 67 | "public/assets", # Rails compiled assets 68 | ] 69 | return super().is_ignored_dirname(dirname) or dirname in ruby_ignored_dirs 70 | 71 | @override 72 | def _get_wait_time_for_cross_file_referencing(self) -> float: 73 | """Override to provide optimal wait time for ruby-lsp cross-file reference resolution. 74 | 75 | ruby-lsp typically initializes quickly, but may need a brief moment 76 | for cross-file analysis in larger projects. 77 | """ 78 | return 0.5 # 500ms should be sufficient for ruby-lsp 79 | 80 | @staticmethod 81 | def _find_executable_with_extensions(executable_name: str) -> str | None: 82 | """ 83 | Find executable with Windows-specific extensions (.bat, .cmd, .exe) if on Windows. 84 | Returns the full path to the executable or None if not found. 85 | """ 86 | import platform 87 | 88 | if platform.system() == "Windows": 89 | # Try Windows-specific extensions first 90 | for ext in [".bat", ".cmd", ".exe"]: 91 | path = shutil.which(f"{executable_name}{ext}") 92 | if path: 93 | return path 94 | # Fall back to default search 95 | return shutil.which(executable_name) 96 | else: 97 | # Unix systems 98 | return shutil.which(executable_name) 99 | 100 | @staticmethod 101 | def _setup_runtime_dependencies(logger: LanguageServerLogger, config: LanguageServerConfig, repository_root_path: str) -> list[str]: 102 | """ 103 | Setup runtime dependencies for ruby-lsp and return the command list to start the server. 104 | Installation strategy: Bundler project > global ruby-lsp > gem install ruby-lsp 105 | """ 106 | # Detect rbenv-managed Ruby environment 107 | # When .ruby-version exists, it indicates the project uses rbenv for version management. 108 | # rbenv automatically reads .ruby-version to determine which Ruby version to use. 109 | # Using "rbenv exec" ensures commands run with the correct Ruby version and its gems. 110 | # 111 | # Why rbenv is preferred over system Ruby: 112 | # - Respects project-specific Ruby versions 113 | # - Avoids bundler version mismatches between system and project 114 | # - Ensures consistent environment across developers 115 | # 116 | # Fallback behavior: 117 | # If .ruby-version doesn't exist or rbenv isn't installed, we fall back to system Ruby. 118 | # This may cause issues if: 119 | # - System Ruby version differs from what the project expects 120 | # - System bundler version is incompatible with Gemfile.lock 121 | # - Project gems aren't installed in system Ruby 122 | ruby_version_file = os.path.join(repository_root_path, ".ruby-version") 123 | use_rbenv = os.path.exists(ruby_version_file) and shutil.which("rbenv") is not None 124 | 125 | if use_rbenv: 126 | ruby_cmd = ["rbenv", "exec", "ruby"] 127 | bundle_cmd = ["rbenv", "exec", "bundle"] 128 | logger.log(f"Using rbenv-managed Ruby (found {ruby_version_file})", logging.INFO) 129 | else: 130 | ruby_cmd = ["ruby"] 131 | bundle_cmd = ["bundle"] 132 | if os.path.exists(ruby_version_file): 133 | logger.log( 134 | f"Found {ruby_version_file} but rbenv is not installed. " 135 | "Using system Ruby. Consider installing rbenv for better version management: https://github.com/rbenv/rbenv", 136 | logging.WARNING, 137 | ) 138 | else: 139 | logger.log("No .ruby-version file found, using system Ruby", logging.INFO) 140 | 141 | # Check if Ruby is installed 142 | try: 143 | result = subprocess.run(ruby_cmd + ["--version"], check=True, capture_output=True, cwd=repository_root_path, text=True) 144 | ruby_version = result.stdout.strip() 145 | logger.log(f"Ruby version: {ruby_version}", logging.INFO) 146 | 147 | # Extract version number for compatibility checks 148 | import re 149 | 150 | version_match = re.search(r"ruby (\d+)\.(\d+)\.(\d+)", ruby_version) 151 | if version_match: 152 | major, minor, patch = map(int, version_match.groups()) 153 | if major < 2 or (major == 2 and minor < 6): 154 | logger.log(f"Warning: Ruby {major}.{minor}.{patch} detected. ruby-lsp works best with Ruby 2.6+", logging.WARNING) 155 | 156 | except subprocess.CalledProcessError as e: 157 | error_msg = e.stderr if isinstance(e.stderr, str) else e.stderr.decode() if e.stderr else "Unknown error" 158 | raise RuntimeError( 159 | f"Error checking Ruby installation: {error_msg}. Please ensure Ruby is properly installed and in PATH." 160 | ) from e 161 | except FileNotFoundError as e: 162 | raise RuntimeError( 163 | "Ruby is not installed or not found in PATH. Please install Ruby using one of these methods:\n" 164 | " - Using rbenv: rbenv install 3.0.0 && rbenv global 3.0.0\n" 165 | " - Using RVM: rvm install 3.0.0 && rvm use 3.0.0 --default\n" 166 | " - Using asdf: asdf install ruby 3.0.0 && asdf global ruby 3.0.0\n" 167 | " - System package manager (brew install ruby, apt install ruby, etc.)" 168 | ) from e 169 | 170 | # Check for Bundler project (Gemfile exists) 171 | gemfile_path = os.path.join(repository_root_path, "Gemfile") 172 | gemfile_lock_path = os.path.join(repository_root_path, "Gemfile.lock") 173 | is_bundler_project = os.path.exists(gemfile_path) 174 | 175 | if is_bundler_project: 176 | logger.log("Detected Bundler project (Gemfile found)", logging.INFO) 177 | 178 | # Check if bundle command is available using Windows-compatible search 179 | bundle_path = RubyLsp._find_executable_with_extensions(bundle_cmd[0] if len(bundle_cmd) == 1 else "bundle") 180 | if not bundle_path: 181 | # Try common bundle executables 182 | for bundle_executable in ["bin/bundle", "bundle"]: 183 | if bundle_executable.startswith("bin/"): 184 | bundle_full_path = os.path.join(repository_root_path, bundle_executable) 185 | else: 186 | bundle_full_path = RubyLsp._find_executable_with_extensions(bundle_executable) 187 | if bundle_full_path and os.path.exists(bundle_full_path): 188 | bundle_path = bundle_full_path if bundle_executable.startswith("bin/") else bundle_executable 189 | break 190 | 191 | if not bundle_path: 192 | logger.log( 193 | "Bundler project detected but 'bundle' command not found. Falling back to global ruby-lsp installation.", 194 | logging.WARNING, 195 | ) 196 | else: 197 | # Check if ruby-lsp is in Gemfile.lock 198 | ruby_lsp_in_bundle = False 199 | if os.path.exists(gemfile_lock_path): 200 | try: 201 | with open(gemfile_lock_path) as f: 202 | content = f.read() 203 | ruby_lsp_in_bundle = "ruby-lsp" in content.lower() 204 | except Exception as e: 205 | logger.log(f"Warning: Could not read Gemfile.lock: {e}", logging.WARNING) 206 | 207 | if ruby_lsp_in_bundle: 208 | logger.log("Found ruby-lsp in Gemfile.lock", logging.INFO) 209 | return bundle_cmd + ["exec", "ruby-lsp"] 210 | else: 211 | logger.log( 212 | "ruby-lsp not found in Gemfile.lock. Consider adding 'gem \"ruby-lsp\"' to your Gemfile for better compatibility.", 213 | logging.INFO, 214 | ) 215 | # Fall through to global installation check 216 | 217 | # Check if ruby-lsp is available globally using Windows-compatible search 218 | ruby_lsp_path = RubyLsp._find_executable_with_extensions("ruby-lsp") 219 | if ruby_lsp_path: 220 | logger.log(f"Found ruby-lsp at: {ruby_lsp_path}", logging.INFO) 221 | return [ruby_lsp_path] 222 | 223 | # Try to install ruby-lsp globally 224 | logger.log("ruby-lsp not found, attempting to install globally...", logging.INFO) 225 | try: 226 | subprocess.run(["gem", "install", "ruby-lsp"], check=True, capture_output=True, cwd=repository_root_path) 227 | logger.log("Successfully installed ruby-lsp globally", logging.INFO) 228 | # Find the newly installed ruby-lsp executable 229 | ruby_lsp_path = RubyLsp._find_executable_with_extensions("ruby-lsp") 230 | return [ruby_lsp_path] if ruby_lsp_path else ["ruby-lsp"] 231 | except subprocess.CalledProcessError as e: 232 | error_msg = e.stderr if isinstance(e.stderr, str) else e.stderr.decode() if e.stderr else str(e) 233 | if is_bundler_project: 234 | raise RuntimeError( 235 | f"Failed to install ruby-lsp globally: {error_msg}\n" 236 | "For Bundler projects, please add 'gem \"ruby-lsp\"' to your Gemfile and run 'bundle install'.\n" 237 | "Alternatively, install globally: gem install ruby-lsp" 238 | ) from e 239 | raise RuntimeError(f"Failed to install ruby-lsp: {error_msg}\nPlease try installing manually: gem install ruby-lsp") from e 240 | 241 | @staticmethod 242 | def _detect_rails_project(repository_root_path: str) -> bool: 243 | """ 244 | Detect if this is a Rails project by checking for Rails-specific files. 245 | """ 246 | rails_indicators = [ 247 | "config/application.rb", 248 | "config/environment.rb", 249 | "app/controllers/application_controller.rb", 250 | "Rakefile", 251 | ] 252 | 253 | for indicator in rails_indicators: 254 | if os.path.exists(os.path.join(repository_root_path, indicator)): 255 | return True 256 | 257 | # Check for Rails in Gemfile 258 | gemfile_path = os.path.join(repository_root_path, "Gemfile") 259 | if os.path.exists(gemfile_path): 260 | try: 261 | with open(gemfile_path) as f: 262 | content = f.read().lower() 263 | if "gem 'rails'" in content or 'gem "rails"' in content: 264 | return True 265 | except Exception: 266 | pass 267 | 268 | return False 269 | 270 | @staticmethod 271 | def _get_ruby_exclude_patterns(repository_root_path: str) -> list[str]: 272 | """ 273 | Get Ruby and Rails-specific exclude patterns for better performance. 274 | """ 275 | base_patterns = [ 276 | "**/vendor/**", # Ruby vendor directory 277 | "**/.bundle/**", # Bundler cache 278 | "**/tmp/**", # Temporary files 279 | "**/log/**", # Log files 280 | "**/coverage/**", # Test coverage reports 281 | "**/.yardoc/**", # YARD documentation cache 282 | "**/doc/**", # Generated documentation 283 | "**/.git/**", # Git directory 284 | "**/node_modules/**", # Node modules (for Rails with JS) 285 | "**/public/assets/**", # Rails compiled assets 286 | ] 287 | 288 | # Add Rails-specific patterns if this is a Rails project 289 | if RubyLsp._detect_rails_project(repository_root_path): 290 | base_patterns.extend( 291 | [ 292 | "**/app/assets/builds/**", # Rails 7+ CSS builds 293 | "**/storage/**", # Active Storage 294 | "**/public/packs/**", # Webpacker 295 | "**/public/webpack/**", # Webpack 296 | ] 297 | ) 298 | 299 | return base_patterns 300 | 301 | def _get_initialize_params(self) -> InitializeParams: 302 | """ 303 | Returns ruby-lsp specific initialization parameters. 304 | """ 305 | exclude_patterns = self._get_ruby_exclude_patterns(self.repository_root_path) 306 | 307 | initialize_params = { 308 | "processId": os.getpid(), 309 | "rootPath": self.repository_root_path, 310 | "rootUri": pathlib.Path(self.repository_root_path).as_uri(), 311 | "capabilities": { 312 | "workspace": { 313 | "workspaceEdit": {"documentChanges": True}, 314 | "configuration": True, 315 | }, 316 | "window": { 317 | "workDoneProgress": True, 318 | }, 319 | "textDocument": { 320 | "documentSymbol": { 321 | "hierarchicalDocumentSymbolSupport": True, 322 | "symbolKind": {"valueSet": list(range(1, 27))}, 323 | }, 324 | "completion": { 325 | "completionItem": { 326 | "snippetSupport": True, 327 | "commitCharactersSupport": True, 328 | } 329 | }, 330 | }, 331 | }, 332 | "initializationOptions": { 333 | # ruby-lsp enables all features by default, so we don't need to specify enabledFeatures 334 | "experimentalFeaturesEnabled": False, 335 | "featuresConfiguration": {}, 336 | "indexing": { 337 | "includedPatterns": ["**/*.rb", "**/*.rake", "**/*.ru", "**/*.erb"], 338 | "excludedPatterns": exclude_patterns, 339 | }, 340 | }, 341 | } 342 | 343 | return initialize_params 344 | 345 | def _start_server(self) -> None: 346 | """ 347 | Starts the ruby-lsp Language Server for Ruby 348 | """ 349 | 350 | def register_capability_handler(params: dict) -> None: 351 | assert "registrations" in params 352 | for registration in params["registrations"]: 353 | self.logger.log(f"Registered capability: {registration['method']}", logging.INFO) 354 | return 355 | 356 | def lang_status_handler(params: dict) -> None: 357 | self.logger.log(f"LSP: language/status: {params}", logging.INFO) 358 | if params.get("type") == "ready": 359 | self.logger.log("ruby-lsp service is ready.", logging.INFO) 360 | self.analysis_complete.set() 361 | self.completions_available.set() 362 | 363 | def execute_client_command_handler(params: dict) -> list: 364 | return [] 365 | 366 | def do_nothing(params: dict) -> None: 367 | return 368 | 369 | def window_log_message(msg: dict) -> None: 370 | self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) 371 | 372 | def progress_handler(params: dict) -> None: 373 | # ruby-lsp sends progress notifications during indexing 374 | self.logger.log(f"LSP: $/progress: {params}", logging.DEBUG) 375 | if "value" in params: 376 | value = params["value"] 377 | # Check for completion indicators 378 | if value.get("kind") == "end": 379 | self.logger.log("ruby-lsp indexing complete ($/progress end)", logging.INFO) 380 | self.analysis_complete.set() 381 | self.completions_available.set() 382 | elif value.get("kind") == "begin": 383 | self.logger.log("ruby-lsp indexing started ($/progress begin)", logging.INFO) 384 | elif "percentage" in value: 385 | percentage = value.get("percentage", 0) 386 | self.logger.log(f"ruby-lsp indexing progress: {percentage}%", logging.DEBUG) 387 | # Handle direct progress format (fallback) 388 | elif "token" in params and "value" in params: 389 | token = params.get("token") 390 | if isinstance(token, str) and "indexing" in token.lower(): 391 | value = params.get("value", {}) 392 | if value.get("kind") == "end" or value.get("percentage") == 100: 393 | self.logger.log("ruby-lsp indexing complete (token progress)", logging.INFO) 394 | self.analysis_complete.set() 395 | self.completions_available.set() 396 | 397 | def window_work_done_progress_create(params: dict) -> None: 398 | """Handle workDoneProgress/create requests from ruby-lsp""" 399 | self.logger.log(f"LSP: window/workDoneProgress/create: {params}", logging.DEBUG) 400 | return {} 401 | 402 | self.server.on_request("client/registerCapability", register_capability_handler) 403 | self.server.on_notification("language/status", lang_status_handler) 404 | self.server.on_notification("window/logMessage", window_log_message) 405 | self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) 406 | self.server.on_notification("$/progress", progress_handler) 407 | self.server.on_request("window/workDoneProgress/create", window_work_done_progress_create) 408 | self.server.on_notification("textDocument/publishDiagnostics", do_nothing) 409 | 410 | self.logger.log("Starting ruby-lsp server process", logging.INFO) 411 | self.server.start() 412 | initialize_params = self._get_initialize_params() 413 | 414 | self.logger.log( 415 | "Sending initialize request from LSP client to LSP server and awaiting response", 416 | logging.INFO, 417 | ) 418 | self.logger.log(f"Sending init params: {json.dumps(initialize_params, indent=4)}", logging.INFO) 419 | init_response = self.server.send.initialize(initialize_params) 420 | self.logger.log(f"Received init response: {init_response}", logging.INFO) 421 | 422 | # Verify expected capabilities 423 | # Note: ruby-lsp may return textDocumentSync in different formats (number or object) 424 | text_document_sync = init_response["capabilities"].get("textDocumentSync") 425 | if isinstance(text_document_sync, int): 426 | assert text_document_sync in [1, 2], f"Unexpected textDocumentSync value: {text_document_sync}" 427 | elif isinstance(text_document_sync, dict): 428 | # ruby-lsp returns an object with change property 429 | assert "change" in text_document_sync, "textDocumentSync object should have 'change' property" 430 | 431 | assert "completionProvider" in init_response["capabilities"] 432 | 433 | self.server.notify.initialized({}) 434 | # Wait for ruby-lsp to complete its initial indexing 435 | # ruby-lsp has fast indexing 436 | self.logger.log("Waiting for ruby-lsp to complete initial indexing...", logging.INFO) 437 | if self.analysis_complete.wait(timeout=30.0): 438 | self.logger.log("ruby-lsp initial indexing complete, server ready", logging.INFO) 439 | else: 440 | self.logger.log("Timeout waiting for ruby-lsp indexing completion, proceeding anyway", logging.WARNING) 441 | # Fallback: assume indexing is complete after timeout 442 | self.analysis_complete.set() 443 | self.completions_available.set() 444 | 445 | def _handle_initialization_response(self, init_response): 446 | """ 447 | Handle the initialization response from ruby-lsp and validate capabilities. 448 | """ 449 | if "capabilities" in init_response: 450 | capabilities = init_response["capabilities"] 451 | 452 | # Validate textDocumentSync (ruby-lsp may return different formats) 453 | text_document_sync = capabilities.get("textDocumentSync") 454 | if isinstance(text_document_sync, int): 455 | assert text_document_sync in [1, 2], f"Unexpected textDocumentSync value: {text_document_sync}" 456 | elif isinstance(text_document_sync, dict): 457 | # ruby-lsp returns an object with change property 458 | assert "change" in text_document_sync, "textDocumentSync object should have 'change' property" 459 | 460 | # Log important capabilities 461 | important_capabilities = [ 462 | "completionProvider", 463 | "hoverProvider", 464 | "definitionProvider", 465 | "referencesProvider", 466 | "documentSymbolProvider", 467 | "codeActionProvider", 468 | "documentFormattingProvider", 469 | "semanticTokensProvider", 470 | ] 471 | 472 | for cap in important_capabilities: 473 | if cap in capabilities: 474 | self.logger.log(f"ruby-lsp {cap}: available", logging.DEBUG) 475 | 476 | # Signal that the service is ready 477 | self.service_ready_event.set() 478 | ``` -------------------------------------------------------------------------------- /src/solidlsp/language_servers/kotlin_language_server.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Provides Kotlin specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Kotlin. 3 | """ 4 | 5 | import dataclasses 6 | import logging 7 | import os 8 | import pathlib 9 | import stat 10 | 11 | from solidlsp.ls import SolidLanguageServer 12 | from solidlsp.ls_config import LanguageServerConfig 13 | from solidlsp.ls_logger import LanguageServerLogger 14 | from solidlsp.ls_utils import FileUtils, PlatformUtils 15 | from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams 16 | from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo 17 | from solidlsp.settings import SolidLSPSettings 18 | 19 | 20 | @dataclasses.dataclass 21 | class KotlinRuntimeDependencyPaths: 22 | """ 23 | Stores the paths to the runtime dependencies of Kotlin Language Server 24 | """ 25 | 26 | java_path: str 27 | java_home_path: str 28 | kotlin_executable_path: str 29 | 30 | 31 | class KotlinLanguageServer(SolidLanguageServer): 32 | """ 33 | Provides Kotlin specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Kotlin. 34 | """ 35 | 36 | def __init__( 37 | self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings 38 | ): 39 | """ 40 | Creates a Kotlin Language Server instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. 41 | """ 42 | runtime_dependency_paths = self._setup_runtime_dependencies(logger, config, solidlsp_settings) 43 | self.runtime_dependency_paths = runtime_dependency_paths 44 | 45 | # Create command to execute the Kotlin Language Server script 46 | cmd = [self.runtime_dependency_paths.kotlin_executable_path, "--stdio"] 47 | 48 | # Set environment variables including JAVA_HOME 49 | proc_env = {"JAVA_HOME": self.runtime_dependency_paths.java_home_path} 50 | 51 | super().__init__( 52 | config, 53 | logger, 54 | repository_root_path, 55 | ProcessLaunchInfo(cmd=cmd, env=proc_env, cwd=repository_root_path), 56 | "kotlin", 57 | solidlsp_settings, 58 | ) 59 | 60 | @classmethod 61 | def _setup_runtime_dependencies( 62 | cls, logger: LanguageServerLogger, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings 63 | ) -> KotlinRuntimeDependencyPaths: 64 | """ 65 | Setup runtime dependencies for Kotlin Language Server and return the paths. 66 | """ 67 | platform_id = PlatformUtils.get_platform_id() 68 | 69 | # Verify platform support 70 | assert ( 71 | platform_id.value.startswith("win-") or platform_id.value.startswith("linux-") or platform_id.value.startswith("osx-") 72 | ), "Only Windows, Linux and macOS platforms are supported for Kotlin in multilspy at the moment" 73 | 74 | # Runtime dependency information 75 | runtime_dependencies = { 76 | "runtimeDependency": { 77 | "id": "KotlinLsp", 78 | "description": "Kotlin Language Server", 79 | "url": "https://download-cdn.jetbrains.com/kotlin-lsp/0.253.10629/kotlin-0.253.10629.zip", 80 | "archiveType": "zip", 81 | }, 82 | "java": { 83 | "win-x64": { 84 | "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-win32-x64-1.42.0-561.vsix", 85 | "archiveType": "zip", 86 | "java_home_path": "extension/jre/21.0.7-win32-x86_64", 87 | "java_path": "extension/jre/21.0.7-win32-x86_64/bin/java.exe", 88 | }, 89 | "linux-x64": { 90 | "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-linux-x64-1.42.0-561.vsix", 91 | "archiveType": "zip", 92 | "java_home_path": "extension/jre/21.0.7-linux-x86_64", 93 | "java_path": "extension/jre/21.0.7-linux-x86_64/bin/java", 94 | }, 95 | "linux-arm64": { 96 | "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-linux-arm64-1.42.0-561.vsix", 97 | "archiveType": "zip", 98 | "java_home_path": "extension/jre/21.0.7-linux-aarch64", 99 | "java_path": "extension/jre/21.0.7-linux-aarch64/bin/java", 100 | }, 101 | "osx-x64": { 102 | "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-darwin-x64-1.42.0-561.vsix", 103 | "archiveType": "zip", 104 | "java_home_path": "extension/jre/21.0.7-macosx-x86_64", 105 | "java_path": "extension/jre/21.0.7-macosx-x86_64/bin/java", 106 | }, 107 | "osx-arm64": { 108 | "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-darwin-arm64-1.42.0-561.vsix", 109 | "archiveType": "zip", 110 | "java_home_path": "extension/jre/21.0.7-macosx-aarch64", 111 | "java_path": "extension/jre/21.0.7-macosx-aarch64/bin/java", 112 | }, 113 | }, 114 | } 115 | 116 | kotlin_dependency = runtime_dependencies["runtimeDependency"] 117 | java_dependency = runtime_dependencies["java"][platform_id.value] 118 | 119 | # Setup paths for dependencies 120 | static_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "kotlin_language_server") 121 | os.makedirs(static_dir, exist_ok=True) 122 | 123 | # Setup Java paths 124 | java_dir = os.path.join(static_dir, "java") 125 | os.makedirs(java_dir, exist_ok=True) 126 | 127 | java_home_path = os.path.join(java_dir, java_dependency["java_home_path"]) 128 | java_path = os.path.join(java_dir, java_dependency["java_path"]) 129 | 130 | # Download and extract Java if not exists 131 | if not os.path.exists(java_path): 132 | logger.log(f"Downloading Java for {platform_id.value}...", logging.INFO) 133 | FileUtils.download_and_extract_archive(logger, java_dependency["url"], java_dir, java_dependency["archiveType"]) 134 | # Make Java executable 135 | if not platform_id.value.startswith("win-"): 136 | os.chmod(java_path, 0o755) 137 | 138 | assert os.path.exists(java_path), f"Java executable not found at {java_path}" 139 | 140 | # Setup Kotlin Language Server paths 141 | kotlin_ls_dir = static_dir 142 | 143 | # Get platform-specific executable script path 144 | if platform_id.value.startswith("win-"): 145 | kotlin_script = os.path.join(kotlin_ls_dir, "kotlin-lsp.cmd") 146 | else: 147 | kotlin_script = os.path.join(kotlin_ls_dir, "kotlin-lsp.sh") 148 | 149 | # Download and extract Kotlin Language Server if script doesn't exist 150 | if not os.path.exists(kotlin_script): 151 | logger.log("Downloading Kotlin Language Server...", logging.INFO) 152 | FileUtils.download_and_extract_archive(logger, kotlin_dependency["url"], static_dir, kotlin_dependency["archiveType"]) 153 | 154 | # Make script executable on Unix platforms 155 | if os.path.exists(kotlin_script) and not platform_id.value.startswith("win-"): 156 | os.chmod( 157 | kotlin_script, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH 158 | ) 159 | 160 | # Use script file 161 | if os.path.exists(kotlin_script): 162 | kotlin_executable_path = kotlin_script 163 | logger.log(f"Using Kotlin Language Server script at {kotlin_script}", logging.INFO) 164 | else: 165 | raise FileNotFoundError(f"Kotlin Language Server script not found at {kotlin_script}") 166 | 167 | return KotlinRuntimeDependencyPaths( 168 | java_path=java_path, java_home_path=java_home_path, kotlin_executable_path=kotlin_executable_path 169 | ) 170 | 171 | @staticmethod 172 | def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: 173 | """ 174 | Returns the initialize params for the Kotlin Language Server. 175 | """ 176 | if not os.path.isabs(repository_absolute_path): 177 | repository_absolute_path = os.path.abspath(repository_absolute_path) 178 | 179 | root_uri = pathlib.Path(repository_absolute_path).as_uri() 180 | initialize_params = { 181 | "clientInfo": {"name": "Multilspy Kotlin Client", "version": "1.0.0"}, 182 | "locale": "en", 183 | "rootPath": repository_absolute_path, 184 | "rootUri": root_uri, 185 | "capabilities": { 186 | "workspace": { 187 | "applyEdit": True, 188 | "workspaceEdit": { 189 | "documentChanges": True, 190 | "resourceOperations": ["create", "rename", "delete"], 191 | "failureHandling": "textOnlyTransactional", 192 | "normalizesLineEndings": True, 193 | "changeAnnotationSupport": {"groupsOnLabel": True}, 194 | }, 195 | "didChangeConfiguration": {"dynamicRegistration": True}, 196 | "didChangeWatchedFiles": {"dynamicRegistration": True, "relativePatternSupport": True}, 197 | "symbol": { 198 | "dynamicRegistration": True, 199 | "symbolKind": {"valueSet": list(range(1, 27))}, 200 | "tagSupport": {"valueSet": [1]}, 201 | "resolveSupport": {"properties": ["location.range"]}, 202 | }, 203 | "codeLens": {"refreshSupport": True}, 204 | "executeCommand": {"dynamicRegistration": True}, 205 | "configuration": True, 206 | "workspaceFolders": True, 207 | "semanticTokens": {"refreshSupport": True}, 208 | "fileOperations": { 209 | "dynamicRegistration": True, 210 | "didCreate": True, 211 | "didRename": True, 212 | "didDelete": True, 213 | "willCreate": True, 214 | "willRename": True, 215 | "willDelete": True, 216 | }, 217 | "inlineValue": {"refreshSupport": True}, 218 | "inlayHint": {"refreshSupport": True}, 219 | "diagnostics": {"refreshSupport": True}, 220 | }, 221 | "textDocument": { 222 | "publishDiagnostics": { 223 | "relatedInformation": True, 224 | "versionSupport": False, 225 | "tagSupport": {"valueSet": [1, 2]}, 226 | "codeDescriptionSupport": True, 227 | "dataSupport": True, 228 | }, 229 | "synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True}, 230 | "completion": { 231 | "dynamicRegistration": True, 232 | "contextSupport": True, 233 | "completionItem": { 234 | "snippetSupport": False, 235 | "commitCharactersSupport": True, 236 | "documentationFormat": ["markdown", "plaintext"], 237 | "deprecatedSupport": True, 238 | "preselectSupport": True, 239 | "tagSupport": {"valueSet": [1]}, 240 | "insertReplaceSupport": False, 241 | "resolveSupport": {"properties": ["documentation", "detail", "additionalTextEdits"]}, 242 | "insertTextModeSupport": {"valueSet": [1, 2]}, 243 | "labelDetailsSupport": True, 244 | }, 245 | "insertTextMode": 2, 246 | "completionItemKind": { 247 | "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] 248 | }, 249 | "completionList": {"itemDefaults": ["commitCharacters", "editRange", "insertTextFormat", "insertTextMode"]}, 250 | }, 251 | "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, 252 | "signatureHelp": { 253 | "dynamicRegistration": True, 254 | "signatureInformation": { 255 | "documentationFormat": ["markdown", "plaintext"], 256 | "parameterInformation": {"labelOffsetSupport": True}, 257 | "activeParameterSupport": True, 258 | }, 259 | "contextSupport": True, 260 | }, 261 | "definition": {"dynamicRegistration": True, "linkSupport": True}, 262 | "references": {"dynamicRegistration": True}, 263 | "documentHighlight": {"dynamicRegistration": True}, 264 | "documentSymbol": { 265 | "dynamicRegistration": True, 266 | "symbolKind": {"valueSet": list(range(1, 27))}, 267 | "hierarchicalDocumentSymbolSupport": True, 268 | "tagSupport": {"valueSet": [1]}, 269 | "labelSupport": True, 270 | }, 271 | "codeAction": { 272 | "dynamicRegistration": True, 273 | "isPreferredSupport": True, 274 | "disabledSupport": True, 275 | "dataSupport": True, 276 | "resolveSupport": {"properties": ["edit"]}, 277 | "codeActionLiteralSupport": { 278 | "codeActionKind": { 279 | "valueSet": [ 280 | "", 281 | "quickfix", 282 | "refactor", 283 | "refactor.extract", 284 | "refactor.inline", 285 | "refactor.rewrite", 286 | "source", 287 | "source.organizeImports", 288 | ] 289 | } 290 | }, 291 | "honorsChangeAnnotations": False, 292 | }, 293 | "codeLens": {"dynamicRegistration": True}, 294 | "formatting": {"dynamicRegistration": True}, 295 | "rangeFormatting": {"dynamicRegistration": True}, 296 | "onTypeFormatting": {"dynamicRegistration": True}, 297 | "rename": { 298 | "dynamicRegistration": True, 299 | "prepareSupport": True, 300 | "prepareSupportDefaultBehavior": 1, 301 | "honorsChangeAnnotations": True, 302 | }, 303 | "documentLink": {"dynamicRegistration": True, "tooltipSupport": True}, 304 | "typeDefinition": {"dynamicRegistration": True, "linkSupport": True}, 305 | "implementation": {"dynamicRegistration": True, "linkSupport": True}, 306 | "colorProvider": {"dynamicRegistration": True}, 307 | "foldingRange": { 308 | "dynamicRegistration": True, 309 | "rangeLimit": 5000, 310 | "lineFoldingOnly": True, 311 | "foldingRangeKind": {"valueSet": ["comment", "imports", "region"]}, 312 | "foldingRange": {"collapsedText": False}, 313 | }, 314 | "declaration": {"dynamicRegistration": True, "linkSupport": True}, 315 | "selectionRange": {"dynamicRegistration": True}, 316 | "callHierarchy": {"dynamicRegistration": True}, 317 | "semanticTokens": { 318 | "dynamicRegistration": True, 319 | "tokenTypes": [ 320 | "namespace", 321 | "type", 322 | "class", 323 | "enum", 324 | "interface", 325 | "struct", 326 | "typeParameter", 327 | "parameter", 328 | "variable", 329 | "property", 330 | "enumMember", 331 | "event", 332 | "function", 333 | "method", 334 | "macro", 335 | "keyword", 336 | "modifier", 337 | "comment", 338 | "string", 339 | "number", 340 | "regexp", 341 | "operator", 342 | "decorator", 343 | ], 344 | "tokenModifiers": [ 345 | "declaration", 346 | "definition", 347 | "readonly", 348 | "static", 349 | "deprecated", 350 | "abstract", 351 | "async", 352 | "modification", 353 | "documentation", 354 | "defaultLibrary", 355 | ], 356 | "formats": ["relative"], 357 | "requests": {"range": True, "full": {"delta": True}}, 358 | "multilineTokenSupport": False, 359 | "overlappingTokenSupport": False, 360 | "serverCancelSupport": True, 361 | "augmentsSyntaxTokens": True, 362 | }, 363 | "linkedEditingRange": {"dynamicRegistration": True}, 364 | "typeHierarchy": {"dynamicRegistration": True}, 365 | "inlineValue": {"dynamicRegistration": True}, 366 | "inlayHint": { 367 | "dynamicRegistration": True, 368 | "resolveSupport": {"properties": ["tooltip", "textEdits", "label.tooltip", "label.location", "label.command"]}, 369 | }, 370 | "diagnostic": {"dynamicRegistration": True, "relatedDocumentSupport": False}, 371 | }, 372 | "window": { 373 | "showMessage": {"messageActionItem": {"additionalPropertiesSupport": True}}, 374 | "showDocument": {"support": True}, 375 | "workDoneProgress": True, 376 | }, 377 | "general": { 378 | "staleRequestSupport": { 379 | "cancel": True, 380 | "retryOnContentModified": [ 381 | "textDocument/semanticTokens/full", 382 | "textDocument/semanticTokens/range", 383 | "textDocument/semanticTokens/full/delta", 384 | ], 385 | }, 386 | "regularExpressions": {"engine": "ECMAScript", "version": "ES2020"}, 387 | "markdown": {"parser": "marked", "version": "1.1.0"}, 388 | "positionEncodings": ["utf-16"], 389 | }, 390 | "notebookDocument": {"synchronization": {"dynamicRegistration": True, "executionSummarySupport": True}}, 391 | }, 392 | "initializationOptions": { 393 | "workspaceFolders": [root_uri], 394 | "storagePath": None, 395 | "codegen": {"enabled": False}, 396 | "compiler": {"jvm": {"target": "default"}}, 397 | "completion": {"snippets": {"enabled": True}}, 398 | "diagnostics": {"enabled": True, "level": 4, "debounceTime": 250}, 399 | "scripts": {"enabled": True, "buildScriptsEnabled": True}, 400 | "indexing": {"enabled": True}, 401 | "externalSources": {"useKlsScheme": False, "autoConvertToKotlin": False}, 402 | "inlayHints": {"typeHints": False, "parameterHints": False, "chainedHints": False}, 403 | "formatting": { 404 | "formatter": "ktfmt", 405 | "ktfmt": { 406 | "style": "google", 407 | "indent": 4, 408 | "maxWidth": 100, 409 | "continuationIndent": 8, 410 | "removeUnusedImports": True, 411 | }, 412 | }, 413 | }, 414 | "trace": "verbose", 415 | "processId": os.getpid(), 416 | "workspaceFolders": [ 417 | { 418 | "uri": root_uri, 419 | "name": os.path.basename(repository_absolute_path), 420 | } 421 | ], 422 | } 423 | return initialize_params 424 | 425 | def _start_server(self): 426 | """ 427 | Starts the Kotlin Language Server 428 | """ 429 | 430 | def execute_client_command_handler(params): 431 | return [] 432 | 433 | def do_nothing(params): 434 | return 435 | 436 | def window_log_message(msg): 437 | self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) 438 | 439 | self.server.on_request("client/registerCapability", do_nothing) 440 | self.server.on_notification("language/status", do_nothing) 441 | self.server.on_notification("window/logMessage", window_log_message) 442 | self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) 443 | self.server.on_notification("$/progress", do_nothing) 444 | self.server.on_notification("textDocument/publishDiagnostics", do_nothing) 445 | self.server.on_notification("language/actionableNotification", do_nothing) 446 | 447 | self.logger.log("Starting Kotlin server process", logging.INFO) 448 | self.server.start() 449 | initialize_params = self._get_initialize_params(self.repository_root_path) 450 | 451 | self.logger.log( 452 | "Sending initialize request from LSP client to LSP server and awaiting response", 453 | logging.INFO, 454 | ) 455 | init_response = self.server.send.initialize(initialize_params) 456 | 457 | capabilities = init_response["capabilities"] 458 | assert "textDocumentSync" in capabilities, "Server must support textDocumentSync" 459 | assert "hoverProvider" in capabilities, "Server must support hover" 460 | assert "completionProvider" in capabilities, "Server must support code completion" 461 | assert "signatureHelpProvider" in capabilities, "Server must support signature help" 462 | assert "definitionProvider" in capabilities, "Server must support go to definition" 463 | assert "referencesProvider" in capabilities, "Server must support find references" 464 | assert "documentSymbolProvider" in capabilities, "Server must support document symbols" 465 | assert "workspaceSymbolProvider" in capabilities, "Server must support workspace symbols" 466 | assert "semanticTokensProvider" in capabilities, "Server must support semantic tokens" 467 | 468 | self.server.notify.initialized({}) 469 | self.completions_available.set() 470 | ``` -------------------------------------------------------------------------------- /src/serena/resources/dashboard/dashboard.js: -------------------------------------------------------------------------------- ```javascript 1 | class LogMessage { 2 | constructor(message, toolNames) { 3 | message = this.escapeHtml(message); 4 | const logLevel = this.determineLogLevel(message); 5 | const highlightedMessage = this.highlightToolNames(message, toolNames); 6 | this.$elem = $('<div>').addClass('log-' + logLevel).html(highlightedMessage + '\n'); 7 | } 8 | 9 | determineLogLevel(message) { 10 | if (message.startsWith('DEBUG')) { 11 | return 'debug'; 12 | } else if (message.startsWith('INFO')) { 13 | return 'info'; 14 | } else if (message.startsWith('WARNING')) { 15 | return 'warning'; 16 | } else if (message.startsWith('ERROR')) { 17 | return 'error'; 18 | } else { 19 | return 'default'; 20 | } 21 | } 22 | 23 | highlightToolNames(message, toolNames) { 24 | let highlightedMessage = message; 25 | toolNames.forEach(function(toolName) { 26 | const regex = new RegExp('\\b' + toolName + '\\b', 'gi'); 27 | highlightedMessage = highlightedMessage.replace(regex, '<span class="tool-name">' + toolName + '</span>'); 28 | }); 29 | return highlightedMessage; 30 | } 31 | 32 | escapeHtml (convertString) { 33 | if (typeof convertString !== 'string') return convertString; 34 | 35 | const patterns = { 36 | '<' : '<', 37 | '>' : '>', 38 | '&' : '&', 39 | '"' : '"', 40 | '\'' : ''', 41 | '`' : '`' 42 | }; 43 | 44 | return convertString.replace(/[<>&"'`]/g, match => patterns[match]); 45 | }; 46 | } 47 | 48 | class Dashboard { 49 | constructor() { 50 | let self = this; 51 | 52 | this.toolNames = []; 53 | this.currentMaxIdx = -1; 54 | this.pollInterval = null; 55 | this.failureCount = 0; 56 | this.$logContainer = $('#log-container'); 57 | this.$errorContainer = $('#error-container'); 58 | this.$loadButton = $('#load-logs'); 59 | this.$shutdownButton = $('#shutdown'); 60 | this.$toggleStats = $('#toggle-stats'); 61 | this.$statsSection = $('#stats-section'); 62 | this.$refreshStats = $('#refresh-stats'); 63 | this.$clearStats = $('#clear-stats'); 64 | this.$themeToggle = $('#theme-toggle'); 65 | this.$themeIcon = $('#theme-icon'); 66 | this.$themeText = $('#theme-text'); 67 | 68 | this.countChart = null; 69 | this.tokensChart = null; 70 | this.inputChart = null; 71 | this.outputChart = null; 72 | 73 | // register event handlers 74 | this.$loadButton.click(this.loadLogs.bind(this)); 75 | this.$shutdownButton.click(this.shutdown.bind(this)); 76 | this.$toggleStats.click(this.toggleStats.bind(this)); 77 | this.$refreshStats.click(this.loadStats.bind(this)); 78 | this.$clearStats.click(this.clearStats.bind(this)); 79 | this.$themeToggle.click(this.toggleTheme.bind(this)); 80 | 81 | // initialize theme 82 | this.initializeTheme(); 83 | 84 | // initialize the application 85 | this.loadToolNames().then(function() { 86 | // Load logs on page load after tool names are loaded 87 | self.loadLogs(); 88 | }); 89 | } 90 | 91 | displayLogMessage(message) { 92 | $('#log-container').append(new LogMessage(message, this.toolNames).$elem); 93 | } 94 | 95 | loadToolNames() { 96 | let self = this; 97 | return $.ajax({ 98 | url: '/get_tool_names', 99 | type: 'GET', 100 | success: function(response) { 101 | self.toolNames = response.tool_names || []; 102 | console.log('Loaded tool names:', self.toolNames); 103 | }, 104 | error: function(xhr, status, error) { 105 | console.error('Error loading tool names:', error); 106 | } 107 | }); 108 | } 109 | 110 | updateTitle(activeProject) { 111 | document.title = activeProject ? `${activeProject} – Serena Dashboard` : 'Serena Dashboard'; 112 | } 113 | 114 | loadLogs() { 115 | console.log("Loading logs"); 116 | let self = this; 117 | 118 | // Disable button and show loading state 119 | self.$loadButton.prop('disabled', true).text('Loading...'); 120 | self.$errorContainer.empty(); 121 | 122 | // Make API call 123 | $.ajax({ 124 | url: '/get_log_messages', 125 | type: 'POST', 126 | contentType: 'application/json', 127 | data: JSON.stringify({ 128 | start_idx: 0 129 | }), 130 | success: function(response) { 131 | // Clear existing logs 132 | self.$logContainer.empty(); 133 | 134 | // Update max_idx 135 | self.currentMaxIdx = response.max_idx || -1; 136 | 137 | // Display each log message 138 | if (response.messages && response.messages.length > 0) { 139 | response.messages.forEach(function(message) { 140 | self.displayLogMessage(message); 141 | }); 142 | 143 | // Auto-scroll to bottom 144 | const logContainer = $('#log-container')[0]; 145 | logContainer.scrollTop = logContainer.scrollHeight; 146 | } else { 147 | $('#log-container').html('<div class="loading">No log messages found.</div>'); 148 | } 149 | 150 | self.updateTitle(response.active_project); 151 | 152 | // Start periodic polling for new logs 153 | self.startPeriodicPolling(); 154 | }, 155 | error: function(xhr, status, error) { 156 | console.error('Error loading logs:', error); 157 | self.$errorContainer.html('<div class="error-message">Error loading logs: ' + 158 | (xhr.responseJSON ? xhr.responseJSON.detail : error) + '</div>'); 159 | }, 160 | complete: function() { 161 | // Re-enable button 162 | self.$loadButton.prop('disabled', false).text('Reload Log'); 163 | } 164 | }); 165 | } 166 | 167 | pollForNewLogs() { 168 | let self = this; 169 | console.log("Polling logs", this.currentMaxIdx); 170 | $.ajax({ 171 | url: '/get_log_messages', 172 | type: 'POST', 173 | contentType: 'application/json', 174 | data: JSON.stringify({ 175 | start_idx: self.currentMaxIdx + 1 176 | }), 177 | success: function(response) { 178 | self.failureCount = 0; 179 | // Only append new messages if we have any 180 | if (response.messages && response.messages.length > 0) { 181 | let wasAtBottom = false; 182 | const logContainer = $('#log-container')[0]; 183 | 184 | // Check if user was at the bottom before adding new logs 185 | if (logContainer.scrollHeight > 0) { 186 | wasAtBottom = (logContainer.scrollTop + logContainer.clientHeight) >= (logContainer.scrollHeight - 10); 187 | } 188 | 189 | // Append new messages 190 | response.messages.forEach(function(message) { 191 | self.displayLogMessage(message); 192 | }); 193 | 194 | // Update max_idx 195 | self.currentMaxIdx = response.max_idx || self.currentMaxIdx; 196 | 197 | // Auto-scroll to bottom if user was already at bottom 198 | if (wasAtBottom) { 199 | logContainer.scrollTop = logContainer.scrollHeight; 200 | } 201 | } else { 202 | // Update max_idx even if no new messages 203 | self.currentMaxIdx = response.max_idx || self.currentMaxIdx; 204 | } 205 | 206 | // Update window title with active project 207 | self.updateTitle(response.active_project); 208 | }, 209 | error: function(xhr, status, error) { 210 | console.error('Error polling for new logs:', error); 211 | self.failureCount++; 212 | if (self.failureCount >= 3) { 213 | console.log('Server appears to be down, closing tab'); 214 | window.close(); 215 | } 216 | } 217 | }); 218 | } 219 | 220 | startPeriodicPolling() { 221 | // Clear any existing interval 222 | if (this.pollInterval) { 223 | clearInterval(this.pollInterval); 224 | } 225 | 226 | // Start polling every second (1000ms) 227 | this.pollInterval = setInterval(this.pollForNewLogs.bind(this), 1000); 228 | } 229 | 230 | toggleStats() { 231 | if (this.$statsSection.is(':visible')) { 232 | this.$statsSection.hide(); 233 | this.$toggleStats.text('Show Stats'); 234 | } else { 235 | this.$statsSection.show(); 236 | this.$toggleStats.text('Hide Stats'); 237 | this.loadStats(); 238 | } 239 | } 240 | 241 | loadStats() { 242 | let self = this; 243 | $.when( 244 | $.ajax({ url: '/get_tool_stats', type: 'GET' }), 245 | $.ajax({ url: '/get_token_count_estimator_name', type: 'GET' }) 246 | ).done(function(statsResp, estimatorResp) { 247 | const stats = statsResp[0].stats; 248 | const tokenCountEstimatorName = estimatorResp[0].token_count_estimator_name; 249 | self.displayStats(stats, tokenCountEstimatorName); 250 | }).fail(function() { 251 | console.error('Error loading stats or estimator name'); 252 | }); 253 | } 254 | 255 | 256 | clearStats() { 257 | let self = this; 258 | $.ajax({ 259 | url: '/clear_tool_stats', 260 | type: 'POST', 261 | success: function() { 262 | self.loadStats(); 263 | }, 264 | error: function(xhr, status, error) { 265 | console.error('Error clearing stats:', error); 266 | } 267 | }); 268 | } 269 | 270 | displayStats(stats, tokenCountEstimatorName) { 271 | const names = Object.keys(stats); 272 | // If no stats collected 273 | if (names.length === 0) { 274 | // hide summary, charts, estimator name 275 | $('#stats-summary').hide(); 276 | $('#estimator-name').hide(); 277 | $('.charts-container').hide(); 278 | // show no-stats message 279 | $('#no-stats-message').show(); 280 | return; 281 | } else { 282 | // Ensure everything is visible 283 | $('#estimator-name').show(); 284 | $('#stats-summary').show(); 285 | $('.charts-container').show(); 286 | $('#no-stats-message').hide(); 287 | } 288 | 289 | $('#estimator-name').html(`<strong>Token count estimator:</strong> ${tokenCountEstimatorName}`); 290 | 291 | const counts = names.map(n => stats[n].num_times_called); 292 | const inputTokens = names.map(n => stats[n].input_tokens); 293 | const outputTokens = names.map(n => stats[n].output_tokens); 294 | const totalTokens = names.map(n => stats[n].input_tokens + stats[n].output_tokens); 295 | 296 | // Calculate totals for summary table 297 | const totalCalls = counts.reduce((sum, count) => sum + count, 0); 298 | const totalInputTokens = inputTokens.reduce((sum, tokens) => sum + tokens, 0); 299 | const totalOutputTokens = outputTokens.reduce((sum, tokens) => sum + tokens, 0); 300 | 301 | // Generate consistent colors for tools 302 | const colors = this.generateColors(names.length); 303 | 304 | const countCtx = document.getElementById('count-chart'); 305 | const tokensCtx = document.getElementById('tokens-chart'); 306 | const inputCtx = document.getElementById('input-chart'); 307 | const outputCtx = document.getElementById('output-chart'); 308 | 309 | if (this.countChart) this.countChart.destroy(); 310 | if (this.tokensChart) this.tokensChart.destroy(); 311 | if (this.inputChart) this.inputChart.destroy(); 312 | if (this.outputChart) this.outputChart.destroy(); 313 | 314 | // Update summary table 315 | this.updateSummaryTable(totalCalls, totalInputTokens, totalOutputTokens); 316 | 317 | // Register datalabels plugin 318 | Chart.register(ChartDataLabels); 319 | 320 | // Get theme-aware colors 321 | const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; 322 | const textColor = isDark ? '#ffffff' : '#000000'; 323 | const gridColor = isDark ? '#444' : '#ddd'; 324 | 325 | // Tool calls pie chart 326 | this.countChart = new Chart(countCtx, { 327 | type: 'pie', 328 | data: { 329 | labels: names, 330 | datasets: [{ 331 | data: counts, 332 | backgroundColor: colors 333 | }] 334 | }, 335 | options: { 336 | plugins: { 337 | legend: { 338 | display: true, 339 | labels: { 340 | color: textColor 341 | } 342 | }, 343 | datalabels: { 344 | display: true, 345 | color: 'white', 346 | font: { weight: 'bold' }, 347 | formatter: (value) => value 348 | } 349 | } 350 | } 351 | }); 352 | 353 | // Input tokens pie chart 354 | this.inputChart = new Chart(inputCtx, { 355 | type: 'pie', 356 | data: { 357 | labels: names, 358 | datasets: [{ 359 | data: inputTokens, 360 | backgroundColor: colors 361 | }] 362 | }, 363 | options: { 364 | plugins: { 365 | legend: { 366 | display: true, 367 | labels: { 368 | color: textColor 369 | } 370 | }, 371 | datalabels: { 372 | display: true, 373 | color: 'white', 374 | font: { weight: 'bold' }, 375 | formatter: (value) => value 376 | } 377 | } 378 | } 379 | }); 380 | 381 | // Output tokens pie chart 382 | this.outputChart = new Chart(outputCtx, { 383 | type: 'pie', 384 | data: { 385 | labels: names, 386 | datasets: [{ 387 | data: outputTokens, 388 | backgroundColor: colors 389 | }] 390 | }, 391 | options: { 392 | plugins: { 393 | legend: { 394 | display: true, 395 | labels: { 396 | color: textColor 397 | } 398 | }, 399 | datalabels: { 400 | display: true, 401 | color: 'white', 402 | font: { weight: 'bold' }, 403 | formatter: (value) => value 404 | } 405 | } 406 | } 407 | }); 408 | 409 | // Combined input/output tokens bar chart 410 | this.tokensChart = new Chart(tokensCtx, { 411 | type: 'bar', 412 | data: { 413 | labels: names, 414 | datasets: [ 415 | { 416 | label: 'Input Tokens', 417 | data: inputTokens, 418 | backgroundColor: colors.map(color => color + '80'), // Semi-transparent 419 | borderColor: colors, 420 | borderWidth: 2, 421 | borderSkipped: false, 422 | yAxisID: 'y' 423 | }, 424 | { 425 | label: 'Output Tokens', 426 | data: outputTokens, 427 | backgroundColor: colors, 428 | yAxisID: 'y1' 429 | } 430 | ] 431 | }, 432 | options: { 433 | responsive: true, 434 | plugins: { 435 | legend: { 436 | labels: { 437 | color: textColor 438 | } 439 | } 440 | }, 441 | scales: { 442 | x: { 443 | ticks: { 444 | color: textColor 445 | }, 446 | grid: { 447 | color: gridColor 448 | } 449 | }, 450 | y: { 451 | type: 'linear', 452 | display: true, 453 | position: 'left', 454 | beginAtZero: true, 455 | title: { 456 | display: true, 457 | text: 'Input Tokens', 458 | color: textColor 459 | }, 460 | ticks: { 461 | color: textColor 462 | }, 463 | grid: { 464 | color: gridColor 465 | } 466 | }, 467 | y1: { 468 | type: 'linear', 469 | display: true, 470 | position: 'right', 471 | beginAtZero: true, 472 | title: { 473 | display: true, 474 | text: 'Output Tokens', 475 | color: textColor 476 | }, 477 | ticks: { 478 | color: textColor 479 | }, 480 | grid: { 481 | drawOnChartArea: false, 482 | color: gridColor 483 | } 484 | } 485 | } 486 | } 487 | }); 488 | } 489 | 490 | generateColors(count) { 491 | const colors = [ 492 | '#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF', 493 | '#FF9F40', '#FF6384', '#C9CBCF', '#4BC0C0', '#FF6384' 494 | ]; 495 | return Array.from({length: count}, (_, i) => colors[i % colors.length]); 496 | } 497 | 498 | updateSummaryTable(totalCalls, totalInputTokens, totalOutputTokens) { 499 | const tableHtml = ` 500 | <table class="stats-summary"> 501 | <tr><th>Metric</th><th>Total</th></tr> 502 | <tr><td>Tool Calls</td><td>${totalCalls}</td></tr> 503 | <tr><td>Input Tokens</td><td>${totalInputTokens}</td></tr> 504 | <tr><td>Output Tokens</td><td>${totalOutputTokens}</td></tr> 505 | <tr><td>Total Tokens</td><td>${totalInputTokens + totalOutputTokens}</td></tr> 506 | </table> 507 | `; 508 | $('#stats-summary').html(tableHtml); 509 | } 510 | 511 | initializeTheme() { 512 | // Check if user has manually set a theme preference 513 | const savedTheme = localStorage.getItem('serena-theme'); 514 | 515 | if (savedTheme) { 516 | // User has manually set a preference, use it 517 | this.setTheme(savedTheme); 518 | } else { 519 | // No manual preference, detect system color scheme 520 | this.detectSystemTheme(); 521 | } 522 | 523 | // Listen for system theme changes 524 | this.setupSystemThemeListener(); 525 | } 526 | 527 | detectSystemTheme() { 528 | // Check if system prefers dark mode 529 | const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; 530 | const theme = prefersDark ? 'dark' : 'light'; 531 | this.setTheme(theme); 532 | } 533 | 534 | setupSystemThemeListener() { 535 | // Listen for changes in system color scheme 536 | const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); 537 | 538 | const handleSystemThemeChange = (e) => { 539 | // Only auto-switch if user hasn't manually set a preference 540 | const savedTheme = localStorage.getItem('serena-theme'); 541 | if (!savedTheme) { 542 | const newTheme = e.matches ? 'dark' : 'light'; 543 | this.setTheme(newTheme); 544 | } 545 | }; 546 | 547 | // Add listener for system theme changes 548 | if (mediaQuery.addEventListener) { 549 | mediaQuery.addEventListener('change', handleSystemThemeChange); 550 | } else { 551 | // Fallback for older browsers 552 | mediaQuery.addListener(handleSystemThemeChange); 553 | } 554 | } 555 | 556 | toggleTheme() { 557 | const currentTheme = document.documentElement.getAttribute('data-theme') || 'light'; 558 | const newTheme = currentTheme === 'light' ? 'dark' : 'light'; 559 | 560 | // When user manually toggles, save their preference 561 | localStorage.setItem('serena-theme', newTheme); 562 | this.setTheme(newTheme); 563 | } 564 | 565 | setTheme(theme) { 566 | // Set the theme on the document element 567 | document.documentElement.setAttribute('data-theme', theme); 568 | 569 | // Update the toggle button 570 | if (theme === 'dark') { 571 | this.$themeIcon.text('☀️'); 572 | this.$themeText.text('Light'); 573 | } else { 574 | this.$themeIcon.text('🌙'); 575 | this.$themeText.text('Dark'); 576 | } 577 | 578 | // Update the logo based on theme 579 | this.updateLogo(theme); 580 | 581 | // Save to localStorage 582 | localStorage.setItem('serena-theme', theme); 583 | 584 | // Update charts if they exist 585 | this.updateChartsTheme(); 586 | } 587 | 588 | updateLogo(theme) { 589 | const logoElement = document.getElementById('serena-logo'); 590 | if (logoElement) { 591 | if (theme === 'dark') { 592 | logoElement.src = 'serena-logs-dark-mode.png'; 593 | } else { 594 | logoElement.src = 'serena-logs.png'; 595 | } 596 | } 597 | } 598 | 599 | updateChartsTheme() { 600 | const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; 601 | const textColor = isDark ? '#ffffff' : '#000000'; 602 | const gridColor = isDark ? '#444' : '#ddd'; 603 | 604 | // Update existing charts 605 | if (this.countChart) { 606 | this.countChart.options.scales.x.ticks.color = textColor; 607 | this.countChart.options.scales.y.ticks.color = textColor; 608 | this.countChart.options.scales.x.grid.color = gridColor; 609 | this.countChart.options.scales.y.grid.color = gridColor; 610 | this.countChart.update(); 611 | } 612 | 613 | if (this.inputChart) { 614 | this.inputChart.options.scales.x.ticks.color = textColor; 615 | this.inputChart.options.scales.y.ticks.color = textColor; 616 | this.inputChart.options.scales.x.grid.color = gridColor; 617 | this.inputChart.options.scales.y.grid.color = gridColor; 618 | this.inputChart.update(); 619 | } 620 | 621 | if (this.outputChart) { 622 | this.outputChart.options.scales.x.ticks.color = textColor; 623 | this.outputChart.options.scales.y.ticks.color = textColor; 624 | this.outputChart.options.scales.x.grid.color = gridColor; 625 | this.outputChart.options.scales.y.grid.color = gridColor; 626 | this.outputChart.update(); 627 | } 628 | 629 | if (this.tokensChart) { 630 | this.tokensChart.options.scales.x.ticks.color = textColor; 631 | this.tokensChart.options.scales.y.ticks.color = textColor; 632 | this.tokensChart.options.scales.y1.ticks.color = textColor; 633 | this.tokensChart.options.scales.x.grid.color = gridColor; 634 | this.tokensChart.options.scales.y.grid.color = gridColor; 635 | this.tokensChart.options.scales.y1.grid.color = gridColor; 636 | this.tokensChart.update(); 637 | } 638 | } 639 | 640 | shutdown() { 641 | const self = this; 642 | const _shutdown = function () { 643 | console.log("Triggering shutdown"); 644 | $.ajax({ 645 | url: '/shutdown', 646 | type: "PUT", 647 | contentType: 'application/json', 648 | }); 649 | self.$errorContainer.html('<div class="error-message">Shutting down ...</div>') 650 | setTimeout(function() { 651 | window.close(); 652 | }, 2000); 653 | } 654 | 655 | // ask for confirmation using a dialog 656 | if (confirm("This will fully terminate the Serena server.")) { 657 | _shutdown(); 658 | } else { 659 | console.log("Shutdown cancelled"); 660 | } 661 | } 662 | } 663 | ``` -------------------------------------------------------------------------------- /src/solidlsp/ls_handler.py: -------------------------------------------------------------------------------- ```python 1 | import asyncio 2 | import json 3 | import logging 4 | import os 5 | import platform 6 | import subprocess 7 | import threading 8 | import time 9 | from collections.abc import Callable 10 | from dataclasses import dataclass 11 | from queue import Empty, Queue 12 | from typing import Any 13 | 14 | import psutil 15 | from sensai.util.string import ToStringMixin 16 | 17 | from solidlsp.ls_exceptions import SolidLSPException 18 | from solidlsp.ls_request import LanguageServerRequest 19 | from solidlsp.lsp_protocol_handler.lsp_requests import LspNotification 20 | from solidlsp.lsp_protocol_handler.lsp_types import ErrorCodes 21 | from solidlsp.lsp_protocol_handler.server import ( 22 | ENCODING, 23 | LSPError, 24 | MessageType, 25 | PayloadLike, 26 | ProcessLaunchInfo, 27 | StringDict, 28 | content_length, 29 | create_message, 30 | make_error_response, 31 | make_notification, 32 | make_request, 33 | make_response, 34 | ) 35 | from solidlsp.util.subprocess_util import subprocess_kwargs 36 | 37 | log = logging.getLogger(__name__) 38 | 39 | 40 | class LanguageServerTerminatedException(Exception): 41 | """ 42 | Exception raised when the language server process has terminated unexpectedly. 43 | """ 44 | 45 | def __init__(self, message: str, cause: Exception | None = None) -> None: 46 | super().__init__(message) 47 | self.message = message 48 | self.cause = cause 49 | 50 | def __str__(self) -> str: 51 | return f"LanguageServerTerminatedException: {self.message}" + (f"; Cause: {self.cause}" if self.cause else "") 52 | 53 | 54 | class Request(ToStringMixin): 55 | 56 | @dataclass 57 | class Result: 58 | payload: PayloadLike | None = None 59 | error: Exception | None = None 60 | 61 | def is_error(self) -> bool: 62 | return self.error is not None 63 | 64 | def __init__(self, request_id: int, method: str) -> None: 65 | self._request_id = request_id 66 | self._method = method 67 | self._status = "pending" 68 | self._result_queue = Queue() 69 | 70 | def _tostring_includes(self) -> list[str]: 71 | return ["_request_id", "_status", "_method"] 72 | 73 | def on_result(self, params: PayloadLike) -> None: 74 | self._status = "completed" 75 | self._result_queue.put(Request.Result(payload=params)) 76 | 77 | def on_error(self, err: Exception) -> None: 78 | """ 79 | :param err: the error that occurred while processing the request (typically an LSPError 80 | for errors returned by the LS or LanguageServerTerminatedException if the error 81 | is due to the language server process terminating unexpectedly). 82 | """ 83 | self._status = "error" 84 | self._result_queue.put(Request.Result(error=err)) 85 | 86 | def get_result(self, timeout: float | None = None) -> Result: 87 | try: 88 | return self._result_queue.get(timeout=timeout) 89 | except Empty as e: 90 | if timeout is not None: 91 | raise TimeoutError(f"Request timed out ({timeout=})") from e 92 | raise e 93 | 94 | 95 | class SolidLanguageServerHandler: 96 | """ 97 | This class provides the implementation of Python client for the Language Server Protocol. 98 | A class that launches the language server and communicates with it 99 | using the Language Server Protocol (LSP). 100 | 101 | It provides methods for sending requests, responses, and notifications to the server 102 | and for registering handlers for requests and notifications from the server. 103 | 104 | Uses JSON-RPC 2.0 for communication with the server over stdin/stdout. 105 | 106 | Attributes: 107 | send: A LspRequest object that can be used to send requests to the server and 108 | await for the responses. 109 | notify: A LspNotification object that can be used to send notifications to the server. 110 | cmd: A string that represents the command to launch the language server process. 111 | process: A subprocess.Popen object that represents the language server process. 112 | request_id: An integer that represents the next available request id for the client. 113 | _pending_requests: A dictionary that maps request ids to Request objects that 114 | store the results or errors of the requests. 115 | on_request_handlers: A dictionary that maps method names to callback functions 116 | that handle requests from the server. 117 | on_notification_handlers: A dictionary that maps method names to callback functions 118 | that handle notifications from the server. 119 | logger: An optional function that takes two strings (source and destination) and 120 | a payload dictionary, and logs the communication between the client and the server. 121 | tasks: A dictionary that maps task ids to asyncio.Task objects that represent 122 | the asynchronous tasks created by the handler. 123 | task_counter: An integer that represents the next available task id for the handler. 124 | loop: An asyncio.AbstractEventLoop object that represents the event loop used by the handler. 125 | start_independent_lsp_process: An optional boolean flag that indicates whether to start the 126 | language server process in an independent process group. Default is `True`. Setting it to 127 | `False` means that the language server process will be in the same process group as the 128 | the current process, and any SIGINT and SIGTERM signals will be sent to both processes. 129 | 130 | """ 131 | 132 | def __init__( 133 | self, 134 | process_launch_info: ProcessLaunchInfo, 135 | logger: Callable[[str, str, StringDict | str], None] | None = None, 136 | start_independent_lsp_process=True, 137 | request_timeout: float | None = None, 138 | ) -> None: 139 | self.send = LanguageServerRequest(self) 140 | self.notify = LspNotification(self.send_notification) 141 | 142 | self.process_launch_info = process_launch_info 143 | self.process: subprocess.Popen | None = None 144 | self._is_shutting_down = False 145 | 146 | self.request_id = 1 147 | self._pending_requests: dict[Any, Request] = {} 148 | self.on_request_handlers = {} 149 | self.on_notification_handlers = {} 150 | self.logger = logger 151 | self.tasks = {} 152 | self.task_counter = 0 153 | self.loop = None 154 | self.start_independent_lsp_process = start_independent_lsp_process 155 | self._request_timeout = request_timeout 156 | 157 | # Add thread locks for shared resources to prevent race conditions 158 | self._stdin_lock = threading.Lock() 159 | self._request_id_lock = threading.Lock() 160 | self._response_handlers_lock = threading.Lock() 161 | self._tasks_lock = threading.Lock() 162 | 163 | def set_request_timeout(self, timeout: float | None) -> None: 164 | """ 165 | :param timeout: the timeout, in seconds, for all requests sent to the language server. 166 | """ 167 | self._request_timeout = timeout 168 | 169 | def is_running(self) -> bool: 170 | """ 171 | Checks if the language server process is currently running. 172 | """ 173 | return self.process is not None and self.process.returncode is None 174 | 175 | def start(self) -> None: 176 | """ 177 | Starts the language server process and creates a task to continuously read from its stdout to handle communications 178 | from the server to the client 179 | """ 180 | child_proc_env = os.environ.copy() 181 | child_proc_env.update(self.process_launch_info.env) 182 | 183 | cmd = self.process_launch_info.cmd 184 | is_windows = platform.system() == "Windows" 185 | if not isinstance(cmd, str) and not is_windows: 186 | # Since we are using the shell, we need to convert the command list to a single string 187 | # on Linux/macOS 188 | cmd = " ".join(cmd) 189 | log.info("Starting language server process via command: %s", self.process_launch_info.cmd) 190 | kwargs = subprocess_kwargs() 191 | kwargs["start_new_session"] = self.start_independent_lsp_process 192 | self.process = subprocess.Popen( 193 | cmd, 194 | stdout=subprocess.PIPE, 195 | stdin=subprocess.PIPE, 196 | stderr=subprocess.PIPE, 197 | env=child_proc_env, 198 | cwd=self.process_launch_info.cwd, 199 | shell=True, 200 | **kwargs, 201 | ) 202 | 203 | # Check if process terminated immediately 204 | if self.process.returncode is not None: 205 | log.error("Language server has already terminated/could not be started") 206 | # Process has already terminated 207 | stderr_data = self.process.stderr.read() 208 | error_message = stderr_data.decode("utf-8", errors="replace") 209 | raise RuntimeError(f"Process terminated immediately with code {self.process.returncode}. Error: {error_message}") 210 | 211 | # start threads to read stdout and stderr of the process 212 | threading.Thread( 213 | target=self._read_ls_process_stdout, 214 | name="LSP-stdout-reader", 215 | daemon=True, 216 | ).start() 217 | threading.Thread( 218 | target=self._read_ls_process_stderr, 219 | name="LSP-stderr-reader", 220 | daemon=True, 221 | ).start() 222 | 223 | def stop(self) -> None: 224 | """ 225 | Sends the terminate signal to the language server process and waits for it to exit, with a timeout, killing it if necessary 226 | """ 227 | process = self.process 228 | self.process = None 229 | if process: 230 | self._cleanup_process(process) 231 | 232 | def _cleanup_process(self, process): 233 | """Clean up a process: close stdin, terminate/kill process, close stdout/stderr.""" 234 | # Close stdin first to prevent deadlocks 235 | # See: https://bugs.python.org/issue35539 236 | self._safely_close_pipe(process.stdin) 237 | 238 | # Terminate/kill the process if it's still running 239 | if process.returncode is None: 240 | self._terminate_or_kill_process(process) 241 | 242 | # Close stdout and stderr pipes after process has exited 243 | # This is essential to prevent "I/O operation on closed pipe" errors and 244 | # "Event loop is closed" errors during garbage collection 245 | # See: https://bugs.python.org/issue41320 and https://github.com/python/cpython/issues/88050 246 | self._safely_close_pipe(process.stdout) 247 | self._safely_close_pipe(process.stderr) 248 | 249 | def _safely_close_pipe(self, pipe): 250 | """Safely close a pipe, ignoring any exceptions.""" 251 | if pipe: 252 | try: 253 | pipe.close() 254 | except Exception: 255 | pass 256 | 257 | def _terminate_or_kill_process(self, process): 258 | """Try to terminate the process gracefully, then forcefully if necessary.""" 259 | # First try to terminate the process tree gracefully 260 | self._signal_process_tree(process, terminate=True) 261 | 262 | def _signal_process_tree(self, process, terminate=True): 263 | """Send signal (terminate or kill) to the process and all its children.""" 264 | signal_method = "terminate" if terminate else "kill" 265 | 266 | # Try to get the parent process 267 | parent = None 268 | try: 269 | parent = psutil.Process(process.pid) 270 | except (psutil.NoSuchProcess, psutil.AccessDenied, Exception): 271 | pass 272 | 273 | # If we have the parent process and it's running, signal the entire tree 274 | if parent and parent.is_running(): 275 | # Signal children first 276 | for child in parent.children(recursive=True): 277 | try: 278 | getattr(child, signal_method)() 279 | except (psutil.NoSuchProcess, psutil.AccessDenied, Exception): 280 | pass 281 | 282 | # Then signal the parent 283 | try: 284 | getattr(parent, signal_method)() 285 | except (psutil.NoSuchProcess, psutil.AccessDenied, Exception): 286 | pass 287 | else: 288 | # Fall back to direct process signaling 289 | try: 290 | getattr(process, signal_method)() 291 | except Exception: 292 | pass 293 | 294 | def shutdown(self) -> None: 295 | """ 296 | Perform the shutdown sequence for the client, including sending the shutdown request to the server and notifying it of exit 297 | """ 298 | self._is_shutting_down = True 299 | self._log("Sending shutdown request to server") 300 | self.send.shutdown() 301 | self._log("Received shutdown response from server") 302 | self._log("Sending exit notification to server") 303 | self.notify.exit() 304 | self._log("Sent exit notification to server") 305 | 306 | def _log(self, message: str | StringDict) -> None: 307 | """ 308 | Create a log message 309 | """ 310 | if self.logger is not None: 311 | self.logger("client", "logger", message) 312 | 313 | @staticmethod 314 | def _read_bytes_from_process(process, stream, num_bytes): 315 | """Read exactly num_bytes from process stdout""" 316 | data = b"" 317 | while len(data) < num_bytes: 318 | chunk = stream.read(num_bytes - len(data)) 319 | if not chunk: 320 | if process.poll() is not None: 321 | raise LanguageServerTerminatedException( 322 | f"Process terminated while trying to read response (read {num_bytes} of {len(data)} bytes before termination)" 323 | ) 324 | # Process still running but no data available yet, retry after a short delay 325 | time.sleep(0.01) 326 | continue 327 | data += chunk 328 | return data 329 | 330 | def _read_ls_process_stdout(self) -> None: 331 | """ 332 | Continuously read from the language server process stdout and handle the messages 333 | invoking the registered response and notification handlers 334 | """ 335 | exception: Exception | None = None 336 | try: 337 | while self.process and self.process.stdout: 338 | if self.process.poll() is not None: # process has terminated 339 | break 340 | line = self.process.stdout.readline() 341 | if not line: 342 | continue 343 | try: 344 | num_bytes = content_length(line) 345 | except ValueError: 346 | continue 347 | if num_bytes is None: 348 | continue 349 | while line and line.strip(): 350 | line = self.process.stdout.readline() 351 | if not line: 352 | continue 353 | body = self._read_bytes_from_process(self.process, self.process.stdout, num_bytes) 354 | 355 | self._handle_body(body) 356 | except LanguageServerTerminatedException as e: 357 | exception = e 358 | except (BrokenPipeError, ConnectionResetError) as e: 359 | exception = LanguageServerTerminatedException("Language server process terminated while reading stdout", cause=e) 360 | except Exception as e: 361 | exception = LanguageServerTerminatedException("Unexpected error while reading stdout from language server process", cause=e) 362 | log.info("Language server stdout reader thread has terminated") 363 | if not self._is_shutting_down: 364 | if exception is None: 365 | exception = LanguageServerTerminatedException("Language server stdout read process terminated unexpectedly") 366 | log.error(str(exception)) 367 | self._cancel_pending_requests(exception) 368 | 369 | def _read_ls_process_stderr(self) -> None: 370 | """ 371 | Continuously read from the language server process stderr and log the messages 372 | """ 373 | try: 374 | while self.process and self.process.stderr: 375 | if self.process.poll() is not None: 376 | # process has terminated 377 | break 378 | line = self.process.stderr.readline() 379 | if not line: 380 | continue 381 | line = line.decode(ENCODING, errors="replace") 382 | line_lower = line.lower() 383 | if "error" in line_lower or "exception" in line_lower or line.startswith("E["): 384 | level = logging.ERROR 385 | else: 386 | level = logging.INFO 387 | log.log(level, line) 388 | except Exception as e: 389 | log.error("Error while reading stderr from language server process: %s", e, exc_info=e) 390 | if not self._is_shutting_down: 391 | log.error("Language server stderr reader thread terminated unexpectedly") 392 | else: 393 | log.info("Language server stderr reader thread has terminated") 394 | 395 | def _handle_body(self, body: bytes) -> None: 396 | """ 397 | Parse the body text received from the language server process and invoke the appropriate handler 398 | """ 399 | try: 400 | self._receive_payload(json.loads(body)) 401 | except OSError as ex: 402 | self._log(f"malformed {ENCODING}: {ex}") 403 | except UnicodeDecodeError as ex: 404 | self._log(f"malformed {ENCODING}: {ex}") 405 | except json.JSONDecodeError as ex: 406 | self._log(f"malformed JSON: {ex}") 407 | 408 | def _receive_payload(self, payload: StringDict) -> None: 409 | """ 410 | Determine if the payload received from server is for a request, response, or notification and invoke the appropriate handler 411 | """ 412 | if self.logger: 413 | self.logger("server", "client", payload) 414 | try: 415 | if "method" in payload: 416 | if "id" in payload: 417 | self._request_handler(payload) 418 | else: 419 | self._notification_handler(payload) 420 | elif "id" in payload: 421 | self._response_handler(payload) 422 | else: 423 | self._log(f"Unknown payload type: {payload}") 424 | except Exception as err: 425 | self._log(f"Error handling server payload: {err}") 426 | 427 | def send_notification(self, method: str, params: dict | None = None) -> None: 428 | """ 429 | Send notification pertaining to the given method to the server with the given parameters 430 | """ 431 | self._send_payload(make_notification(method, params)) 432 | 433 | def send_response(self, request_id: Any, params: PayloadLike) -> None: 434 | """ 435 | Send response to the given request id to the server with the given parameters 436 | """ 437 | self._send_payload(make_response(request_id, params)) 438 | 439 | def send_error_response(self, request_id: Any, err: LSPError) -> None: 440 | """ 441 | Send error response to the given request id to the server with the given error 442 | """ 443 | # Use lock to prevent race conditions on tasks and task_counter 444 | self._send_payload(make_error_response(request_id, err)) 445 | 446 | def _cancel_pending_requests(self, exception: Exception) -> None: 447 | """ 448 | Cancel all pending requests by setting their results to an error 449 | """ 450 | with self._response_handlers_lock: 451 | log.info("Cancelling %d pending language server requests", len(self._pending_requests)) 452 | for request in self._pending_requests.values(): 453 | log.info("Cancelling %s", request) 454 | request.on_error(exception) 455 | self._pending_requests.clear() 456 | 457 | def send_request(self, method: str, params: dict | None = None) -> PayloadLike: 458 | """ 459 | Send request to the server, register the request id, and wait for the response 460 | """ 461 | with self._request_id_lock: 462 | request_id = self.request_id 463 | self.request_id += 1 464 | 465 | request = Request(request_id=request_id, method=method) 466 | log.debug("Starting: %s", request) 467 | 468 | with self._response_handlers_lock: 469 | self._pending_requests[request_id] = request 470 | 471 | self._send_payload(make_request(method, request_id, params)) 472 | 473 | self._log(f"Waiting for response to request {method} with params:\n{params}") 474 | result = request.get_result(timeout=self._request_timeout) 475 | log.debug("Completed: %s", request) 476 | 477 | self._log("Processing result") 478 | if result.is_error(): 479 | raise SolidLSPException(f"Error processing request {method} with params:\n{params}", cause=result.error) from result.error 480 | 481 | self._log(f"Returning non-error result, which is:\n{result.payload}") 482 | return result.payload 483 | 484 | def _send_payload(self, payload: StringDict) -> None: 485 | """ 486 | Send the payload to the server by writing to its stdin asynchronously. 487 | """ 488 | if not self.process or not self.process.stdin: 489 | return 490 | self._log(payload) 491 | msg = create_message(payload) 492 | 493 | # Use lock to prevent concurrent writes to stdin that cause buffer corruption 494 | with self._stdin_lock: 495 | try: 496 | self.process.stdin.writelines(msg) 497 | self.process.stdin.flush() 498 | except (BrokenPipeError, ConnectionResetError, OSError) as e: 499 | # Log the error but don't raise to prevent cascading failures 500 | if self.logger: 501 | self.logger("client", "logger", f"Failed to write to stdin: {e}") 502 | return 503 | 504 | def on_request(self, method: str, cb) -> None: 505 | """ 506 | Register the callback function to handle requests from the server to the client for the given method 507 | """ 508 | self.on_request_handlers[method] = cb 509 | 510 | def on_notification(self, method: str, cb) -> None: 511 | """ 512 | Register the callback function to handle notifications from the server to the client for the given method 513 | """ 514 | self.on_notification_handlers[method] = cb 515 | 516 | def _response_handler(self, response: StringDict) -> None: 517 | """ 518 | Handle the response received from the server for a request, using the id to determine the request 519 | """ 520 | response_id = response["id"] 521 | with self._response_handlers_lock: 522 | request = self._pending_requests.pop(response_id, None) 523 | if request is None and isinstance(response_id, str) and response_id.isdigit(): 524 | request = self._pending_requests.pop(int(response_id), None) 525 | 526 | if request is None: # need to convert response_id to the right type 527 | log.debug("Request interrupted by user or not found for ID %s", response_id) 528 | return 529 | 530 | if "result" in response and "error" not in response: 531 | request.on_result(response["result"]) 532 | elif "result" not in response and "error" in response: 533 | request.on_error(LSPError.from_lsp(response["error"])) 534 | else: 535 | request.on_error(LSPError(ErrorCodes.InvalidRequest, "")) 536 | 537 | def _request_handler(self, response: StringDict) -> None: 538 | """ 539 | Handle the request received from the server: call the appropriate callback function and return the result 540 | """ 541 | method = response.get("method", "") 542 | params = response.get("params") 543 | request_id = response.get("id") 544 | handler = self.on_request_handlers.get(method) 545 | if not handler: 546 | self.send_error_response( 547 | request_id, 548 | LSPError( 549 | ErrorCodes.MethodNotFound, 550 | f"method '{method}' not handled on client.", 551 | ), 552 | ) 553 | return 554 | try: 555 | self.send_response(request_id, handler(params)) 556 | except LSPError as ex: 557 | self.send_error_response(request_id, ex) 558 | except Exception as ex: 559 | self.send_error_response(request_id, LSPError(ErrorCodes.InternalError, str(ex))) 560 | 561 | def _notification_handler(self, response: StringDict) -> None: 562 | """ 563 | Handle the notification received from the server: call the appropriate callback function 564 | """ 565 | method = response.get("method", "") 566 | params = response.get("params") 567 | handler = self.on_notification_handlers.get(method) 568 | if not handler: 569 | self._log(f"unhandled {method}") 570 | return 571 | try: 572 | handler(params) 573 | except asyncio.CancelledError: 574 | return 575 | except Exception as ex: 576 | if (not self._is_shutting_down) and self.logger: 577 | self.logger( 578 | "client", 579 | "logger", 580 | str( 581 | { 582 | "type": MessageType.error, 583 | "message": str(ex), 584 | "method": method, 585 | "params": params, 586 | } 587 | ), 588 | ) 589 | ``` -------------------------------------------------------------------------------- /test/serena/util/test_file_system.py: -------------------------------------------------------------------------------- ```python 1 | import os 2 | import shutil 3 | import tempfile 4 | from pathlib import Path 5 | 6 | # Assuming the gitignore parser code is in a module named 'gitignore_parser' 7 | from serena.util.file_system import GitignoreParser, GitignoreSpec 8 | 9 | 10 | class TestGitignoreParser: 11 | """Test class for GitignoreParser functionality.""" 12 | 13 | def setup_method(self): 14 | """Set up test environment before each test method.""" 15 | # Create a temporary directory for testing 16 | self.test_dir = tempfile.mkdtemp() 17 | self.repo_path = Path(self.test_dir) 18 | 19 | # Create test repository structure 20 | self._create_repo_structure() 21 | 22 | def teardown_method(self): 23 | """Clean up test environment after each test method.""" 24 | # Remove the temporary directory 25 | shutil.rmtree(self.test_dir) 26 | 27 | def _create_repo_structure(self): 28 | """ 29 | Create a test repository structure with multiple gitignore files. 30 | 31 | Structure: 32 | repo/ 33 | ├── .gitignore 34 | ├── file1.txt 35 | ├── test.log 36 | ├── src/ 37 | │ ├── .gitignore 38 | │ ├── main.py 39 | │ ├── test.log 40 | │ ├── build/ 41 | │ │ └── output.o 42 | │ └── lib/ 43 | │ ├── .gitignore 44 | │ └── cache.tmp 45 | └── docs/ 46 | ├── .gitignore 47 | ├── api.md 48 | └── temp/ 49 | └── draft.md 50 | """ 51 | # Create directories 52 | (self.repo_path / "src").mkdir() 53 | (self.repo_path / "src" / "build").mkdir() 54 | (self.repo_path / "src" / "lib").mkdir() 55 | (self.repo_path / "docs").mkdir() 56 | (self.repo_path / "docs" / "temp").mkdir() 57 | 58 | # Create files 59 | (self.repo_path / "file1.txt").touch() 60 | (self.repo_path / "test.log").touch() 61 | (self.repo_path / "src" / "main.py").touch() 62 | (self.repo_path / "src" / "test.log").touch() 63 | (self.repo_path / "src" / "build" / "output.o").touch() 64 | (self.repo_path / "src" / "lib" / "cache.tmp").touch() 65 | (self.repo_path / "docs" / "api.md").touch() 66 | (self.repo_path / "docs" / "temp" / "draft.md").touch() 67 | 68 | # Create root .gitignore 69 | root_gitignore = self.repo_path / ".gitignore" 70 | root_gitignore.write_text( 71 | """# Root gitignore 72 | *.log 73 | /build/ 74 | """ 75 | ) 76 | 77 | # Create src/.gitignore 78 | src_gitignore = self.repo_path / "src" / ".gitignore" 79 | src_gitignore.write_text( 80 | """# Source gitignore 81 | *.o 82 | build/ 83 | !important.log 84 | """ 85 | ) 86 | 87 | # Create src/lib/.gitignore (deeply nested) 88 | src_lib_gitignore = self.repo_path / "src" / "lib" / ".gitignore" 89 | src_lib_gitignore.write_text( 90 | """# Library gitignore 91 | *.tmp 92 | *.cache 93 | """ 94 | ) 95 | 96 | # Create docs/.gitignore 97 | docs_gitignore = self.repo_path / "docs" / ".gitignore" 98 | docs_gitignore.write_text( 99 | """# Docs gitignore 100 | temp/ 101 | *.tmp 102 | """ 103 | ) 104 | 105 | def test_initialization(self): 106 | """Test GitignoreParser initialization.""" 107 | parser = GitignoreParser(str(self.repo_path)) 108 | 109 | assert parser.repo_root == str(self.repo_path.absolute()) 110 | assert len(parser.get_ignore_specs()) == 4 111 | 112 | def test_find_gitignore_files(self): 113 | """Test finding all gitignore files in repository, including deeply nested ones.""" 114 | parser = GitignoreParser(str(self.repo_path)) 115 | 116 | # Get file paths from specs 117 | gitignore_files = [spec.file_path for spec in parser.get_ignore_specs()] 118 | 119 | # Convert to relative paths for easier testing 120 | rel_paths = [os.path.relpath(f, self.repo_path) for f in gitignore_files] 121 | rel_paths.sort() 122 | 123 | assert len(rel_paths) == 4 124 | assert ".gitignore" in rel_paths 125 | assert os.path.join("src", ".gitignore") in rel_paths 126 | assert os.path.join("src", "lib", ".gitignore") in rel_paths # Deeply nested 127 | assert os.path.join("docs", ".gitignore") in rel_paths 128 | 129 | def test_parse_patterns_root_directory(self): 130 | """Test parsing gitignore patterns in root directory.""" 131 | # Create a simple test case with only root gitignore 132 | test_dir = self.repo_path / "test_root" 133 | test_dir.mkdir() 134 | 135 | gitignore = test_dir / ".gitignore" 136 | gitignore.write_text( 137 | """*.log 138 | build/ 139 | /temp.txt 140 | """ 141 | ) 142 | 143 | parser = GitignoreParser(str(test_dir)) 144 | specs = parser.get_ignore_specs() 145 | 146 | assert len(specs) == 1 147 | patterns = specs[0].patterns 148 | 149 | assert "*.log" in patterns 150 | assert "build/" in patterns 151 | assert "/temp.txt" in patterns 152 | 153 | def test_parse_patterns_subdirectory(self): 154 | """Test parsing gitignore patterns in subdirectory.""" 155 | # Create a test case with subdirectory gitignore 156 | test_dir = self.repo_path / "test_sub" 157 | test_dir.mkdir() 158 | subdir = test_dir / "src" 159 | subdir.mkdir() 160 | 161 | gitignore = subdir / ".gitignore" 162 | gitignore.write_text( 163 | """*.o 164 | /build/ 165 | test.log 166 | """ 167 | ) 168 | 169 | parser = GitignoreParser(str(test_dir)) 170 | specs = parser.get_ignore_specs() 171 | 172 | assert len(specs) == 1 173 | patterns = specs[0].patterns 174 | 175 | # Non-anchored pattern should get ** prefix 176 | assert "src/**/*.o" in patterns 177 | # Anchored pattern should not get ** prefix 178 | assert "src/build/" in patterns 179 | # Non-anchored pattern without slash 180 | assert "src/**/test.log" in patterns 181 | 182 | def test_should_ignore_root_patterns(self): 183 | """Test ignoring files based on root .gitignore.""" 184 | parser = GitignoreParser(str(self.repo_path)) 185 | 186 | # Files that should be ignored 187 | assert parser.should_ignore("test.log") 188 | assert parser.should_ignore(str(self.repo_path / "test.log")) 189 | 190 | # Files that should NOT be ignored 191 | assert not parser.should_ignore("file1.txt") 192 | assert not parser.should_ignore("src/main.py") 193 | 194 | def test_should_ignore_subdirectory_patterns(self): 195 | """Test ignoring files based on subdirectory .gitignore files.""" 196 | parser = GitignoreParser(str(self.repo_path)) 197 | 198 | # .o files in src should be ignored 199 | assert parser.should_ignore("src/build/output.o") 200 | 201 | # build/ directory in src should be ignored 202 | assert parser.should_ignore("src/build/") 203 | 204 | # temp/ directory in docs should be ignored 205 | assert parser.should_ignore("docs/temp/draft.md") 206 | 207 | # But temp/ outside docs should not be ignored by docs/.gitignore 208 | assert not parser.should_ignore("temp/file.txt") 209 | 210 | # Test deeply nested .gitignore in src/lib/ 211 | # .tmp files in src/lib should be ignored 212 | assert parser.should_ignore("src/lib/cache.tmp") 213 | 214 | # .cache files in src/lib should also be ignored 215 | assert parser.should_ignore("src/lib/data.cache") 216 | 217 | # But .tmp files outside src/lib should not be ignored by src/lib/.gitignore 218 | assert not parser.should_ignore("src/other.tmp") 219 | 220 | def test_anchored_vs_non_anchored_patterns(self): 221 | """Test the difference between anchored and non-anchored patterns.""" 222 | # Create new test structure 223 | test_dir = self.repo_path / "test_anchored" 224 | test_dir.mkdir() 225 | (test_dir / "src").mkdir() 226 | (test_dir / "src" / "subdir").mkdir() 227 | (test_dir / "src" / "subdir" / "deep").mkdir() 228 | 229 | # Create src/.gitignore with both anchored and non-anchored patterns 230 | gitignore = test_dir / "src" / ".gitignore" 231 | gitignore.write_text( 232 | """/temp.txt 233 | data.json 234 | """ 235 | ) 236 | 237 | # Create test files 238 | (test_dir / "src" / "temp.txt").touch() 239 | (test_dir / "src" / "data.json").touch() 240 | (test_dir / "src" / "subdir" / "temp.txt").touch() 241 | (test_dir / "src" / "subdir" / "data.json").touch() 242 | (test_dir / "src" / "subdir" / "deep" / "data.json").touch() 243 | 244 | parser = GitignoreParser(str(test_dir)) 245 | 246 | # Anchored pattern /temp.txt should only match in src/ 247 | assert parser.should_ignore("src/temp.txt") 248 | assert not parser.should_ignore("src/subdir/temp.txt") 249 | 250 | # Non-anchored pattern data.json should match anywhere under src/ 251 | assert parser.should_ignore("src/data.json") 252 | assert parser.should_ignore("src/subdir/data.json") 253 | assert parser.should_ignore("src/subdir/deep/data.json") 254 | 255 | def test_root_anchored_patterns(self): 256 | """Test anchored patterns in root .gitignore only match root-level files.""" 257 | # Create new test structure for root anchored patterns 258 | test_dir = self.repo_path / "test_root_anchored" 259 | test_dir.mkdir() 260 | (test_dir / "src").mkdir() 261 | (test_dir / "docs").mkdir() 262 | (test_dir / "src" / "nested").mkdir() 263 | 264 | # Create root .gitignore with anchored patterns 265 | gitignore = test_dir / ".gitignore" 266 | gitignore.write_text( 267 | """/config.json 268 | /temp.log 269 | /build 270 | *.pyc 271 | """ 272 | ) 273 | 274 | # Create test files at root level 275 | (test_dir / "config.json").touch() 276 | (test_dir / "temp.log").touch() 277 | (test_dir / "build").mkdir() 278 | (test_dir / "file.pyc").touch() 279 | 280 | # Create same-named files in subdirectories 281 | (test_dir / "src" / "config.json").touch() 282 | (test_dir / "src" / "temp.log").touch() 283 | (test_dir / "src" / "build").mkdir() 284 | (test_dir / "src" / "file.pyc").touch() 285 | (test_dir / "docs" / "config.json").touch() 286 | (test_dir / "docs" / "temp.log").touch() 287 | (test_dir / "src" / "nested" / "config.json").touch() 288 | (test_dir / "src" / "nested" / "temp.log").touch() 289 | (test_dir / "src" / "nested" / "build").mkdir() 290 | 291 | parser = GitignoreParser(str(test_dir)) 292 | 293 | # Anchored patterns should only match root-level files 294 | assert parser.should_ignore("config.json") 295 | assert not parser.should_ignore("src/config.json") 296 | assert not parser.should_ignore("docs/config.json") 297 | assert not parser.should_ignore("src/nested/config.json") 298 | 299 | assert parser.should_ignore("temp.log") 300 | assert not parser.should_ignore("src/temp.log") 301 | assert not parser.should_ignore("docs/temp.log") 302 | assert not parser.should_ignore("src/nested/temp.log") 303 | 304 | assert parser.should_ignore("build") 305 | assert not parser.should_ignore("src/build") 306 | assert not parser.should_ignore("src/nested/build") 307 | 308 | # Non-anchored patterns should match everywhere 309 | assert parser.should_ignore("file.pyc") 310 | assert parser.should_ignore("src/file.pyc") 311 | 312 | def test_mixed_anchored_and_non_anchored_root_patterns(self): 313 | """Test mix of anchored and non-anchored patterns in root .gitignore.""" 314 | test_dir = self.repo_path / "test_mixed_patterns" 315 | test_dir.mkdir() 316 | (test_dir / "app").mkdir() 317 | (test_dir / "tests").mkdir() 318 | (test_dir / "app" / "modules").mkdir() 319 | 320 | # Create root .gitignore with mixed patterns 321 | gitignore = test_dir / ".gitignore" 322 | gitignore.write_text( 323 | """/secrets.env 324 | /dist/ 325 | node_modules/ 326 | *.tmp 327 | /app/local.config 328 | debug.log 329 | """ 330 | ) 331 | 332 | # Create test files and directories 333 | (test_dir / "secrets.env").touch() 334 | (test_dir / "dist").mkdir() 335 | (test_dir / "node_modules").mkdir() 336 | (test_dir / "file.tmp").touch() 337 | (test_dir / "app" / "local.config").touch() 338 | (test_dir / "debug.log").touch() 339 | 340 | # Create same files in subdirectories 341 | (test_dir / "app" / "secrets.env").touch() 342 | (test_dir / "app" / "dist").mkdir() 343 | (test_dir / "app" / "node_modules").mkdir() 344 | (test_dir / "app" / "file.tmp").touch() 345 | (test_dir / "app" / "debug.log").touch() 346 | (test_dir / "tests" / "secrets.env").touch() 347 | (test_dir / "tests" / "node_modules").mkdir() 348 | (test_dir / "tests" / "debug.log").touch() 349 | (test_dir / "app" / "modules" / "local.config").touch() 350 | 351 | parser = GitignoreParser(str(test_dir)) 352 | 353 | # Anchored patterns should only match at root 354 | assert parser.should_ignore("secrets.env") 355 | assert not parser.should_ignore("app/secrets.env") 356 | assert not parser.should_ignore("tests/secrets.env") 357 | 358 | assert parser.should_ignore("dist") 359 | assert not parser.should_ignore("app/dist") 360 | 361 | assert parser.should_ignore("app/local.config") 362 | assert not parser.should_ignore("app/modules/local.config") 363 | 364 | # Non-anchored patterns should match everywhere 365 | assert parser.should_ignore("node_modules") 366 | assert parser.should_ignore("app/node_modules") 367 | assert parser.should_ignore("tests/node_modules") 368 | 369 | assert parser.should_ignore("file.tmp") 370 | assert parser.should_ignore("app/file.tmp") 371 | 372 | assert parser.should_ignore("debug.log") 373 | assert parser.should_ignore("app/debug.log") 374 | assert parser.should_ignore("tests/debug.log") 375 | 376 | def test_negation_patterns(self): 377 | """Test negation patterns are parsed correctly.""" 378 | test_dir = self.repo_path / "test_negation" 379 | test_dir.mkdir() 380 | 381 | gitignore = test_dir / ".gitignore" 382 | gitignore.write_text( 383 | """*.log 384 | !important.log 385 | !src/keep.log 386 | """ 387 | ) 388 | 389 | parser = GitignoreParser(str(test_dir)) 390 | specs = parser.get_ignore_specs() 391 | 392 | assert len(specs) == 1 393 | patterns = specs[0].patterns 394 | 395 | assert "*.log" in patterns 396 | assert "!important.log" in patterns 397 | assert "!src/keep.log" in patterns 398 | 399 | def test_comments_and_empty_lines(self): 400 | """Test that comments and empty lines are ignored.""" 401 | test_dir = self.repo_path / "test_comments" 402 | test_dir.mkdir() 403 | 404 | gitignore = test_dir / ".gitignore" 405 | gitignore.write_text( 406 | """# This is a comment 407 | *.log 408 | 409 | # Another comment 410 | # Indented comment 411 | 412 | build/ 413 | """ 414 | ) 415 | 416 | parser = GitignoreParser(str(test_dir)) 417 | specs = parser.get_ignore_specs() 418 | 419 | assert len(specs) == 1 420 | patterns = specs[0].patterns 421 | 422 | assert len(patterns) == 2 423 | assert "*.log" in patterns 424 | assert "build/" in patterns 425 | 426 | def test_escaped_characters(self): 427 | """Test escaped special characters.""" 428 | test_dir = self.repo_path / "test_escaped" 429 | test_dir.mkdir() 430 | 431 | gitignore = test_dir / ".gitignore" 432 | gitignore.write_text( 433 | """\\#not-a-comment.txt 434 | \\!not-negation.txt 435 | """ 436 | ) 437 | 438 | parser = GitignoreParser(str(test_dir)) 439 | specs = parser.get_ignore_specs() 440 | 441 | assert len(specs) == 1 442 | patterns = specs[0].patterns 443 | 444 | assert "#not-a-comment.txt" in patterns 445 | assert "!not-negation.txt" in patterns 446 | 447 | def test_escaped_negation_patterns(self): 448 | test_dir = self.repo_path / "test_escaped_negation" 449 | test_dir.mkdir() 450 | 451 | gitignore = test_dir / ".gitignore" 452 | gitignore.write_text( 453 | """*.log 454 | \\!not-negation.log 455 | !actual-negation.log 456 | """ 457 | ) 458 | 459 | parser = GitignoreParser(str(test_dir)) 460 | specs = parser.get_ignore_specs() 461 | 462 | assert len(specs) == 1 463 | patterns = specs[0].patterns 464 | 465 | # Key assertions: escaped exclamation becomes literal, real negation preserved 466 | assert "!not-negation.log" in patterns # escaped -> literal 467 | assert "!actual-negation.log" in patterns # real negation preserved 468 | 469 | # Test the actual behavioral difference between escaped and real negation: 470 | # *.log pattern should ignore test.log 471 | assert parser.should_ignore("test.log") 472 | 473 | # Escaped negation file should still be ignored by *.log pattern 474 | assert parser.should_ignore("!not-negation.log") 475 | 476 | # Actual negation should override the *.log pattern 477 | assert not parser.should_ignore("actual-negation.log") 478 | 479 | def test_glob_patterns(self): 480 | """Test various glob patterns work correctly.""" 481 | test_dir = self.repo_path / "test_glob" 482 | test_dir.mkdir() 483 | 484 | gitignore = test_dir / ".gitignore" 485 | gitignore.write_text( 486 | """*.pyc 487 | **/*.tmp 488 | src/*.o 489 | !src/important.o 490 | [Tt]est* 491 | """ 492 | ) 493 | 494 | # Create test files 495 | (test_dir / "src").mkdir() 496 | (test_dir / "src" / "nested").mkdir() 497 | (test_dir / "file.pyc").touch() 498 | (test_dir / "src" / "file.pyc").touch() 499 | (test_dir / "file.tmp").touch() 500 | (test_dir / "src" / "nested" / "file.tmp").touch() 501 | (test_dir / "src" / "file.o").touch() 502 | (test_dir / "src" / "important.o").touch() 503 | (test_dir / "Test.txt").touch() 504 | (test_dir / "test.log").touch() 505 | 506 | parser = GitignoreParser(str(test_dir)) 507 | 508 | # *.pyc should match everywhere 509 | assert parser.should_ignore("file.pyc") 510 | assert parser.should_ignore("src/file.pyc") 511 | 512 | # **/*.tmp should match all .tmp files 513 | assert parser.should_ignore("file.tmp") 514 | assert parser.should_ignore("src/nested/file.tmp") 515 | 516 | # src/*.o should only match .o files directly in src/ 517 | assert parser.should_ignore("src/file.o") 518 | 519 | # Character class patterns 520 | assert parser.should_ignore("Test.txt") 521 | assert parser.should_ignore("test.log") 522 | 523 | def test_empty_gitignore(self): 524 | """Test handling of empty gitignore files.""" 525 | test_dir = self.repo_path / "test_empty" 526 | test_dir.mkdir() 527 | 528 | gitignore = test_dir / ".gitignore" 529 | gitignore.write_text("") 530 | 531 | parser = GitignoreParser(str(test_dir)) 532 | 533 | # Should not crash and should return empty list 534 | assert len(parser.get_ignore_specs()) == 0 535 | 536 | def test_malformed_gitignore(self): 537 | """Test handling of malformed gitignore content.""" 538 | test_dir = self.repo_path / "test_malformed" 539 | test_dir.mkdir() 540 | 541 | gitignore = test_dir / ".gitignore" 542 | gitignore.write_text( 543 | """# Only comments and empty lines 544 | 545 | # More comments 546 | 547 | """ 548 | ) 549 | 550 | parser = GitignoreParser(str(test_dir)) 551 | 552 | # Should handle gracefully 553 | assert len(parser.get_ignore_specs()) == 0 554 | 555 | def test_reload(self): 556 | """Test reloading gitignore files.""" 557 | test_dir = self.repo_path / "test_reload" 558 | test_dir.mkdir() 559 | 560 | # Create initial gitignore 561 | gitignore = test_dir / ".gitignore" 562 | gitignore.write_text("*.log") 563 | 564 | parser = GitignoreParser(str(test_dir)) 565 | assert len(parser.get_ignore_specs()) == 1 566 | assert parser.should_ignore("test.log") 567 | 568 | # Modify gitignore 569 | gitignore.write_text("*.tmp") 570 | 571 | # Without reload, should still use old patterns 572 | assert parser.should_ignore("test.log") 573 | assert not parser.should_ignore("test.tmp") 574 | 575 | # After reload, should use new patterns 576 | parser.reload() 577 | assert not parser.should_ignore("test.log") 578 | assert parser.should_ignore("test.tmp") 579 | 580 | def test_gitignore_spec_matches(self): 581 | """Test GitignoreSpec.matches method.""" 582 | spec = GitignoreSpec("/path/to/.gitignore", ["*.log", "build/", "!important.log"]) 583 | 584 | assert spec.matches("test.log") 585 | assert spec.matches("build/output.o") 586 | assert spec.matches("src/test.log") 587 | 588 | # Note: Negation patterns in pathspec work differently than in git 589 | # This is a limitation of the pathspec library 590 | 591 | def test_subdirectory_gitignore_pattern_scoping(self): 592 | """Test that subdirectory .gitignore patterns are scoped correctly.""" 593 | # Create test structure: foo/ with subdirectory bar/ 594 | test_dir = self.repo_path / "test_subdir_scoping" 595 | test_dir.mkdir() 596 | (test_dir / "foo").mkdir() 597 | (test_dir / "foo" / "bar").mkdir() 598 | 599 | # Create files in various locations 600 | (test_dir / "foo.txt").touch() # root level 601 | (test_dir / "foo" / "foo.txt").touch() # in foo/ 602 | (test_dir / "foo" / "bar" / "foo.txt").touch() # in foo/bar/ 603 | 604 | # Test case 1: foo.txt in foo/.gitignore should only ignore in foo/ subtree 605 | gitignore = test_dir / "foo" / ".gitignore" 606 | gitignore.write_text("foo.txt\n") 607 | 608 | parser = GitignoreParser(str(test_dir)) 609 | 610 | # foo.txt at root should NOT be ignored by foo/.gitignore 611 | assert not parser.should_ignore("foo.txt"), "Root foo.txt should not be ignored by foo/.gitignore" 612 | 613 | # foo.txt in foo/ should be ignored 614 | assert parser.should_ignore("foo/foo.txt"), "foo/foo.txt should be ignored" 615 | 616 | # foo.txt in foo/bar/ should be ignored (within foo/ subtree) 617 | assert parser.should_ignore("foo/bar/foo.txt"), "foo/bar/foo.txt should be ignored" 618 | 619 | def test_anchored_pattern_in_subdirectory(self): 620 | """Test that anchored patterns in subdirectory only match immediate children.""" 621 | test_dir = self.repo_path / "test_anchored_subdir" 622 | test_dir.mkdir() 623 | (test_dir / "foo").mkdir() 624 | (test_dir / "foo" / "bar").mkdir() 625 | 626 | # Create files 627 | (test_dir / "foo.txt").touch() # root level 628 | (test_dir / "foo" / "foo.txt").touch() # in foo/ 629 | (test_dir / "foo" / "bar" / "foo.txt").touch() # in foo/bar/ 630 | 631 | # Test case 2: /foo.txt in foo/.gitignore should only match foo/foo.txt 632 | gitignore = test_dir / "foo" / ".gitignore" 633 | gitignore.write_text("/foo.txt\n") 634 | 635 | parser = GitignoreParser(str(test_dir)) 636 | 637 | # foo.txt at root should NOT be ignored 638 | assert not parser.should_ignore("foo.txt"), "Root foo.txt should not be ignored" 639 | 640 | # foo.txt directly in foo/ should be ignored 641 | assert parser.should_ignore("foo/foo.txt"), "foo/foo.txt should be ignored by /foo.txt pattern" 642 | 643 | # foo.txt in foo/bar/ should NOT be ignored (anchored pattern only matches immediate children) 644 | assert not parser.should_ignore("foo/bar/foo.txt"), "foo/bar/foo.txt should NOT be ignored by /foo.txt pattern" 645 | 646 | def test_double_star_pattern_scoping(self): 647 | """Test that **/pattern in subdirectory only applies within that subtree.""" 648 | test_dir = self.repo_path / "test_doublestar_scope" 649 | test_dir.mkdir() 650 | (test_dir / "foo").mkdir() 651 | (test_dir / "foo" / "bar").mkdir() 652 | (test_dir / "other").mkdir() 653 | 654 | # Create files 655 | (test_dir / "foo.txt").touch() # root level 656 | (test_dir / "foo" / "foo.txt").touch() # in foo/ 657 | (test_dir / "foo" / "bar" / "foo.txt").touch() # in foo/bar/ 658 | (test_dir / "other" / "foo.txt").touch() # in other/ 659 | 660 | # Test case 3: **/foo.txt in foo/.gitignore should only ignore within foo/ subtree 661 | gitignore = test_dir / "foo" / ".gitignore" 662 | gitignore.write_text("**/foo.txt\n") 663 | 664 | parser = GitignoreParser(str(test_dir)) 665 | 666 | # foo.txt at root should NOT be ignored 667 | assert not parser.should_ignore("foo.txt"), "Root foo.txt should not be ignored by foo/.gitignore" 668 | 669 | # foo.txt in foo/ should be ignored 670 | assert parser.should_ignore("foo/foo.txt"), "foo/foo.txt should be ignored" 671 | 672 | # foo.txt in foo/bar/ should be ignored (within foo/ subtree) 673 | assert parser.should_ignore("foo/bar/foo.txt"), "foo/bar/foo.txt should be ignored" 674 | 675 | # foo.txt in other/ should NOT be ignored (outside foo/ subtree) 676 | assert not parser.should_ignore("other/foo.txt"), "other/foo.txt should NOT be ignored by foo/.gitignore" 677 | 678 | def test_anchored_double_star_pattern(self): 679 | """Test that /**/pattern in subdirectory works correctly.""" 680 | test_dir = self.repo_path / "test_anchored_doublestar" 681 | test_dir.mkdir() 682 | (test_dir / "foo").mkdir() 683 | (test_dir / "foo" / "bar").mkdir() 684 | (test_dir / "other").mkdir() 685 | 686 | # Create files 687 | (test_dir / "foo.txt").touch() # root level 688 | (test_dir / "foo" / "foo.txt").touch() # in foo/ 689 | (test_dir / "foo" / "bar" / "foo.txt").touch() # in foo/bar/ 690 | (test_dir / "other" / "foo.txt").touch() # in other/ 691 | 692 | # Test case 4: /**/foo.txt in foo/.gitignore should correctly ignore only within foo/ subtree 693 | gitignore = test_dir / "foo" / ".gitignore" 694 | gitignore.write_text("/**/foo.txt\n") 695 | 696 | parser = GitignoreParser(str(test_dir)) 697 | 698 | # foo.txt at root should NOT be ignored 699 | assert not parser.should_ignore("foo.txt"), "Root foo.txt should not be ignored" 700 | 701 | # foo.txt in foo/ should be ignored 702 | assert parser.should_ignore("foo/foo.txt"), "foo/foo.txt should be ignored" 703 | 704 | # foo.txt in foo/bar/ should be ignored (within foo/ subtree) 705 | assert parser.should_ignore("foo/bar/foo.txt"), "foo/bar/foo.txt should be ignored" 706 | 707 | # foo.txt in other/ should NOT be ignored (outside foo/ subtree) 708 | assert not parser.should_ignore("other/foo.txt"), "other/foo.txt should NOT be ignored by foo/.gitignore" 709 | ```