This is page 11 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 │ │ ├── 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 │ │ ├── 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 │ │ ├── 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 │ │ ├── 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 │ ├── 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 │ ├── 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 -------------------------------------------------------------------------------- /test/solidlsp/ruby/test_ruby_symbol_retrieval.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Tests for the Ruby 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 | - request_document_symbols integration 9 | """ 10 | 11 | import os 12 | 13 | import pytest 14 | 15 | from solidlsp import SolidLanguageServer 16 | from solidlsp.ls_config import Language 17 | from solidlsp.ls_types import SymbolKind 18 | 19 | pytestmark = pytest.mark.ruby 20 | 21 | 22 | class TestRubyLanguageServerSymbols: 23 | """Test the Ruby language server's symbol-related functionality.""" 24 | 25 | @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) 26 | def test_request_containing_symbol_method(self, language_server: SolidLanguageServer) -> None: 27 | """Test request_containing_symbol for a method.""" 28 | # Test for a position inside the create_user method 29 | file_path = os.path.join("services.rb") 30 | # Look for a position inside the create_user method body 31 | containing_symbol = language_server.request_containing_symbol(file_path, 11, 10, include_body=True) 32 | 33 | # Verify that we found the containing symbol 34 | assert containing_symbol is not None, "Should find containing symbol for method position" 35 | assert containing_symbol["name"] == "create_user", f"Expected 'create_user', got '{containing_symbol['name']}'" 36 | assert ( 37 | containing_symbol["kind"] == SymbolKind.Method.value 38 | ), f"Expected Method kind ({SymbolKind.Method.value}), got {containing_symbol['kind']}" 39 | 40 | # Verify location information 41 | assert "location" in containing_symbol, "Containing symbol should have location information" 42 | location = containing_symbol["location"] 43 | assert "range" in location, "Location should contain range information" 44 | assert "start" in location["range"], "Range should have start position" 45 | assert "end" in location["range"], "Range should have end position" 46 | 47 | # Verify container information 48 | if "containerName" in containing_symbol: 49 | assert containing_symbol["containerName"] in [ 50 | "Services::UserService", 51 | "UserService", 52 | ], f"Expected UserService container, got '{containing_symbol['containerName']}'" 53 | 54 | # Verify body content if available 55 | if "body" in containing_symbol: 56 | body = containing_symbol["body"] 57 | assert "def create_user" in body, "Method body should contain method definition" 58 | assert len(body.strip()) > 0, "Method body should not be empty" 59 | 60 | @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) 61 | def test_request_containing_symbol_class(self, language_server: SolidLanguageServer) -> None: 62 | """Test request_containing_symbol for a class.""" 63 | # Test for a position inside the UserService class but outside any method 64 | file_path = os.path.join("services.rb") 65 | # Line around the class definition 66 | containing_symbol = language_server.request_containing_symbol(file_path, 5, 5) 67 | 68 | # Verify that we found the containing symbol 69 | assert containing_symbol is not None, "Should find containing symbol for class position" 70 | assert containing_symbol["name"] == "UserService", f"Expected 'UserService', got '{containing_symbol['name']}'" 71 | assert ( 72 | containing_symbol["kind"] == SymbolKind.Class.value 73 | ), f"Expected Class kind ({SymbolKind.Class.value}), got {containing_symbol['kind']}" 74 | 75 | # Verify location information exists 76 | assert "location" in containing_symbol, "Class symbol should have location information" 77 | location = containing_symbol["location"] 78 | assert "range" in location, "Location should contain range" 79 | assert "start" in location["range"] and "end" in location["range"], "Range should have start and end positions" 80 | 81 | # Verify the class is properly nested in the Services module 82 | if "containerName" in containing_symbol: 83 | assert ( 84 | containing_symbol["containerName"] == "Services" 85 | ), f"Expected 'Services' as container, got '{containing_symbol['containerName']}'" 86 | 87 | @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) 88 | def test_request_containing_symbol_module(self, language_server: SolidLanguageServer) -> None: 89 | """Test request_containing_symbol for a module context.""" 90 | # Test that we can find the Services module in document symbols 91 | file_path = os.path.join("services.rb") 92 | symbols, _roots = language_server.request_document_symbols(file_path) 93 | 94 | # Verify Services module appears in document symbols 95 | services_module = None 96 | for symbol in symbols: 97 | if symbol.get("name") == "Services" and symbol.get("kind") == SymbolKind.Module: 98 | services_module = symbol 99 | break 100 | 101 | assert services_module is not None, "Services module not found in document symbols" 102 | 103 | # Test that UserService class has Services as container 104 | # Position inside UserService class 105 | containing_symbol = language_server.request_containing_symbol(file_path, 4, 8) 106 | assert containing_symbol is not None 107 | assert containing_symbol["name"] == "UserService" 108 | assert containing_symbol["kind"] == SymbolKind.Class 109 | # Verify the module context is preserved in containerName (if supported by the language server) 110 | # ruby-lsp doesn't provide containerName, but Solargraph does 111 | if "containerName" in containing_symbol: 112 | assert containing_symbol.get("containerName") == "Services" 113 | 114 | @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) 115 | def test_request_containing_symbol_nested_class(self, language_server: SolidLanguageServer) -> None: 116 | """Test request_containing_symbol with nested classes.""" 117 | # Test for a position inside a nested class method 118 | file_path = os.path.join("nested.rb") 119 | # Position inside NestedClass.find_me method 120 | containing_symbol = language_server.request_containing_symbol(file_path, 20, 10) 121 | 122 | # Verify that we found the innermost containing symbol 123 | assert containing_symbol is not None 124 | assert containing_symbol["name"] == "find_me" 125 | assert containing_symbol["kind"] == SymbolKind.Method 126 | 127 | @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) 128 | def test_request_containing_symbol_none(self, language_server: SolidLanguageServer) -> None: 129 | """Test request_containing_symbol for a position with no containing symbol.""" 130 | # Test for a position outside any class/method (e.g., in requires) 131 | file_path = os.path.join("services.rb") 132 | # Line 1 is a require statement, not inside any class or method 133 | containing_symbol = language_server.request_containing_symbol(file_path, 1, 5) 134 | 135 | # Should return None or an empty dictionary 136 | assert containing_symbol is None or containing_symbol == {} 137 | 138 | @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) 139 | def test_request_referencing_symbols_method(self, language_server: SolidLanguageServer) -> None: 140 | """Test request_referencing_symbols for a method.""" 141 | # Test referencing symbols for create_user method 142 | file_path = os.path.join("services.rb") 143 | # Line containing the create_user method definition 144 | symbols, _roots = language_server.request_document_symbols(file_path) 145 | create_user_symbol = None 146 | 147 | # Find create_user method in the document symbols (Ruby returns flat list) 148 | for symbol in symbols: 149 | if symbol.get("name") == "create_user": 150 | create_user_symbol = symbol 151 | break 152 | 153 | if not create_user_symbol or "selectionRange" not in create_user_symbol: 154 | pytest.skip("create_user symbol or its selectionRange not found") 155 | 156 | sel_start = create_user_symbol["selectionRange"]["start"] 157 | ref_symbols = [ 158 | ref.symbol for ref in language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"]) 159 | ] 160 | 161 | # We might not have references in our simple test setup, so just verify structure 162 | for symbol in ref_symbols: 163 | assert "name" in symbol 164 | assert "kind" in symbol 165 | 166 | @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) 167 | def test_request_referencing_symbols_class(self, language_server: SolidLanguageServer) -> None: 168 | """Test request_referencing_symbols for a class.""" 169 | # Test referencing symbols for User class 170 | file_path = os.path.join("models.rb") 171 | # Find User class in document symbols 172 | symbols, _roots = language_server.request_document_symbols(file_path) 173 | user_symbol = None 174 | 175 | for symbol in symbols: 176 | if symbol.get("name") == "User": 177 | user_symbol = symbol 178 | break 179 | 180 | if not user_symbol or "selectionRange" not in user_symbol: 181 | pytest.skip("User symbol or its selectionRange not found") 182 | 183 | sel_start = user_symbol["selectionRange"]["start"] 184 | ref_symbols = [ 185 | ref.symbol for ref in language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"]) 186 | ] 187 | 188 | # Verify structure of referencing symbols 189 | for symbol in ref_symbols: 190 | assert "name" in symbol 191 | assert "kind" in symbol 192 | if "location" in symbol and "range" in symbol["location"]: 193 | assert "start" in symbol["location"]["range"] 194 | assert "end" in symbol["location"]["range"] 195 | 196 | @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) 197 | def test_request_defining_symbol_variable(self, language_server: SolidLanguageServer) -> None: 198 | """Test request_defining_symbol for a variable usage.""" 199 | # Test finding the definition of a variable in a method 200 | file_path = os.path.join("services.rb") 201 | # Look for @users variable usage 202 | defining_symbol = language_server.request_defining_symbol(file_path, 12, 10) 203 | 204 | # This test might fail if the language server doesn't support it well 205 | if defining_symbol is not None: 206 | assert "name" in defining_symbol 207 | assert "kind" in defining_symbol 208 | 209 | @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) 210 | def test_request_defining_symbol_class(self, language_server: SolidLanguageServer) -> None: 211 | """Test request_defining_symbol for a class reference.""" 212 | # Test finding the definition of the User class used in services 213 | file_path = os.path.join("services.rb") 214 | # Line that references User class 215 | defining_symbol = language_server.request_defining_symbol(file_path, 11, 15) 216 | 217 | # This might not work perfectly in all Ruby language servers 218 | if defining_symbol is not None: 219 | assert "name" in defining_symbol 220 | # The name might be "User" or the method that contains it 221 | assert defining_symbol.get("name") is not None 222 | 223 | @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) 224 | def test_request_defining_symbol_none(self, language_server: SolidLanguageServer) -> None: 225 | """Test request_defining_symbol for a position with no symbol.""" 226 | # Test for a position with no symbol (e.g., whitespace or comment) 227 | file_path = os.path.join("services.rb") 228 | # Line 3 is likely a blank line or comment 229 | defining_symbol = language_server.request_defining_symbol(file_path, 3, 0) 230 | 231 | # Should return None for positions with no symbol 232 | assert defining_symbol is None or defining_symbol == {} 233 | 234 | @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) 235 | def test_request_defining_symbol_nested_class(self, language_server: SolidLanguageServer) -> None: 236 | """Test request_defining_symbol for nested class access.""" 237 | # Test finding definition of NestedClass 238 | file_path = os.path.join("nested.rb") 239 | # Position where NestedClass is referenced 240 | defining_symbol = language_server.request_defining_symbol(file_path, 44, 25) 241 | 242 | # This is challenging for many language servers 243 | if defining_symbol is not None: 244 | assert "name" in defining_symbol 245 | assert defining_symbol.get("name") in ["NestedClass", "OuterClass"] 246 | 247 | @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) 248 | def test_symbol_methods_integration(self, language_server: SolidLanguageServer) -> None: 249 | """Test the integration between different symbol-related methods.""" 250 | file_path = os.path.join("models.rb") 251 | 252 | # Step 1: Find a method we know exists 253 | containing_symbol = language_server.request_containing_symbol(file_path, 8, 5) # inside initialize method 254 | if containing_symbol is not None: 255 | assert containing_symbol["name"] == "initialize" 256 | 257 | # Step 2: Get the defining symbol for the same position 258 | defining_symbol = language_server.request_defining_symbol(file_path, 8, 5) 259 | if defining_symbol is not None: 260 | assert defining_symbol["name"] == "initialize" 261 | 262 | # Step 3: Verify that they refer to the same symbol type 263 | assert defining_symbol["kind"] == containing_symbol["kind"] 264 | 265 | @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) 266 | def test_symbol_tree_structure_basic(self, language_server: SolidLanguageServer) -> None: 267 | """Test that the symbol tree structure includes Ruby symbols.""" 268 | # Get all symbols in the test repository 269 | repo_structure = language_server.request_full_symbol_tree() 270 | assert len(repo_structure) >= 1 271 | 272 | # Look for our Ruby files in the structure 273 | found_ruby_files = False 274 | for root in repo_structure: 275 | if "children" in root: 276 | for child in root["children"]: 277 | if child.get("name") in ["models", "services", "nested"]: 278 | found_ruby_files = True 279 | break 280 | 281 | # We should find at least some Ruby files in the symbol tree 282 | assert found_ruby_files, "Ruby files not found in symbol tree" 283 | 284 | @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) 285 | def test_document_symbols_detailed(self, language_server: SolidLanguageServer) -> None: 286 | """Test document symbols for detailed Ruby file structure.""" 287 | file_path = os.path.join("models.rb") 288 | symbols, roots = language_server.request_document_symbols(file_path) 289 | 290 | # Verify we have symbols 291 | assert len(symbols) > 0 or len(roots) > 0 292 | 293 | # Look for expected class names 294 | symbol_names = set() 295 | all_symbols = symbols if symbols else roots 296 | 297 | for symbol in all_symbols: 298 | symbol_names.add(symbol.get("name")) 299 | # Add children names too 300 | if "children" in symbol: 301 | for child in symbol["children"]: 302 | symbol_names.add(child.get("name")) 303 | 304 | # We should find at least some of our defined classes/methods 305 | expected_symbols = {"User", "Item", "Order", "ItemHelpers"} 306 | found_symbols = symbol_names.intersection(expected_symbols) 307 | assert len(found_symbols) > 0, f"Expected symbols not found. Found: {symbol_names}" 308 | 309 | @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) 310 | def test_module_and_class_hierarchy(self, language_server: SolidLanguageServer) -> None: 311 | """Test symbol detection for modules and nested class hierarchies.""" 312 | file_path = os.path.join("nested.rb") 313 | symbols, roots = language_server.request_document_symbols(file_path) 314 | 315 | # Verify we can detect the nested structure 316 | assert len(symbols) > 0 or len(roots) > 0 317 | 318 | # Look for OuterClass and its nested elements 319 | symbol_names = set() 320 | all_symbols = symbols if symbols else roots 321 | 322 | for symbol in all_symbols: 323 | symbol_names.add(symbol.get("name")) 324 | if "children" in symbol: 325 | for child in symbol["children"]: 326 | symbol_names.add(child.get("name")) 327 | # Check deeply nested too 328 | if "children" in child: 329 | for grandchild in child["children"]: 330 | symbol_names.add(grandchild.get("name")) 331 | 332 | # Should find the outer class at minimum 333 | assert "OuterClass" in symbol_names, f"OuterClass not found in symbols: {symbol_names}" 334 | 335 | @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) 336 | def test_references_to_variables(self, language_server: SolidLanguageServer) -> None: 337 | """Test request_referencing_symbols for a variable with detailed verification.""" 338 | file_path = os.path.join("variables.rb") 339 | # Test references to @status variable in DataContainer class (around line 9) 340 | ref_symbols = [ref.symbol for ref in language_server.request_referencing_symbols(file_path, 8, 4)] 341 | 342 | if len(ref_symbols) > 0: 343 | # Verify we have references 344 | assert len(ref_symbols) > 0, "Should find references to @status variable" 345 | 346 | # Check that we have location information 347 | ref_with_locations = [ref for ref in ref_symbols if "location" in ref and "range" in ref["location"]] 348 | assert len(ref_with_locations) > 0, "References should include location information" 349 | 350 | # Verify line numbers are reasonable (should be within the file) 351 | ref_lines = [ref["location"]["range"]["start"]["line"] for ref in ref_with_locations] 352 | assert all(line >= 0 for line in ref_lines), "Reference lines should be valid" 353 | 354 | # Check for specific reference locations we expect 355 | # Lines where @status is modified/accessed 356 | expected_line_ranges = [(20, 40), (45, 70)] # Approximate ranges 357 | found_in_expected_range = any(any(start <= line <= end for start, end in expected_line_ranges) for line in ref_lines) 358 | assert found_in_expected_range, f"Expected references in ranges {expected_line_ranges}, found lines: {ref_lines}" 359 | 360 | @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) 361 | def test_request_referencing_symbols_parameter(self, language_server: SolidLanguageServer) -> None: 362 | """Test request_referencing_symbols for a method parameter.""" 363 | # Test referencing symbols for a method parameter in get_user method 364 | file_path = os.path.join("services.rb") 365 | # Find get_user method and test parameter references 366 | symbols, _roots = language_server.request_document_symbols(file_path) 367 | get_user_symbol = None 368 | 369 | for symbol in symbols: 370 | if symbol.get("name") == "get_user": 371 | get_user_symbol = symbol 372 | break 373 | 374 | if not get_user_symbol or "selectionRange" not in get_user_symbol: 375 | pytest.skip("get_user symbol or its selectionRange not found") 376 | 377 | # Test parameter reference within method body 378 | method_start_line = get_user_symbol["selectionRange"]["start"]["line"] 379 | ref_symbols = [ 380 | ref.symbol 381 | for ref in language_server.request_referencing_symbols(file_path, method_start_line + 1, 10) # Position within method body 382 | ] 383 | 384 | # Verify structure of referencing symbols 385 | for symbol in ref_symbols: 386 | assert "name" in symbol, "Symbol should have name" 387 | assert "kind" in symbol, "Symbol should have kind" 388 | if "location" in symbol and "range" in symbol["location"]: 389 | range_info = symbol["location"]["range"] 390 | assert "start" in range_info, "Range should have start" 391 | assert "end" in range_info, "Range should have end" 392 | # Verify line number is valid (references can be before method definition too) 393 | assert range_info["start"]["line"] >= 0, "Reference line should be valid" 394 | 395 | @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) 396 | def test_request_referencing_symbols_none(self, language_server: SolidLanguageServer) -> None: 397 | """Test request_referencing_symbols for a position with no symbol.""" 398 | # Test for a position with no symbol (comment or blank line) 399 | file_path = os.path.join("services.rb") 400 | 401 | # Try multiple positions that should have no symbols 402 | test_positions = [(1, 0), (2, 0)] # Comment/require lines 403 | 404 | for line, char in test_positions: 405 | try: 406 | ref_symbols = [ref.symbol for ref in language_server.request_referencing_symbols(file_path, line, char)] 407 | # If we get here, make sure we got an empty result or minimal results 408 | if ref_symbols: 409 | # Some language servers might return minimal info, verify it's reasonable 410 | assert len(ref_symbols) <= 3, f"Expected few/no references at line {line}, got {len(ref_symbols)}" 411 | 412 | except Exception as e: 413 | # Some language servers throw exceptions for invalid positions, which is acceptable 414 | assert ( 415 | "symbol" in str(e).lower() or "position" in str(e).lower() or "reference" in str(e).lower() 416 | ), f"Exception should be related to symbol/position/reference issues, got: {e}" 417 | 418 | @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) 419 | def test_request_dir_overview(self, language_server: SolidLanguageServer) -> None: 420 | """Test that request_dir_overview returns correct symbol information for files in a directory.""" 421 | # Get overview of the test repo directory 422 | overview = language_server.request_dir_overview(".") 423 | 424 | # Verify that we have entries for our main files 425 | expected_files = ["services.rb", "models.rb", "variables.rb", "nested.rb"] 426 | found_files = [] 427 | 428 | for file_path in overview.keys(): 429 | for expected in expected_files: 430 | if expected in file_path: 431 | found_files.append(expected) 432 | break 433 | 434 | assert len(found_files) >= 2, f"Should find at least 2 expected files, found: {found_files}" 435 | 436 | # Test specific symbols from services.rb if it exists 437 | services_file_key = None 438 | for file_path in overview.keys(): 439 | if "services.rb" in file_path: 440 | services_file_key = file_path 441 | break 442 | 443 | if services_file_key: 444 | services_symbols = overview[services_file_key] 445 | assert len(services_symbols) > 0, "services.rb should have symbols" 446 | 447 | # Check for expected symbols with detailed verification 448 | symbol_names = [s[0] for s in services_symbols if isinstance(s, tuple) and len(s) > 0] 449 | if not symbol_names: # If not tuples, try different format 450 | symbol_names = [s.get("name") for s in services_symbols if hasattr(s, "get")] 451 | 452 | expected_symbols = ["Services", "UserService", "ItemService"] 453 | found_expected = [name for name in expected_symbols if name in symbol_names] 454 | assert len(found_expected) >= 1, f"Should find at least one expected symbol, found: {found_expected} in {symbol_names}" 455 | 456 | @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) 457 | def test_request_document_overview(self, language_server: SolidLanguageServer) -> None: 458 | """Test that request_document_overview returns correct symbol information for a file.""" 459 | # Get overview of the user_management.rb file 460 | file_path = os.path.join("examples", "user_management.rb") 461 | overview = language_server.request_document_overview(file_path) 462 | 463 | # Verify that we have symbol information 464 | assert len(overview) > 0, "Document overview should contain symbols" 465 | 466 | # Look for expected symbols from the file 467 | symbol_names = set() 468 | for s_info in overview: 469 | if isinstance(s_info, tuple) and len(s_info) > 0: 470 | symbol_names.add(s_info[0]) 471 | elif hasattr(s_info, "get"): 472 | symbol_names.add(s_info.get("name")) 473 | elif isinstance(s_info, str): 474 | symbol_names.add(s_info) 475 | 476 | # We should find some of our defined classes/methods 477 | expected_symbols = {"UserStats", "UserManager", "process_user_data", "main"} 478 | found_symbols = symbol_names.intersection(expected_symbols) 479 | assert len(found_symbols) > 0, f"Expected to find some symbols from {expected_symbols}, found: {symbol_names}" 480 | 481 | @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) 482 | def test_request_containing_symbol_variable(self, language_server: SolidLanguageServer) -> None: 483 | """Test request_containing_symbol where the target is a variable.""" 484 | # Test for a position inside a variable definition or usage 485 | file_path = os.path.join("variables.rb") 486 | # Position around a variable assignment (e.g., @status = "pending") 487 | containing_symbol = language_server.request_containing_symbol(file_path, 10, 5) 488 | 489 | # Verify that we found a containing symbol (likely the method or class) 490 | if containing_symbol is not None: 491 | assert "name" in containing_symbol, "Containing symbol should have a name" 492 | assert "kind" in containing_symbol, "Containing symbol should have a kind" 493 | # The containing symbol should be a method, class, or similar construct 494 | expected_kinds = [SymbolKind.Method, SymbolKind.Class, SymbolKind.Function, SymbolKind.Constructor] 495 | assert containing_symbol["kind"] in [ 496 | k.value for k in expected_kinds 497 | ], f"Expected containing symbol to be method/class/function, got kind: {containing_symbol['kind']}" 498 | 499 | @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) 500 | def test_request_containing_symbol_function(self, language_server: SolidLanguageServer) -> None: 501 | """Test request_containing_symbol for a function (not method).""" 502 | # Test for a position inside a standalone function 503 | file_path = os.path.join("variables.rb") 504 | # Position inside the demonstrate_variable_usage function 505 | containing_symbol = language_server.request_containing_symbol(file_path, 100, 10) 506 | 507 | if containing_symbol is not None: 508 | assert containing_symbol["name"] in [ 509 | "demonstrate_variable_usage", 510 | "main", 511 | ], f"Expected function name, got: {containing_symbol['name']}" 512 | assert containing_symbol["kind"] in [ 513 | SymbolKind.Function.value, 514 | SymbolKind.Method.value, 515 | ], f"Expected function or method kind, got: {containing_symbol['kind']}" 516 | 517 | @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) 518 | def test_request_containing_symbol_nested(self, language_server: SolidLanguageServer) -> None: 519 | """Test request_containing_symbol with nested scopes.""" 520 | # Test for a position inside a method which is inside a class 521 | file_path = os.path.join("services.rb") 522 | # Position inside create_user method within UserService class 523 | containing_symbol = language_server.request_containing_symbol(file_path, 12, 15) 524 | 525 | # Verify that we found the innermost containing symbol (the method) 526 | assert containing_symbol is not None 527 | assert containing_symbol["name"] == "create_user" 528 | assert containing_symbol["kind"] == SymbolKind.Method 529 | 530 | # Verify the container context is preserved 531 | if "containerName" in containing_symbol: 532 | assert "UserService" in containing_symbol["containerName"] 533 | 534 | @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) 535 | def test_symbol_tree_structure_subdir(self, language_server: SolidLanguageServer) -> None: 536 | """Test that the symbol tree structure correctly handles subdirectories.""" 537 | # Get symbols within the examples subdirectory 538 | examples_structure = language_server.request_full_symbol_tree(within_relative_path="examples") 539 | 540 | if len(examples_structure) > 0: 541 | # Should find the examples directory structure 542 | assert len(examples_structure) >= 1, "Should find examples directory structure" 543 | 544 | # Look for the user_management file in the structure 545 | found_user_management = False 546 | for root in examples_structure: 547 | if "children" in root: 548 | for child in root["children"]: 549 | if "user_management" in child.get("name", ""): 550 | found_user_management = True 551 | # Verify the structure includes symbol information 552 | if "children" in child: 553 | child_names = [c.get("name") for c in child["children"]] 554 | expected_names = ["UserStats", "UserManager", "process_user_data"] 555 | found_expected = [name for name in expected_names if name in child_names] 556 | assert ( 557 | len(found_expected) > 0 558 | ), f"Should find symbols in user_management, expected {expected_names}, found {child_names}" 559 | break 560 | 561 | if not found_user_management: 562 | pytest.skip("user_management file not found in examples subdirectory structure") 563 | 564 | @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) 565 | def test_request_defining_symbol_imported_class(self, language_server: SolidLanguageServer) -> None: 566 | """Test request_defining_symbol for an imported/required class.""" 567 | # Test finding the definition of a class used from another file 568 | file_path = os.path.join("examples", "user_management.rb") 569 | # Position where Services::UserService is referenced 570 | defining_symbol = language_server.request_defining_symbol(file_path, 25, 20) 571 | 572 | # This might not work perfectly in all Ruby language servers due to require complexity 573 | if defining_symbol is not None: 574 | assert "name" in defining_symbol 575 | # The defining symbol should relate to UserService or Services 576 | # The defining symbol should relate to UserService, Services, or the containing class 577 | # Different language servers may resolve this differently 578 | expected_names = ["UserService", "Services", "new", "UserManager"] 579 | assert defining_symbol.get("name") in expected_names, f"Expected one of {expected_names}, got: {defining_symbol.get('name')}" 580 | 581 | @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) 582 | def test_request_defining_symbol_method_call(self, language_server: SolidLanguageServer) -> None: 583 | """Test request_defining_symbol for a method call.""" 584 | # Test finding the definition of a method being called 585 | file_path = os.path.join("examples", "user_management.rb") 586 | # Position at a method call like create_user 587 | defining_symbol = language_server.request_defining_symbol(file_path, 30, 15) 588 | 589 | # Verify that we can find method definitions 590 | if defining_symbol is not None: 591 | assert "name" in defining_symbol 592 | assert "kind" in defining_symbol 593 | # Should be a method or constructor 594 | assert defining_symbol.get("kind") in [SymbolKind.Method.value, SymbolKind.Constructor.value, SymbolKind.Function.value] 595 | 596 | @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) 597 | def test_request_defining_symbol_nested_function(self, language_server: SolidLanguageServer) -> None: 598 | """Test request_defining_symbol for a nested function or block.""" 599 | # Test finding definition within nested contexts 600 | file_path = os.path.join("nested.rb") 601 | # Position inside or referencing nested functionality 602 | defining_symbol = language_server.request_defining_symbol(file_path, 15, 10) 603 | 604 | # This is challenging for many language servers 605 | if defining_symbol is not None: 606 | assert "name" in defining_symbol 607 | assert "kind" in defining_symbol 608 | # Could be method, function, or variable depending on implementation 609 | valid_kinds = [SymbolKind.Method.value, SymbolKind.Function.value, SymbolKind.Variable.value, SymbolKind.Class.value] 610 | assert defining_symbol.get("kind") in valid_kinds 611 | 612 | @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) 613 | def test_containing_symbol_of_var_is_file(self, language_server: SolidLanguageServer) -> None: 614 | """Test that the containing symbol of a file-level variable is handled appropriately.""" 615 | # Test behavior with file-level variables or constants 616 | file_path = os.path.join("variables.rb") 617 | # Position at file-level variable/constant 618 | containing_symbol = language_server.request_containing_symbol(file_path, 5, 5) 619 | 620 | # Different language servers handle file-level symbols differently 621 | # Some return None, others return file-level containers 622 | if containing_symbol is not None: 623 | # If we get a symbol, verify its structure 624 | assert "name" in containing_symbol 625 | assert "kind" in containing_symbol 626 | ``` -------------------------------------------------------------------------------- /src/solidlsp/language_servers/eclipse_jdtls.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Provides Java specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Java. 3 | """ 4 | 5 | import dataclasses 6 | import logging 7 | import os 8 | import pathlib 9 | import shutil 10 | import threading 11 | import uuid 12 | from pathlib import PurePath 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.ls_utils import FileUtils, PlatformUtils 20 | from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams 21 | from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo 22 | from solidlsp.settings import SolidLSPSettings 23 | 24 | 25 | @dataclasses.dataclass 26 | class RuntimeDependencyPaths: 27 | """ 28 | Stores the paths to the runtime dependencies of EclipseJDTLS 29 | """ 30 | 31 | gradle_path: str 32 | lombok_jar_path: str 33 | jre_path: str 34 | jre_home_path: str 35 | jdtls_launcher_jar_path: str 36 | jdtls_readonly_config_path: str 37 | intellicode_jar_path: str 38 | intellisense_members_path: str 39 | 40 | 41 | class EclipseJDTLS(SolidLanguageServer): 42 | """ 43 | The EclipseJDTLS class provides a Java specific implementation of the LanguageServer class 44 | """ 45 | 46 | def __init__( 47 | self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings 48 | ): 49 | """ 50 | Creates a new EclipseJDTLS instance initializing the language server settings appropriately. 51 | This class is not meant to be instantiated directly. Use LanguageServer.create() instead. 52 | """ 53 | runtime_dependency_paths = self._setupRuntimeDependencies(logger, config, solidlsp_settings) 54 | self.runtime_dependency_paths = runtime_dependency_paths 55 | 56 | # ws_dir is the workspace directory for the EclipseJDTLS server 57 | ws_dir = str( 58 | PurePath( 59 | solidlsp_settings.ls_resources_dir, 60 | "EclipseJDTLS", 61 | "workspaces", 62 | uuid.uuid4().hex, 63 | ) 64 | ) 65 | 66 | # shared_cache_location is the global cache used by Eclipse JDTLS across all workspaces 67 | shared_cache_location = str(PurePath(solidlsp_settings.ls_resources_dir, "lsp", "EclipseJDTLS", "sharedIndex")) 68 | os.makedirs(shared_cache_location, exist_ok=True) 69 | os.makedirs(ws_dir, exist_ok=True) 70 | 71 | jre_path = self.runtime_dependency_paths.jre_path 72 | lombok_jar_path = self.runtime_dependency_paths.lombok_jar_path 73 | 74 | jdtls_launcher_jar = self.runtime_dependency_paths.jdtls_launcher_jar_path 75 | 76 | data_dir = str(PurePath(ws_dir, "data_dir")) 77 | jdtls_config_path = str(PurePath(ws_dir, "config_path")) 78 | 79 | jdtls_readonly_config_path = self.runtime_dependency_paths.jdtls_readonly_config_path 80 | 81 | if not os.path.exists(jdtls_config_path): 82 | shutil.copytree(jdtls_readonly_config_path, jdtls_config_path) 83 | 84 | for static_path in [ 85 | jre_path, 86 | lombok_jar_path, 87 | jdtls_launcher_jar, 88 | jdtls_config_path, 89 | jdtls_readonly_config_path, 90 | ]: 91 | assert os.path.exists(static_path), static_path 92 | 93 | # TODO: Add "self.runtime_dependency_paths.jre_home_path"/bin to $PATH as well 94 | proc_env = {"syntaxserver": "false", "JAVA_HOME": self.runtime_dependency_paths.jre_home_path} 95 | proc_cwd = repository_root_path 96 | cmd = " ".join( 97 | [ 98 | jre_path, 99 | "--add-modules=ALL-SYSTEM", 100 | "--add-opens", 101 | "java.base/java.util=ALL-UNNAMED", 102 | "--add-opens", 103 | "java.base/java.lang=ALL-UNNAMED", 104 | "--add-opens", 105 | "java.base/sun.nio.fs=ALL-UNNAMED", 106 | "-Declipse.application=org.eclipse.jdt.ls.core.id1", 107 | "-Dosgi.bundles.defaultStartLevel=4", 108 | "-Declipse.product=org.eclipse.jdt.ls.core.product", 109 | "-Djava.import.generatesMetadataFilesAtProjectRoot=false", 110 | "-Dfile.encoding=utf8", 111 | "-noverify", 112 | "-XX:+UseParallelGC", 113 | "-XX:GCTimeRatio=4", 114 | "-XX:AdaptiveSizePolicyWeight=90", 115 | "-Dsun.zip.disableMemoryMapping=true", 116 | "-Djava.lsp.joinOnCompletion=true", 117 | "-Xmx3G", 118 | "-Xms100m", 119 | "-Xlog:disable", 120 | "-Dlog.level=ALL", 121 | f'"-javaagent:{lombok_jar_path}"', 122 | f'"-Djdt.core.sharedIndexLocation={shared_cache_location}"', 123 | "-jar", 124 | f'"{jdtls_launcher_jar}"', 125 | "-configuration", 126 | f'"{jdtls_config_path}"', 127 | "-data", 128 | f'"{data_dir}"', 129 | ] 130 | ) 131 | 132 | self.service_ready_event = threading.Event() 133 | self.intellicode_enable_command_available = threading.Event() 134 | self.initialize_searcher_command_available = threading.Event() 135 | 136 | super().__init__( 137 | config, logger, repository_root_path, ProcessLaunchInfo(cmd, proc_env, proc_cwd), "java", solidlsp_settings=solidlsp_settings 138 | ) 139 | 140 | @override 141 | def is_ignored_dirname(self, dirname: str) -> bool: 142 | # Ignore common Java build directories from different build tools: 143 | # - Maven: target 144 | # - Gradle: build, .gradle 145 | # - Eclipse: bin, .settings 146 | # - IntelliJ IDEA: out, .idea 147 | # - General: classes, dist, lib 148 | return super().is_ignored_dirname(dirname) or dirname in [ 149 | "target", # Maven 150 | "build", # Gradle 151 | "bin", # Eclipse 152 | "out", # IntelliJ IDEA 153 | "classes", # General 154 | "dist", # General 155 | "lib", # General 156 | ] 157 | 158 | @classmethod 159 | def _setupRuntimeDependencies( 160 | cls, logger: LanguageServerLogger, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings 161 | ) -> RuntimeDependencyPaths: 162 | """ 163 | Setup runtime dependencies for EclipseJDTLS and return the paths. 164 | """ 165 | platformId = PlatformUtils.get_platform_id() 166 | 167 | runtime_dependencies = { 168 | "gradle": { 169 | "platform-agnostic": { 170 | "url": "https://services.gradle.org/distributions/gradle-8.14.2-bin.zip", 171 | "archiveType": "zip", 172 | "relative_extraction_path": ".", 173 | } 174 | }, 175 | "vscode-java": { 176 | "darwin-arm64": { 177 | "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-darwin-arm64-1.42.0-561.vsix", 178 | "archiveType": "zip", 179 | "relative_extraction_path": "vscode-java", 180 | }, 181 | "osx-arm64": { 182 | "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-darwin-arm64-1.42.0-561.vsix", 183 | "archiveType": "zip", 184 | "relative_extraction_path": "vscode-java", 185 | "jre_home_path": "extension/jre/21.0.7-macosx-aarch64", 186 | "jre_path": "extension/jre/21.0.7-macosx-aarch64/bin/java", 187 | "lombok_jar_path": "extension/lombok/lombok-1.18.36.jar", 188 | "jdtls_launcher_jar_path": "extension/server/plugins/org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar", 189 | "jdtls_readonly_config_path": "extension/server/config_mac_arm", 190 | }, 191 | "osx-x64": { 192 | "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-darwin-x64-1.42.0-561.vsix", 193 | "archiveType": "zip", 194 | "relative_extraction_path": "vscode-java", 195 | "jre_home_path": "extension/jre/21.0.7-macosx-x86_64", 196 | "jre_path": "extension/jre/21.0.7-macosx-x86_64/bin/java", 197 | "lombok_jar_path": "extension/lombok/lombok-1.18.36.jar", 198 | "jdtls_launcher_jar_path": "extension/server/plugins/org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar", 199 | "jdtls_readonly_config_path": "extension/server/config_mac", 200 | }, 201 | "linux-arm64": { 202 | "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-linux-arm64-1.42.0-561.vsix", 203 | "archiveType": "zip", 204 | "relative_extraction_path": "vscode-java", 205 | }, 206 | "linux-x64": { 207 | "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-linux-x64-1.42.0-561.vsix", 208 | "archiveType": "zip", 209 | "relative_extraction_path": "vscode-java", 210 | "jre_home_path": "extension/jre/21.0.7-linux-x86_64", 211 | "jre_path": "extension/jre/21.0.7-linux-x86_64/bin/java", 212 | "lombok_jar_path": "extension/lombok/lombok-1.18.36.jar", 213 | "jdtls_launcher_jar_path": "extension/server/plugins/org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar", 214 | "jdtls_readonly_config_path": "extension/server/config_linux", 215 | }, 216 | "win-x64": { 217 | "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-win32-x64-1.42.0-561.vsix", 218 | "archiveType": "zip", 219 | "relative_extraction_path": "vscode-java", 220 | "jre_home_path": "extension/jre/21.0.7-win32-x86_64", 221 | "jre_path": "extension/jre/21.0.7-win32-x86_64/bin/java.exe", 222 | "lombok_jar_path": "extension/lombok/lombok-1.18.36.jar", 223 | "jdtls_launcher_jar_path": "extension/server/plugins/org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar", 224 | "jdtls_readonly_config_path": "extension/server/config_win", 225 | }, 226 | }, 227 | "intellicode": { 228 | "platform-agnostic": { 229 | "url": "https://VisualStudioExptTeam.gallery.vsassets.io/_apis/public/gallery/publisher/VisualStudioExptTeam/extension/vscodeintellicode/1.2.30/assetbyname/Microsoft.VisualStudio.Services.VSIXPackage", 230 | "alternate_url": "https://marketplace.visualstudio.com/_apis/public/gallery/publishers/VisualStudioExptTeam/vsextensions/vscodeintellicode/1.2.30/vspackage", 231 | "archiveType": "zip", 232 | "relative_extraction_path": "intellicode", 233 | "intellicode_jar_path": "extension/dist/com.microsoft.jdtls.intellicode.core-0.7.0.jar", 234 | "intellisense_members_path": "extension/dist/bundledModels/java_intellisense-members", 235 | } 236 | }, 237 | } 238 | 239 | # assert platformId.value in [ 240 | # "linux-x64", 241 | # "win-x64", 242 | # ], "Only linux-x64 platform is supported for in multilspy at the moment" 243 | 244 | gradle_path = str( 245 | PurePath( 246 | cls.ls_resources_dir(solidlsp_settings), 247 | "gradle-8.14.2", 248 | ) 249 | ) 250 | 251 | if not os.path.exists(gradle_path): 252 | FileUtils.download_and_extract_archive( 253 | logger, 254 | runtime_dependencies["gradle"]["platform-agnostic"]["url"], 255 | str(PurePath(gradle_path).parent), 256 | runtime_dependencies["gradle"]["platform-agnostic"]["archiveType"], 257 | ) 258 | 259 | assert os.path.exists(gradle_path) 260 | 261 | dependency = runtime_dependencies["vscode-java"][platformId.value] 262 | vscode_java_path = str(PurePath(cls.ls_resources_dir(solidlsp_settings), dependency["relative_extraction_path"])) 263 | os.makedirs(vscode_java_path, exist_ok=True) 264 | jre_home_path = str(PurePath(vscode_java_path, dependency["jre_home_path"])) 265 | jre_path = str(PurePath(vscode_java_path, dependency["jre_path"])) 266 | lombok_jar_path = str(PurePath(vscode_java_path, dependency["lombok_jar_path"])) 267 | jdtls_launcher_jar_path = str(PurePath(vscode_java_path, dependency["jdtls_launcher_jar_path"])) 268 | jdtls_readonly_config_path = str(PurePath(vscode_java_path, dependency["jdtls_readonly_config_path"])) 269 | if not all( 270 | [ 271 | os.path.exists(vscode_java_path), 272 | os.path.exists(jre_home_path), 273 | os.path.exists(jre_path), 274 | os.path.exists(lombok_jar_path), 275 | os.path.exists(jdtls_launcher_jar_path), 276 | os.path.exists(jdtls_readonly_config_path), 277 | ] 278 | ): 279 | FileUtils.download_and_extract_archive(logger, dependency["url"], vscode_java_path, dependency["archiveType"]) 280 | 281 | os.chmod(jre_path, 0o755) 282 | 283 | assert os.path.exists(vscode_java_path) 284 | assert os.path.exists(jre_home_path) 285 | assert os.path.exists(jre_path) 286 | assert os.path.exists(lombok_jar_path) 287 | assert os.path.exists(jdtls_launcher_jar_path) 288 | assert os.path.exists(jdtls_readonly_config_path) 289 | 290 | dependency = runtime_dependencies["intellicode"]["platform-agnostic"] 291 | intellicode_directory_path = str(PurePath(cls.ls_resources_dir(solidlsp_settings), dependency["relative_extraction_path"])) 292 | os.makedirs(intellicode_directory_path, exist_ok=True) 293 | intellicode_jar_path = str(PurePath(intellicode_directory_path, dependency["intellicode_jar_path"])) 294 | intellisense_members_path = str(PurePath(intellicode_directory_path, dependency["intellisense_members_path"])) 295 | if not all( 296 | [ 297 | os.path.exists(intellicode_directory_path), 298 | os.path.exists(intellicode_jar_path), 299 | os.path.exists(intellisense_members_path), 300 | ] 301 | ): 302 | FileUtils.download_and_extract_archive(logger, dependency["url"], intellicode_directory_path, dependency["archiveType"]) 303 | 304 | assert os.path.exists(intellicode_directory_path) 305 | assert os.path.exists(intellicode_jar_path) 306 | assert os.path.exists(intellisense_members_path) 307 | 308 | return RuntimeDependencyPaths( 309 | gradle_path=gradle_path, 310 | lombok_jar_path=lombok_jar_path, 311 | jre_path=jre_path, 312 | jre_home_path=jre_home_path, 313 | jdtls_launcher_jar_path=jdtls_launcher_jar_path, 314 | jdtls_readonly_config_path=jdtls_readonly_config_path, 315 | intellicode_jar_path=intellicode_jar_path, 316 | intellisense_members_path=intellisense_members_path, 317 | ) 318 | 319 | def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: 320 | """ 321 | Returns the initialize parameters for the EclipseJDTLS server. 322 | """ 323 | # Look into https://github.com/eclipse/eclipse.jdt.ls/blob/master/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/preferences/Preferences.java to understand all the options available 324 | 325 | if not os.path.isabs(repository_absolute_path): 326 | repository_absolute_path = os.path.abspath(repository_absolute_path) 327 | repo_uri = pathlib.Path(repository_absolute_path).as_uri() 328 | 329 | initialize_params = { 330 | "locale": "en", 331 | "rootPath": repository_absolute_path, 332 | "rootUri": pathlib.Path(repository_absolute_path).as_uri(), 333 | "capabilities": { 334 | "workspace": { 335 | "applyEdit": True, 336 | "workspaceEdit": { 337 | "documentChanges": True, 338 | "resourceOperations": ["create", "rename", "delete"], 339 | "failureHandling": "textOnlyTransactional", 340 | "normalizesLineEndings": True, 341 | "changeAnnotationSupport": {"groupsOnLabel": True}, 342 | }, 343 | "didChangeConfiguration": {"dynamicRegistration": True}, 344 | "didChangeWatchedFiles": {"dynamicRegistration": True, "relativePatternSupport": True}, 345 | "symbol": { 346 | "dynamicRegistration": True, 347 | "symbolKind": {"valueSet": list(range(1, 27))}, 348 | "tagSupport": {"valueSet": [1]}, 349 | "resolveSupport": {"properties": ["location.range"]}, 350 | }, 351 | "codeLens": {"refreshSupport": True}, 352 | "executeCommand": {"dynamicRegistration": True}, 353 | "configuration": True, 354 | "workspaceFolders": True, 355 | "semanticTokens": {"refreshSupport": True}, 356 | "fileOperations": { 357 | "dynamicRegistration": True, 358 | "didCreate": True, 359 | "didRename": True, 360 | "didDelete": True, 361 | "willCreate": True, 362 | "willRename": True, 363 | "willDelete": True, 364 | }, 365 | "inlineValue": {"refreshSupport": True}, 366 | "inlayHint": {"refreshSupport": True}, 367 | "diagnostics": {"refreshSupport": True}, 368 | }, 369 | "textDocument": { 370 | "publishDiagnostics": { 371 | "relatedInformation": True, 372 | "versionSupport": False, 373 | "tagSupport": {"valueSet": [1, 2]}, 374 | "codeDescriptionSupport": True, 375 | "dataSupport": True, 376 | }, 377 | "synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True}, 378 | # TODO: we have an assert that completion provider is not included in the capabilities at server startup 379 | # Removing this will cause the assert to fail. Investigate why this is the case, simplify config 380 | "completion": { 381 | "dynamicRegistration": True, 382 | "contextSupport": True, 383 | "completionItem": { 384 | "snippetSupport": False, 385 | "commitCharactersSupport": True, 386 | "documentationFormat": ["markdown", "plaintext"], 387 | "deprecatedSupport": True, 388 | "preselectSupport": True, 389 | "tagSupport": {"valueSet": [1]}, 390 | "insertReplaceSupport": False, 391 | "resolveSupport": {"properties": ["documentation", "detail", "additionalTextEdits"]}, 392 | "insertTextModeSupport": {"valueSet": [1, 2]}, 393 | "labelDetailsSupport": True, 394 | }, 395 | "insertTextMode": 2, 396 | "completionItemKind": { 397 | "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] 398 | }, 399 | "completionList": {"itemDefaults": ["commitCharacters", "editRange", "insertTextFormat", "insertTextMode"]}, 400 | }, 401 | "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, 402 | "signatureHelp": { 403 | "dynamicRegistration": True, 404 | "signatureInformation": { 405 | "documentationFormat": ["markdown", "plaintext"], 406 | "parameterInformation": {"labelOffsetSupport": True}, 407 | "activeParameterSupport": True, 408 | }, 409 | }, 410 | "definition": {"dynamicRegistration": True, "linkSupport": True}, 411 | "references": {"dynamicRegistration": True}, 412 | "documentSymbol": { 413 | "dynamicRegistration": True, 414 | "symbolKind": {"valueSet": list(range(1, 27))}, 415 | "hierarchicalDocumentSymbolSupport": True, 416 | "tagSupport": {"valueSet": [1]}, 417 | "labelSupport": True, 418 | }, 419 | "rename": { 420 | "dynamicRegistration": True, 421 | "prepareSupport": True, 422 | "prepareSupportDefaultBehavior": 1, 423 | "honorsChangeAnnotations": True, 424 | }, 425 | "documentLink": {"dynamicRegistration": True, "tooltipSupport": True}, 426 | "typeDefinition": {"dynamicRegistration": True, "linkSupport": True}, 427 | "implementation": {"dynamicRegistration": True, "linkSupport": True}, 428 | "colorProvider": {"dynamicRegistration": True}, 429 | "declaration": {"dynamicRegistration": True, "linkSupport": True}, 430 | "selectionRange": {"dynamicRegistration": True}, 431 | "callHierarchy": {"dynamicRegistration": True}, 432 | "semanticTokens": { 433 | "dynamicRegistration": True, 434 | "tokenTypes": [ 435 | "namespace", 436 | "type", 437 | "class", 438 | "enum", 439 | "interface", 440 | "struct", 441 | "typeParameter", 442 | "parameter", 443 | "variable", 444 | "property", 445 | "enumMember", 446 | "event", 447 | "function", 448 | "method", 449 | "macro", 450 | "keyword", 451 | "modifier", 452 | "comment", 453 | "string", 454 | "number", 455 | "regexp", 456 | "operator", 457 | "decorator", 458 | ], 459 | "tokenModifiers": [ 460 | "declaration", 461 | "definition", 462 | "readonly", 463 | "static", 464 | "deprecated", 465 | "abstract", 466 | "async", 467 | "modification", 468 | "documentation", 469 | "defaultLibrary", 470 | ], 471 | "formats": ["relative"], 472 | "requests": {"range": True, "full": {"delta": True}}, 473 | "multilineTokenSupport": False, 474 | "overlappingTokenSupport": False, 475 | "serverCancelSupport": True, 476 | "augmentsSyntaxTokens": True, 477 | }, 478 | "typeHierarchy": {"dynamicRegistration": True}, 479 | "inlineValue": {"dynamicRegistration": True}, 480 | "diagnostic": {"dynamicRegistration": True, "relatedDocumentSupport": False}, 481 | }, 482 | "general": { 483 | "staleRequestSupport": { 484 | "cancel": True, 485 | "retryOnContentModified": [ 486 | "textDocument/semanticTokens/full", 487 | "textDocument/semanticTokens/range", 488 | "textDocument/semanticTokens/full/delta", 489 | ], 490 | }, 491 | "regularExpressions": {"engine": "ECMAScript", "version": "ES2020"}, 492 | "positionEncodings": ["utf-16"], 493 | }, 494 | "notebookDocument": {"synchronization": {"dynamicRegistration": True, "executionSummarySupport": True}}, 495 | }, 496 | "initializationOptions": { 497 | "bundles": ["intellicode-core.jar"], 498 | "settings": { 499 | "java": { 500 | "home": None, 501 | "jdt": { 502 | "ls": { 503 | "java": {"home": None}, 504 | "vmargs": "-XX:+UseParallelGC -XX:GCTimeRatio=4 -XX:AdaptiveSizePolicyWeight=90 -Dsun.zip.disableMemoryMapping=true -Xmx1G -Xms100m -Xlog:disable", 505 | "lombokSupport": {"enabled": True}, 506 | "protobufSupport": {"enabled": True}, 507 | "androidSupport": {"enabled": True}, 508 | } 509 | }, 510 | "errors": {"incompleteClasspath": {"severity": "error"}}, 511 | "configuration": { 512 | "checkProjectSettingsExclusions": False, 513 | "updateBuildConfiguration": "interactive", 514 | "maven": { 515 | "userSettings": None, 516 | "globalSettings": None, 517 | "notCoveredPluginExecutionSeverity": "warning", 518 | "defaultMojoExecutionAction": "ignore", 519 | }, 520 | "workspaceCacheLimit": 90, 521 | "runtimes": [ 522 | {"name": "JavaSE-21", "path": "static/vscode-java/extension/jre/21.0.7-linux-x86_64", "default": True} 523 | ], 524 | }, 525 | "trace": {"server": "verbose"}, 526 | "import": { 527 | "maven": { 528 | "enabled": True, 529 | "offline": {"enabled": False}, 530 | "disableTestClasspathFlag": False, 531 | }, 532 | "gradle": { 533 | "enabled": True, 534 | "wrapper": {"enabled": False}, 535 | "version": None, 536 | "home": "abs(static/gradle-7.3.3)", 537 | "java": {"home": "abs(static/launch_jres/21.0.7-linux-x86_64)"}, 538 | "offline": {"enabled": False}, 539 | "arguments": None, 540 | "jvmArguments": None, 541 | "user": {"home": None}, 542 | "annotationProcessing": {"enabled": True}, 543 | }, 544 | "exclusions": [ 545 | "**/node_modules/**", 546 | "**/.metadata/**", 547 | "**/archetype-resources/**", 548 | "**/META-INF/maven/**", 549 | ], 550 | "generatesMetadataFilesAtProjectRoot": False, 551 | }, 552 | "maven": {"downloadSources": True, "updateSnapshots": True}, 553 | "eclipse": {"downloadSources": True}, 554 | "signatureHelp": {"enabled": True, "description": {"enabled": True}}, 555 | "implementationsCodeLens": {"enabled": True}, 556 | "format": { 557 | "enabled": True, 558 | "settings": {"url": None, "profile": None}, 559 | "comments": {"enabled": True}, 560 | "onType": {"enabled": True}, 561 | "insertSpaces": True, 562 | "tabSize": 4, 563 | }, 564 | "saveActions": {"organizeImports": False}, 565 | "project": { 566 | "referencedLibraries": ["lib/**/*.jar"], 567 | "importOnFirstTimeStartup": "automatic", 568 | "importHint": True, 569 | "resourceFilters": ["node_modules", "\\.git"], 570 | "encoding": "ignore", 571 | "exportJar": {"targetPath": "${workspaceFolder}/${workspaceFolderBasename}.jar"}, 572 | }, 573 | "contentProvider": {"preferred": None}, 574 | "autobuild": {"enabled": True}, 575 | "maxConcurrentBuilds": 1, 576 | "selectionRange": {"enabled": True}, 577 | "showBuildStatusOnStart": {"enabled": "notification"}, 578 | "server": {"launchMode": "Standard"}, 579 | "sources": {"organizeImports": {"starThreshold": 99, "staticStarThreshold": 99}}, 580 | "imports": {"gradle": {"wrapper": {"checksums": []}}}, 581 | "templates": {"fileHeader": [], "typeComment": []}, 582 | "references": {"includeAccessors": True, "includeDecompiledSources": True}, 583 | "typeHierarchy": {"lazyLoad": False}, 584 | "settings": {"url": None}, 585 | "symbols": {"includeSourceMethodDeclarations": False}, 586 | "inlayHints": {"parameterNames": {"enabled": "literals", "exclusions": []}}, 587 | "codeAction": {"sortMembers": {"avoidVolatileChanges": True}}, 588 | "compile": { 589 | "nullAnalysis": { 590 | "nonnull": [ 591 | "javax.annotation.Nonnull", 592 | "org.eclipse.jdt.annotation.NonNull", 593 | "org.springframework.lang.NonNull", 594 | ], 595 | "nullable": [ 596 | "javax.annotation.Nullable", 597 | "org.eclipse.jdt.annotation.Nullable", 598 | "org.springframework.lang.Nullable", 599 | ], 600 | "mode": "automatic", 601 | } 602 | }, 603 | "sharedIndexes": {"enabled": "auto", "location": ""}, 604 | "silentNotification": False, 605 | "dependency": { 606 | "showMembers": False, 607 | "syncWithFolderExplorer": True, 608 | "autoRefresh": True, 609 | "refreshDelay": 2000, 610 | "packagePresentation": "flat", 611 | }, 612 | "help": {"firstView": "auto", "showReleaseNotes": True, "collectErrorLog": False}, 613 | "test": {"defaultConfig": "", "config": {}}, 614 | } 615 | }, 616 | }, 617 | "trace": "verbose", 618 | "processId": os.getpid(), 619 | "workspaceFolders": [ 620 | { 621 | "uri": repo_uri, 622 | "name": os.path.basename(repository_absolute_path), 623 | } 624 | ], 625 | } 626 | 627 | initialize_params["initializationOptions"]["workspaceFolders"] = [repo_uri] 628 | bundles = [self.runtime_dependency_paths.intellicode_jar_path] 629 | initialize_params["initializationOptions"]["bundles"] = bundles 630 | initialize_params["initializationOptions"]["settings"]["java"]["configuration"]["runtimes"] = [ 631 | {"name": "JavaSE-21", "path": self.runtime_dependency_paths.jre_home_path, "default": True} 632 | ] 633 | 634 | for runtime in initialize_params["initializationOptions"]["settings"]["java"]["configuration"]["runtimes"]: 635 | assert "name" in runtime 636 | assert "path" in runtime 637 | assert os.path.exists(runtime["path"]), f"Runtime required for eclipse_jdtls at path {runtime['path']} does not exist" 638 | 639 | gradle_settings = initialize_params["initializationOptions"]["settings"]["java"]["import"]["gradle"] 640 | gradle_settings["home"] = self.runtime_dependency_paths.gradle_path 641 | gradle_settings["java"]["home"] = self.runtime_dependency_paths.jre_path 642 | return initialize_params 643 | 644 | def _start_server(self): 645 | """ 646 | Starts the Eclipse JDTLS Language Server 647 | """ 648 | 649 | def register_capability_handler(params): 650 | assert "registrations" in params 651 | for registration in params["registrations"]: 652 | if registration["method"] == "textDocument/completion": 653 | assert registration["registerOptions"]["resolveProvider"] == True 654 | assert registration["registerOptions"]["triggerCharacters"] == [ 655 | ".", 656 | "@", 657 | "#", 658 | "*", 659 | " ", 660 | ] 661 | self.completions_available.set() 662 | if registration["method"] == "workspace/executeCommand": 663 | if "java.intellicode.enable" in registration["registerOptions"]["commands"]: 664 | self.intellicode_enable_command_available.set() 665 | return 666 | 667 | def lang_status_handler(params): 668 | # TODO: Should we wait for 669 | # server -> client: {'jsonrpc': '2.0', 'method': 'language/status', 'params': {'type': 'ProjectStatus', 'message': 'OK'}} 670 | # Before proceeding? 671 | if params["type"] == "ServiceReady" and params["message"] == "ServiceReady": 672 | self.service_ready_event.set() 673 | 674 | def execute_client_command_handler(params): 675 | assert params["command"] == "_java.reloadBundles.command" 676 | assert params["arguments"] == [] 677 | return [] 678 | 679 | def window_log_message(msg): 680 | self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) 681 | 682 | def do_nothing(params): 683 | return 684 | 685 | self.server.on_request("client/registerCapability", register_capability_handler) 686 | self.server.on_notification("language/status", lang_status_handler) 687 | self.server.on_notification("window/logMessage", window_log_message) 688 | self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) 689 | self.server.on_notification("$/progress", do_nothing) 690 | self.server.on_notification("textDocument/publishDiagnostics", do_nothing) 691 | self.server.on_notification("language/actionableNotification", do_nothing) 692 | 693 | self.logger.log("Starting EclipseJDTLS server process", logging.INFO) 694 | self.server.start() 695 | initialize_params = self._get_initialize_params(self.repository_root_path) 696 | 697 | self.logger.log( 698 | "Sending initialize request from LSP client to LSP server and awaiting response", 699 | logging.INFO, 700 | ) 701 | init_response = self.server.send.initialize(initialize_params) 702 | assert init_response["capabilities"]["textDocumentSync"]["change"] == 2 703 | assert "completionProvider" not in init_response["capabilities"] 704 | assert "executeCommandProvider" not in init_response["capabilities"] 705 | 706 | self.server.notify.initialized({}) 707 | 708 | self.server.notify.workspace_did_change_configuration({"settings": initialize_params["initializationOptions"]["settings"]}) 709 | 710 | self.intellicode_enable_command_available.wait() 711 | 712 | java_intellisense_members_path = self.runtime_dependency_paths.intellisense_members_path 713 | assert os.path.exists(java_intellisense_members_path) 714 | intellicode_enable_result = self.server.send.execute_command( 715 | { 716 | "command": "java.intellicode.enable", 717 | "arguments": [True, java_intellisense_members_path], 718 | } 719 | ) 720 | assert intellicode_enable_result 721 | 722 | # TODO: Add comments about why we wait here, and how this can be optimized 723 | self.service_ready_event.wait() 724 | ``` -------------------------------------------------------------------------------- /src/serena/cli.py: -------------------------------------------------------------------------------- ```python 1 | import glob 2 | import json 3 | import os 4 | import shutil 5 | import subprocess 6 | import sys 7 | from logging import Logger 8 | from pathlib import Path 9 | from typing import Any, Literal 10 | 11 | import click 12 | from sensai.util import logging 13 | from sensai.util.logging import FileLoggerContext, datetime_tag 14 | from tqdm import tqdm 15 | 16 | from serena.agent import SerenaAgent 17 | from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode 18 | from serena.config.serena_config import ProjectConfig, SerenaConfig, SerenaPaths 19 | from serena.constants import ( 20 | DEFAULT_CONTEXT, 21 | DEFAULT_MODES, 22 | PROMPT_TEMPLATES_DIR_IN_USER_HOME, 23 | PROMPT_TEMPLATES_DIR_INTERNAL, 24 | SERENA_LOG_FORMAT, 25 | SERENA_MANAGED_DIR_IN_HOME, 26 | SERENAS_OWN_CONTEXT_YAMLS_DIR, 27 | SERENAS_OWN_MODE_YAMLS_DIR, 28 | USER_CONTEXT_YAMLS_DIR, 29 | USER_MODE_YAMLS_DIR, 30 | ) 31 | from serena.mcp import SerenaMCPFactory, SerenaMCPFactorySingleProcess 32 | from serena.project import Project 33 | from serena.tools import FindReferencingSymbolsTool, FindSymbolTool, GetSymbolsOverviewTool, SearchForPatternTool, ToolRegistry 34 | from serena.util.logging import MemoryLogHandler 35 | from solidlsp.ls_config import Language 36 | from solidlsp.util.subprocess_util import subprocess_kwargs 37 | 38 | log = logging.getLogger(__name__) 39 | 40 | # --------------------- Utilities ------------------------------------- 41 | 42 | 43 | def _open_in_editor(path: str) -> None: 44 | """Open the given file in the system's default editor or viewer.""" 45 | editor = os.environ.get("EDITOR") 46 | run_kwargs = subprocess_kwargs() 47 | try: 48 | if editor: 49 | subprocess.run([editor, path], check=False, **run_kwargs) 50 | elif sys.platform.startswith("win"): 51 | try: 52 | os.startfile(path) 53 | except OSError: 54 | subprocess.run(["notepad.exe", path], check=False, **run_kwargs) 55 | elif sys.platform == "darwin": 56 | subprocess.run(["open", path], check=False, **run_kwargs) 57 | else: 58 | subprocess.run(["xdg-open", path], check=False, **run_kwargs) 59 | except Exception as e: 60 | print(f"Failed to open {path}: {e}") 61 | 62 | 63 | class ProjectType(click.ParamType): 64 | """ParamType allowing either a project name or a path to a project directory.""" 65 | 66 | name = "[PROJECT_NAME|PROJECT_PATH]" 67 | 68 | def convert(self, value: str, param: Any, ctx: Any) -> str: 69 | path = Path(value).resolve() 70 | if path.exists() and path.is_dir(): 71 | return str(path) 72 | return value 73 | 74 | 75 | PROJECT_TYPE = ProjectType() 76 | 77 | 78 | class AutoRegisteringGroup(click.Group): 79 | """ 80 | A click.Group subclass that automatically registers any click.Command 81 | attributes defined on the class into the group. 82 | 83 | After initialization, it inspects its own class for attributes that are 84 | instances of click.Command (typically created via @click.command) and 85 | calls self.add_command(cmd) on each. This lets you define your commands 86 | as static methods on the subclass for IDE-friendly organization without 87 | manual registration. 88 | """ 89 | 90 | def __init__(self, name: str, help: str): 91 | super().__init__(name=name, help=help) 92 | # Scan class attributes for click.Command instances and register them. 93 | for attr in dir(self.__class__): 94 | cmd = getattr(self.__class__, attr) 95 | if isinstance(cmd, click.Command): 96 | self.add_command(cmd) 97 | 98 | 99 | class TopLevelCommands(AutoRegisteringGroup): 100 | """Root CLI group containing the core Serena commands.""" 101 | 102 | def __init__(self) -> None: 103 | super().__init__(name="serena", help="Serena CLI commands. You can run `<command> --help` for more info on each command.") 104 | 105 | @staticmethod 106 | @click.command("start-mcp-server", help="Starts the Serena MCP server.") 107 | @click.option("--project", "project", type=PROJECT_TYPE, default=None, help="Path or name of project to activate at startup.") 108 | @click.option("--project-file", "project", type=PROJECT_TYPE, default=None, help="[DEPRECATED] Use --project instead.") 109 | @click.argument("project_file_arg", type=PROJECT_TYPE, required=False, default=None, metavar="") 110 | @click.option( 111 | "--context", type=str, default=DEFAULT_CONTEXT, show_default=True, help="Built-in context name or path to custom context YAML." 112 | ) 113 | @click.option( 114 | "--mode", 115 | "modes", 116 | type=str, 117 | multiple=True, 118 | default=DEFAULT_MODES, 119 | show_default=True, 120 | help="Built-in mode names or paths to custom mode YAMLs.", 121 | ) 122 | @click.option( 123 | "--transport", 124 | type=click.Choice(["stdio", "sse", "streamable-http"]), 125 | default="stdio", 126 | show_default=True, 127 | help="Transport protocol.", 128 | ) 129 | @click.option("--host", type=str, default="0.0.0.0", show_default=True) 130 | @click.option("--port", type=int, default=8000, show_default=True) 131 | @click.option("--enable-web-dashboard", type=bool, is_flag=False, default=None, help="Override dashboard setting in config.") 132 | @click.option("--enable-gui-log-window", type=bool, is_flag=False, default=None, help="Override GUI log window setting in config.") 133 | @click.option( 134 | "--log-level", 135 | type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]), 136 | default=None, 137 | help="Override log level in config.", 138 | ) 139 | @click.option("--trace-lsp-communication", type=bool, is_flag=False, default=None, help="Whether to trace LSP communication.") 140 | @click.option("--tool-timeout", type=float, default=None, help="Override tool execution timeout in config.") 141 | def start_mcp_server( 142 | project: str | None, 143 | project_file_arg: str | None, 144 | context: str, 145 | modes: tuple[str, ...], 146 | transport: Literal["stdio", "sse", "streamable-http"], 147 | host: str, 148 | port: int, 149 | enable_web_dashboard: bool | None, 150 | enable_gui_log_window: bool | None, 151 | log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None, 152 | trace_lsp_communication: bool | None, 153 | tool_timeout: float | None, 154 | ) -> None: 155 | # initialize logging, using INFO level initially (will later be adjusted by SerenaAgent according to the config) 156 | # * memory log handler (for use by GUI/Dashboard) 157 | # * stream handler for stderr (for direct console output, which will also be captured by clients like Claude Desktop) 158 | # * file handler 159 | # (Note that stdout must never be used for logging, as it is used by the MCP server to communicate with the client.) 160 | Logger.root.setLevel(logging.INFO) 161 | formatter = logging.Formatter(SERENA_LOG_FORMAT) 162 | memory_log_handler = MemoryLogHandler() 163 | Logger.root.addHandler(memory_log_handler) 164 | stderr_handler = logging.StreamHandler(stream=sys.stderr) 165 | stderr_handler.formatter = formatter 166 | Logger.root.addHandler(stderr_handler) 167 | log_path = SerenaPaths().get_next_log_file_path("mcp") 168 | file_handler = logging.FileHandler(log_path, mode="w") 169 | file_handler.formatter = formatter 170 | Logger.root.addHandler(file_handler) 171 | 172 | log.info("Initializing Serena MCP server") 173 | log.info("Storing logs in %s", log_path) 174 | project_file = project_file_arg or project 175 | factory = SerenaMCPFactorySingleProcess(context=context, project=project_file, memory_log_handler=memory_log_handler) 176 | server = factory.create_mcp_server( 177 | host=host, 178 | port=port, 179 | modes=modes, 180 | enable_web_dashboard=enable_web_dashboard, 181 | enable_gui_log_window=enable_gui_log_window, 182 | log_level=log_level, 183 | trace_lsp_communication=trace_lsp_communication, 184 | tool_timeout=tool_timeout, 185 | ) 186 | if project_file_arg: 187 | log.warning( 188 | "Positional project arg is deprecated; use --project instead. Used: %s", 189 | project_file, 190 | ) 191 | log.info("Starting MCP server …") 192 | server.run(transport=transport) 193 | 194 | @staticmethod 195 | @click.command("print-system-prompt", help="Print the system prompt for a project.") 196 | @click.argument("project", type=click.Path(exists=True), default=os.getcwd(), required=False) 197 | @click.option( 198 | "--log-level", 199 | type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]), 200 | default="WARNING", 201 | help="Log level for prompt generation.", 202 | ) 203 | @click.option("--only-instructions", is_flag=True, help="Print only the initial instructions, without prefix/postfix.") 204 | @click.option( 205 | "--context", type=str, default=DEFAULT_CONTEXT, show_default=True, help="Built-in context name or path to custom context YAML." 206 | ) 207 | @click.option( 208 | "--mode", 209 | "modes", 210 | type=str, 211 | multiple=True, 212 | default=DEFAULT_MODES, 213 | show_default=True, 214 | help="Built-in mode names or paths to custom mode YAMLs.", 215 | ) 216 | def print_system_prompt(project: str, log_level: str, only_instructions: bool, context: str, modes: tuple[str, ...]) -> None: 217 | prefix = "You will receive access to Serena's symbolic tools. Below are instructions for using them, take them into account." 218 | postfix = "You begin by acknowledging that you understood the above instructions and are ready to receive tasks." 219 | from serena.tools.workflow_tools import InitialInstructionsTool 220 | 221 | lvl = logging.getLevelNamesMapping()[log_level.upper()] 222 | logging.configure(level=lvl) 223 | context_instance = SerenaAgentContext.load(context) 224 | mode_instances = [SerenaAgentMode.load(mode) for mode in modes] 225 | agent = SerenaAgent( 226 | project=os.path.abspath(project), 227 | serena_config=SerenaConfig(web_dashboard=False, log_level=lvl), 228 | context=context_instance, 229 | modes=mode_instances, 230 | ) 231 | tool = agent.get_tool(InitialInstructionsTool) 232 | instr = tool.apply() 233 | if only_instructions: 234 | print(instr) 235 | else: 236 | print(f"{prefix}\n{instr}\n{postfix}") 237 | 238 | 239 | class ModeCommands(AutoRegisteringGroup): 240 | """Group for 'mode' subcommands.""" 241 | 242 | def __init__(self) -> None: 243 | super().__init__(name="mode", help="Manage Serena modes. You can run `mode <command> --help` for more info on each command.") 244 | 245 | @staticmethod 246 | @click.command("list", help="List available modes.") 247 | def list() -> None: 248 | mode_names = SerenaAgentMode.list_registered_mode_names() 249 | max_len_name = max(len(name) for name in mode_names) if mode_names else 20 250 | for name in mode_names: 251 | mode_yml_path = SerenaAgentMode.get_path(name) 252 | is_internal = Path(mode_yml_path).is_relative_to(SERENAS_OWN_MODE_YAMLS_DIR) 253 | descriptor = "(internal)" if is_internal else f"(at {mode_yml_path})" 254 | name_descr_string = f"{name:<{max_len_name + 4}}{descriptor}" 255 | click.echo(name_descr_string) 256 | 257 | @staticmethod 258 | @click.command("create", help="Create a new mode or copy an internal one.") 259 | @click.option( 260 | "--name", 261 | "-n", 262 | type=str, 263 | default=None, 264 | help="Name for the new mode. If --from-internal is passed may be left empty to create a mode of the same name, which will then override the internal mode.", 265 | ) 266 | @click.option("--from-internal", "from_internal", type=str, default=None, help="Copy from an internal mode.") 267 | def create(name: str, from_internal: str) -> None: 268 | if not (name or from_internal): 269 | raise click.UsageError("Provide at least one of --name or --from-internal.") 270 | mode_name = name or from_internal 271 | dest = os.path.join(USER_MODE_YAMLS_DIR, f"{mode_name}.yml") 272 | src = ( 273 | os.path.join(SERENAS_OWN_MODE_YAMLS_DIR, f"{from_internal}.yml") 274 | if from_internal 275 | else os.path.join(SERENAS_OWN_MODE_YAMLS_DIR, "mode.template.yml") 276 | ) 277 | if not os.path.exists(src): 278 | raise FileNotFoundError( 279 | f"Internal mode '{from_internal}' not found in {SERENAS_OWN_MODE_YAMLS_DIR}. Available modes: {SerenaAgentMode.list_registered_mode_names()}" 280 | ) 281 | os.makedirs(os.path.dirname(dest), exist_ok=True) 282 | shutil.copyfile(src, dest) 283 | click.echo(f"Created mode '{mode_name}' at {dest}") 284 | _open_in_editor(dest) 285 | 286 | @staticmethod 287 | @click.command("edit", help="Edit a custom mode YAML file.") 288 | @click.argument("mode_name") 289 | def edit(mode_name: str) -> None: 290 | path = os.path.join(USER_MODE_YAMLS_DIR, f"{mode_name}.yml") 291 | if not os.path.exists(path): 292 | if mode_name in SerenaAgentMode.list_registered_mode_names(include_user_modes=False): 293 | click.echo( 294 | f"Mode '{mode_name}' is an internal mode and cannot be edited directly. " 295 | f"Use 'mode create --from-internal {mode_name}' to create a custom mode that overrides it before editing." 296 | ) 297 | else: 298 | click.echo(f"Custom mode '{mode_name}' not found. Create it with: mode create --name {mode_name}.") 299 | return 300 | _open_in_editor(path) 301 | 302 | @staticmethod 303 | @click.command("delete", help="Delete a custom mode file.") 304 | @click.argument("mode_name") 305 | def delete(mode_name: str) -> None: 306 | path = os.path.join(USER_MODE_YAMLS_DIR, f"{mode_name}.yml") 307 | if not os.path.exists(path): 308 | click.echo(f"Custom mode '{mode_name}' not found.") 309 | return 310 | os.remove(path) 311 | click.echo(f"Deleted custom mode '{mode_name}'.") 312 | 313 | 314 | class ContextCommands(AutoRegisteringGroup): 315 | """Group for 'context' subcommands.""" 316 | 317 | def __init__(self) -> None: 318 | super().__init__( 319 | name="context", help="Manage Serena contexts. You can run `context <command> --help` for more info on each command." 320 | ) 321 | 322 | @staticmethod 323 | @click.command("list", help="List available contexts.") 324 | def list() -> None: 325 | context_names = SerenaAgentContext.list_registered_context_names() 326 | max_len_name = max(len(name) for name in context_names) if context_names else 20 327 | for name in context_names: 328 | context_yml_path = SerenaAgentContext.get_path(name) 329 | is_internal = Path(context_yml_path).is_relative_to(SERENAS_OWN_CONTEXT_YAMLS_DIR) 330 | descriptor = "(internal)" if is_internal else f"(at {context_yml_path})" 331 | name_descr_string = f"{name:<{max_len_name + 4}}{descriptor}" 332 | click.echo(name_descr_string) 333 | 334 | @staticmethod 335 | @click.command("create", help="Create a new context or copy an internal one.") 336 | @click.option( 337 | "--name", 338 | "-n", 339 | type=str, 340 | default=None, 341 | help="Name for the new context. If --from-internal is passed may be left empty to create a context of the same name, which will then override the internal context", 342 | ) 343 | @click.option("--from-internal", "from_internal", type=str, default=None, help="Copy from an internal context.") 344 | def create(name: str, from_internal: str) -> None: 345 | if not (name or from_internal): 346 | raise click.UsageError("Provide at least one of --name or --from-internal.") 347 | ctx_name = name or from_internal 348 | dest = os.path.join(USER_CONTEXT_YAMLS_DIR, f"{ctx_name}.yml") 349 | src = ( 350 | os.path.join(SERENAS_OWN_CONTEXT_YAMLS_DIR, f"{from_internal}.yml") 351 | if from_internal 352 | else os.path.join(SERENAS_OWN_CONTEXT_YAMLS_DIR, "context.template.yml") 353 | ) 354 | if not os.path.exists(src): 355 | raise FileNotFoundError( 356 | f"Internal context '{from_internal}' not found in {SERENAS_OWN_CONTEXT_YAMLS_DIR}. Available contexts: {SerenaAgentContext.list_registered_context_names()}" 357 | ) 358 | os.makedirs(os.path.dirname(dest), exist_ok=True) 359 | shutil.copyfile(src, dest) 360 | click.echo(f"Created context '{ctx_name}' at {dest}") 361 | _open_in_editor(dest) 362 | 363 | @staticmethod 364 | @click.command("edit", help="Edit a custom context YAML file.") 365 | @click.argument("context_name") 366 | def edit(context_name: str) -> None: 367 | path = os.path.join(USER_CONTEXT_YAMLS_DIR, f"{context_name}.yml") 368 | if not os.path.exists(path): 369 | if context_name in SerenaAgentContext.list_registered_context_names(include_user_contexts=False): 370 | click.echo( 371 | f"Context '{context_name}' is an internal context and cannot be edited directly. " 372 | f"Use 'context create --from-internal {context_name}' to create a custom context that overrides it before editing." 373 | ) 374 | else: 375 | click.echo(f"Custom context '{context_name}' not found. Create it with: context create --name {context_name}.") 376 | return 377 | _open_in_editor(path) 378 | 379 | @staticmethod 380 | @click.command("delete", help="Delete a custom context file.") 381 | @click.argument("context_name") 382 | def delete(context_name: str) -> None: 383 | path = os.path.join(USER_CONTEXT_YAMLS_DIR, f"{context_name}.yml") 384 | if not os.path.exists(path): 385 | click.echo(f"Custom context '{context_name}' not found.") 386 | return 387 | os.remove(path) 388 | click.echo(f"Deleted custom context '{context_name}'.") 389 | 390 | 391 | class SerenaConfigCommands(AutoRegisteringGroup): 392 | """Group for 'config' subcommands.""" 393 | 394 | def __init__(self) -> None: 395 | super().__init__(name="config", help="Manage Serena configuration.") 396 | 397 | @staticmethod 398 | @click.command( 399 | "edit", help="Edit serena_config.yml in your default editor. Will create a config file from the template if no config is found." 400 | ) 401 | def edit() -> None: 402 | config_path = os.path.join(SERENA_MANAGED_DIR_IN_HOME, "serena_config.yml") 403 | if not os.path.exists(config_path): 404 | SerenaConfig.generate_config_file(config_path) 405 | _open_in_editor(config_path) 406 | 407 | 408 | class ProjectCommands(AutoRegisteringGroup): 409 | """Group for 'project' subcommands.""" 410 | 411 | def __init__(self) -> None: 412 | super().__init__( 413 | name="project", help="Manage Serena projects. You can run `project <command> --help` for more info on each command." 414 | ) 415 | 416 | @staticmethod 417 | @click.command("generate-yml", help="Generate a project.yml file.") 418 | @click.argument("project_path", type=click.Path(exists=True, file_okay=False), default=os.getcwd()) 419 | @click.option("--language", type=str, default=None, help="Programming language; inferred if not specified.") 420 | def generate_yml(project_path: str, language: str | None = None) -> None: 421 | yml_path = os.path.join(project_path, ProjectConfig.rel_path_to_project_yml()) 422 | if os.path.exists(yml_path): 423 | raise FileExistsError(f"Project file {yml_path} already exists.") 424 | lang_inst = None 425 | if language: 426 | try: 427 | lang_inst = Language[language.upper()] 428 | except KeyError: 429 | all_langs = [l.name.lower() for l in Language.iter_all(include_experimental=True)] 430 | raise ValueError(f"Unknown language '{language}'. Supported: {all_langs}") 431 | generated_conf = ProjectConfig.autogenerate(project_root=project_path, project_language=lang_inst) 432 | print(f"Generated project.yml with language {generated_conf.language.value} at {yml_path}.") 433 | 434 | @staticmethod 435 | @click.command("index", help="Index a project by saving symbols to the LSP cache.") 436 | @click.argument("project", type=click.Path(exists=True), default=os.getcwd(), required=False) 437 | @click.option( 438 | "--log-level", 439 | type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]), 440 | default="WARNING", 441 | help="Log level for indexing.", 442 | ) 443 | @click.option("--timeout", type=float, default=10, help="Timeout for indexing a single file.") 444 | def index(project: str, log_level: str, timeout: float) -> None: 445 | ProjectCommands._index_project(project, log_level, timeout=timeout) 446 | 447 | @staticmethod 448 | @click.command("index-deprecated", help="Deprecated alias for 'serena project index'.") 449 | @click.argument("project", type=click.Path(exists=True), default=os.getcwd(), required=False) 450 | @click.option("--log-level", type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]), default="WARNING") 451 | @click.option("--timeout", type=float, default=10, help="Timeout for indexing a single file.") 452 | def index_deprecated(project: str, log_level: str, timeout: float) -> None: 453 | click.echo("Deprecated! Use `serena project index` instead.") 454 | ProjectCommands._index_project(project, log_level, timeout=timeout) 455 | 456 | @staticmethod 457 | def _index_project(project: str, log_level: str, timeout: float) -> None: 458 | lvl = logging.getLevelNamesMapping()[log_level.upper()] 459 | logging.configure(level=lvl) 460 | serena_config = SerenaConfig.from_config_file() 461 | proj = Project.load(os.path.abspath(project)) 462 | click.echo(f"Indexing symbols in project {project}…") 463 | ls = proj.create_language_server(log_level=lvl, ls_timeout=timeout, ls_specific_settings=serena_config.ls_specific_settings) 464 | log_file = os.path.join(project, ".serena", "logs", "indexing.txt") 465 | 466 | collected_exceptions: list[Exception] = [] 467 | files_failed = [] 468 | with ls.start_server(): 469 | files = proj.gather_source_files() 470 | for i, f in enumerate(tqdm(files, desc="Indexing")): 471 | try: 472 | ls.request_document_symbols(f, include_body=False) 473 | ls.request_document_symbols(f, include_body=True) 474 | except Exception as e: 475 | log.error(f"Failed to index {f}, continuing.") 476 | collected_exceptions.append(e) 477 | files_failed.append(f) 478 | if (i + 1) % 10 == 0: 479 | ls.save_cache() 480 | ls.save_cache() 481 | click.echo(f"Symbols saved to {ls.cache_path}") 482 | if len(files_failed) > 0: 483 | os.makedirs(os.path.dirname(log_file), exist_ok=True) 484 | with open(log_file, "w") as f: 485 | for file, exception in zip(files_failed, collected_exceptions, strict=True): 486 | f.write(f"{file}\n") 487 | f.write(f"{exception}\n") 488 | click.echo(f"Failed to index {len(files_failed)} files, see:\n{log_file}") 489 | 490 | @staticmethod 491 | @click.command("is_ignored_path", help="Check if a path is ignored by the project configuration.") 492 | @click.argument("path", type=click.Path(exists=False, file_okay=True, dir_okay=True)) 493 | @click.argument("project", type=click.Path(exists=True, file_okay=False, dir_okay=True), default=os.getcwd()) 494 | def is_ignored_path(path: str, project: str) -> None: 495 | """ 496 | Check if a given path is ignored by the project configuration. 497 | 498 | :param path: The path to check. 499 | :param project: The path to the project directory, defaults to the current working directory. 500 | """ 501 | proj = Project.load(os.path.abspath(project)) 502 | if os.path.isabs(path): 503 | path = os.path.relpath(path, start=proj.project_root) 504 | is_ignored = proj.is_ignored_path(path) 505 | click.echo(f"Path '{path}' IS {'ignored' if is_ignored else 'IS NOT ignored'} by the project configuration.") 506 | 507 | @staticmethod 508 | @click.command("index-file", help="Index a single file by saving its symbols to the LSP cache.") 509 | @click.argument("file", type=click.Path(exists=True, file_okay=True, dir_okay=False)) 510 | @click.argument("project", type=click.Path(exists=True, file_okay=False, dir_okay=True), default=os.getcwd()) 511 | @click.option("--verbose", "-v", is_flag=True, help="Print detailed information about the indexed symbols.") 512 | def index_file(file: str, project: str, verbose: bool) -> None: 513 | """ 514 | Index a single file by saving its symbols to the LSP cache, useful for debugging. 515 | :param file: path to the file to index, must be inside the project directory. 516 | :param project: path to the project directory, defaults to the current working directory. 517 | :param verbose: if set, prints detailed information about the indexed symbols. 518 | """ 519 | proj = Project.load(os.path.abspath(project)) 520 | if os.path.isabs(file): 521 | file = os.path.relpath(file, start=proj.project_root) 522 | if proj.is_ignored_path(file, ignore_non_source_files=True): 523 | click.echo(f"'{file}' is ignored or declared as non-code file by the project configuration, won't index.") 524 | exit(1) 525 | ls = proj.create_language_server() 526 | with ls.start_server(): 527 | symbols, _ = ls.request_document_symbols(file, include_body=False) 528 | ls.request_document_symbols(file, include_body=True) 529 | if verbose: 530 | click.echo(f"Symbols in file '{file}':") 531 | for symbol in symbols: 532 | click.echo(f" - {symbol['name']} at line {symbol['selectionRange']['start']['line']} of kind {symbol['kind']}") 533 | ls.save_cache() 534 | click.echo(f"Successfully indexed file '{file}', {len(symbols)} symbols saved to {ls.cache_path}.") 535 | 536 | @staticmethod 537 | @click.command("health-check", help="Perform a comprehensive health check of the project's tools and language server.") 538 | @click.argument("project", type=click.Path(exists=True, file_okay=False, dir_okay=True), default=os.getcwd()) 539 | def health_check(project: str) -> None: 540 | """ 541 | Perform a comprehensive health check of the project's tools and language server. 542 | 543 | :param project: path to the project directory, defaults to the current working directory. 544 | """ 545 | # NOTE: completely written by Claude Code, only functionality was reviewed, not implementation 546 | logging.configure(level=logging.INFO) 547 | project_path = os.path.abspath(project) 548 | proj = Project.load(project_path) 549 | 550 | # Create log file with timestamp 551 | timestamp = datetime_tag() 552 | log_dir = os.path.join(project_path, ".serena", "logs", "health-checks") 553 | os.makedirs(log_dir, exist_ok=True) 554 | log_file = os.path.join(log_dir, f"health_check_{timestamp}.log") 555 | 556 | with FileLoggerContext(log_file, append=False, enabled=True): 557 | log.info("Starting health check for project: %s", project_path) 558 | 559 | try: 560 | # Create SerenaAgent with dashboard disabled 561 | log.info("Creating SerenaAgent with disabled dashboard...") 562 | config = SerenaConfig(gui_log_window_enabled=False, web_dashboard=False) 563 | agent = SerenaAgent(project=project_path, serena_config=config) 564 | log.info("SerenaAgent created successfully") 565 | 566 | # Find first non-empty file that can be analyzed 567 | log.info("Searching for analyzable files...") 568 | files = proj.gather_source_files() 569 | target_file = None 570 | 571 | for file_path in files: 572 | try: 573 | full_path = os.path.join(project_path, file_path) 574 | if os.path.getsize(full_path) > 0: 575 | target_file = file_path 576 | log.info("Found analyzable file: %s", target_file) 577 | break 578 | except (OSError, FileNotFoundError): 579 | continue 580 | 581 | if not target_file: 582 | log.error("No analyzable files found in project") 583 | click.echo("❌ Health check failed: No analyzable files found") 584 | click.echo(f"Log saved to: {log_file}") 585 | return 586 | 587 | # Get tools from agent 588 | overview_tool = agent.get_tool(GetSymbolsOverviewTool) 589 | find_symbol_tool = agent.get_tool(FindSymbolTool) 590 | find_refs_tool = agent.get_tool(FindReferencingSymbolsTool) 591 | search_pattern_tool = agent.get_tool(SearchForPatternTool) 592 | 593 | # Test 1: Get symbols overview 594 | log.info("Testing GetSymbolsOverviewTool on file: %s", target_file) 595 | overview_result = agent.execute_task(lambda: overview_tool.apply(target_file)) 596 | overview_data = json.loads(overview_result) 597 | log.info("GetSymbolsOverviewTool returned %d symbols", len(overview_data)) 598 | 599 | if not overview_data: 600 | log.error("No symbols found in file %s", target_file) 601 | click.echo("❌ Health check failed: No symbols found in target file") 602 | click.echo(f"Log saved to: {log_file}") 603 | return 604 | 605 | # Extract suitable symbol (prefer class or function over variables) 606 | # LSP symbol kinds: 5=class, 12=function, 6=method, 9=constructor 607 | preferred_kinds = [5, 12, 6, 9] # class, function, method, constructor 608 | 609 | selected_symbol = None 610 | for symbol in overview_data: 611 | if symbol.get("kind") in preferred_kinds: 612 | selected_symbol = symbol 613 | break 614 | 615 | # If no preferred symbol found, use first available 616 | if not selected_symbol: 617 | selected_symbol = overview_data[0] 618 | log.info("No class or function found, using first available symbol") 619 | 620 | symbol_name = selected_symbol.get("name_path", "unknown") 621 | symbol_kind = selected_symbol.get("kind", "unknown") 622 | log.info("Using symbol for testing: %s (kind: %d)", symbol_name, symbol_kind) 623 | 624 | # Test 2: FindSymbolTool 625 | log.info("Testing FindSymbolTool for symbol: %s", symbol_name) 626 | find_symbol_result = agent.execute_task( 627 | lambda: find_symbol_tool.apply(symbol_name, relative_path=target_file, include_body=True) 628 | ) 629 | find_symbol_data = json.loads(find_symbol_result) 630 | log.info("FindSymbolTool found %d matches for symbol %s", len(find_symbol_data), symbol_name) 631 | 632 | # Test 3: FindReferencingSymbolsTool 633 | log.info("Testing FindReferencingSymbolsTool for symbol: %s", symbol_name) 634 | try: 635 | find_refs_result = agent.execute_task(lambda: find_refs_tool.apply(symbol_name, relative_path=target_file)) 636 | find_refs_data = json.loads(find_refs_result) 637 | log.info("FindReferencingSymbolsTool found %d references for symbol %s", len(find_refs_data), symbol_name) 638 | except Exception as e: 639 | log.warning("FindReferencingSymbolsTool failed for symbol %s: %s", symbol_name, str(e)) 640 | find_refs_data = [] 641 | 642 | # Test 4: SearchForPatternTool to verify references 643 | log.info("Testing SearchForPatternTool for pattern: %s", symbol_name) 644 | try: 645 | search_result = agent.execute_task( 646 | lambda: search_pattern_tool.apply(substring_pattern=symbol_name, restrict_search_to_code_files=True) 647 | ) 648 | search_data = json.loads(search_result) 649 | pattern_matches = sum(len(matches) for matches in search_data.values()) 650 | log.info("SearchForPatternTool found %d pattern matches for %s", pattern_matches, symbol_name) 651 | except Exception as e: 652 | log.warning("SearchForPatternTool failed for pattern %s: %s", symbol_name, str(e)) 653 | pattern_matches = 0 654 | 655 | # Verify tools worked as expected 656 | tools_working = True 657 | if not find_symbol_data: 658 | log.error("FindSymbolTool returned no results") 659 | tools_working = False 660 | 661 | if len(find_refs_data) == 0 and pattern_matches == 0: 662 | log.warning("Both FindReferencingSymbolsTool and SearchForPatternTool found no matches - this might indicate an issue") 663 | 664 | log.info("Health check completed successfully") 665 | 666 | if tools_working: 667 | click.echo("✅ Health check passed - All tools working correctly") 668 | else: 669 | click.echo("⚠️ Health check completed with warnings - Check log for details") 670 | 671 | except Exception as e: 672 | log.exception("Health check failed with exception: %s", str(e)) 673 | click.echo(f"❌ Health check failed: {e!s}") 674 | 675 | finally: 676 | click.echo(f"Log saved to: {log_file}") 677 | 678 | 679 | class ToolCommands(AutoRegisteringGroup): 680 | """Group for 'tool' subcommands.""" 681 | 682 | def __init__(self) -> None: 683 | super().__init__( 684 | name="tools", 685 | help="Commands related to Serena's tools. You can run `serena tools <command> --help` for more info on each command.", 686 | ) 687 | 688 | @staticmethod 689 | @click.command( 690 | "list", 691 | help="Prints an overview of the tools that are active by default (not just the active ones for your project). For viewing all tools, pass `--all / -a`", 692 | ) 693 | @click.option("--quiet", "-q", is_flag=True) 694 | @click.option("--all", "-a", "include_optional", is_flag=True, help="List all tools, including those not enabled by default.") 695 | @click.option("--only-optional", is_flag=True, help="List only optional tools (those not enabled by default).") 696 | def list(quiet: bool = False, include_optional: bool = False, only_optional: bool = False) -> None: 697 | tool_registry = ToolRegistry() 698 | if quiet: 699 | if only_optional: 700 | tool_names = tool_registry.get_tool_names_optional() 701 | elif include_optional: 702 | tool_names = tool_registry.get_tool_names() 703 | else: 704 | tool_names = tool_registry.get_tool_names_default_enabled() 705 | for tool_name in tool_names: 706 | click.echo(tool_name) 707 | else: 708 | ToolRegistry().print_tool_overview(include_optional=include_optional, only_optional=only_optional) 709 | 710 | @staticmethod 711 | @click.command( 712 | "description", 713 | help="Print the description of a tool, optionally with a specific context (the latter may modify the default description).", 714 | ) 715 | @click.argument("tool_name", type=str) 716 | @click.option("--context", type=str, default=None, help="Context name or path to context file.") 717 | def description(tool_name: str, context: str | None = None) -> None: 718 | # Load the context 719 | serena_context = None 720 | if context: 721 | serena_context = SerenaAgentContext.load(context) 722 | 723 | agent = SerenaAgent( 724 | project=None, 725 | serena_config=SerenaConfig(web_dashboard=False, log_level=logging.INFO), 726 | context=serena_context, 727 | ) 728 | tool = agent.get_tool_by_name(tool_name) 729 | mcp_tool = SerenaMCPFactory.make_mcp_tool(tool) 730 | click.echo(mcp_tool.description) 731 | 732 | 733 | class PromptCommands(AutoRegisteringGroup): 734 | def __init__(self) -> None: 735 | super().__init__(name="prompts", help="Commands related to Serena's prompts that are outside of contexts and modes.") 736 | 737 | @staticmethod 738 | def _get_user_prompt_yaml_path(prompt_yaml_name: str) -> str: 739 | os.makedirs(PROMPT_TEMPLATES_DIR_IN_USER_HOME, exist_ok=True) 740 | return os.path.join(PROMPT_TEMPLATES_DIR_IN_USER_HOME, prompt_yaml_name) 741 | 742 | @staticmethod 743 | @click.command("list", help="Lists yamls that are used for defining prompts.") 744 | def list() -> None: 745 | serena_prompt_yaml_names = [os.path.basename(f) for f in glob.glob(PROMPT_TEMPLATES_DIR_INTERNAL + "/*.yml")] 746 | for prompt_yaml_name in serena_prompt_yaml_names: 747 | user_prompt_yaml_path = PromptCommands._get_user_prompt_yaml_path(prompt_yaml_name) 748 | if os.path.exists(user_prompt_yaml_path): 749 | click.echo(f"{user_prompt_yaml_path} merged with default prompts in {prompt_yaml_name}") 750 | else: 751 | click.echo(prompt_yaml_name) 752 | 753 | @staticmethod 754 | @click.command("create-override", help="Create an override of an internal prompts yaml for customizing Serena's prompts") 755 | @click.argument("prompt_yaml_name") 756 | def create_override(prompt_yaml_name: str) -> None: 757 | """ 758 | :param prompt_yaml_name: The yaml name of the prompt you want to override. Call the `list` command for discovering valid prompt yaml names. 759 | :return: 760 | """ 761 | # for convenience, we can pass names without .yml 762 | if not prompt_yaml_name.endswith(".yml"): 763 | prompt_yaml_name = prompt_yaml_name + ".yml" 764 | user_prompt_yaml_path = PromptCommands._get_user_prompt_yaml_path(prompt_yaml_name) 765 | if os.path.exists(user_prompt_yaml_path): 766 | raise FileExistsError(f"{user_prompt_yaml_path} already exists.") 767 | serena_prompt_yaml_path = os.path.join(PROMPT_TEMPLATES_DIR_INTERNAL, prompt_yaml_name) 768 | shutil.copyfile(serena_prompt_yaml_path, user_prompt_yaml_path) 769 | _open_in_editor(user_prompt_yaml_path) 770 | 771 | @staticmethod 772 | @click.command("edit-override", help="Edit an existing prompt override file") 773 | @click.argument("prompt_yaml_name") 774 | def edit_override(prompt_yaml_name: str) -> None: 775 | """ 776 | :param prompt_yaml_name: The yaml name of the prompt override to edit. 777 | :return: 778 | """ 779 | # for convenience, we can pass names without .yml 780 | if not prompt_yaml_name.endswith(".yml"): 781 | prompt_yaml_name = prompt_yaml_name + ".yml" 782 | user_prompt_yaml_path = PromptCommands._get_user_prompt_yaml_path(prompt_yaml_name) 783 | if not os.path.exists(user_prompt_yaml_path): 784 | click.echo(f"Override file '{prompt_yaml_name}' not found. Create it with: prompts create-override {prompt_yaml_name}") 785 | return 786 | _open_in_editor(user_prompt_yaml_path) 787 | 788 | @staticmethod 789 | @click.command("list-overrides", help="List existing prompt override files") 790 | def list_overrides() -> None: 791 | os.makedirs(PROMPT_TEMPLATES_DIR_IN_USER_HOME, exist_ok=True) 792 | serena_prompt_yaml_names = [os.path.basename(f) for f in glob.glob(PROMPT_TEMPLATES_DIR_INTERNAL + "/*.yml")] 793 | override_files = glob.glob(os.path.join(PROMPT_TEMPLATES_DIR_IN_USER_HOME, "*.yml")) 794 | for file_path in override_files: 795 | if os.path.basename(file_path) in serena_prompt_yaml_names: 796 | click.echo(file_path) 797 | 798 | @staticmethod 799 | @click.command("delete-override", help="Delete a prompt override file") 800 | @click.argument("prompt_yaml_name") 801 | def delete_override(prompt_yaml_name: str) -> None: 802 | """ 803 | 804 | :param prompt_yaml_name: The yaml name of the prompt override to delete." 805 | :return: 806 | """ 807 | # for convenience, we can pass names without .yml 808 | if not prompt_yaml_name.endswith(".yml"): 809 | prompt_yaml_name = prompt_yaml_name + ".yml" 810 | user_prompt_yaml_path = PromptCommands._get_user_prompt_yaml_path(prompt_yaml_name) 811 | if not os.path.exists(user_prompt_yaml_path): 812 | click.echo(f"Override file '{prompt_yaml_name}' not found.") 813 | return 814 | os.remove(user_prompt_yaml_path) 815 | click.echo(f"Deleted override file '{prompt_yaml_name}'.") 816 | 817 | 818 | # Expose groups so we can reference them in pyproject.toml 819 | mode = ModeCommands() 820 | context = ContextCommands() 821 | project = ProjectCommands() 822 | config = SerenaConfigCommands() 823 | tools = ToolCommands() 824 | prompts = PromptCommands() 825 | 826 | # Expose toplevel commands for the same reason 827 | top_level = TopLevelCommands() 828 | start_mcp_server = top_level.start_mcp_server 829 | index_project = project.index_deprecated 830 | 831 | # needed for the help script to work - register all subcommands to the top-level group 832 | for subgroup in (mode, context, project, config, tools, prompts): 833 | top_level.add_command(subgroup) 834 | 835 | 836 | def get_help() -> str: 837 | """Retrieve the help text for the top-level Serena CLI.""" 838 | return top_level.get_help(click.Context(top_level, info_name="serena")) 839 | ``` -------------------------------------------------------------------------------- /src/solidlsp/language_servers/al_language_server.py: -------------------------------------------------------------------------------- ```python 1 | """AL Language Server implementation for Microsoft Dynamics 365 Business Central.""" 2 | 3 | import logging 4 | import os 5 | import pathlib 6 | import platform 7 | import stat 8 | import time 9 | import zipfile 10 | from pathlib import Path 11 | 12 | import requests 13 | from overrides import override 14 | 15 | from solidlsp.language_servers.common import quote_windows_path 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 Definition, DefinitionParams, LocationLink 20 | from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo 21 | from solidlsp.settings import SolidLSPSettings 22 | 23 | 24 | class ALLanguageServer(SolidLanguageServer): 25 | """ 26 | Language server implementation for AL (Microsoft Dynamics 365 Business Central). 27 | 28 | This implementation uses the AL Language Server from the VS Code AL extension 29 | (ms-dynamics-smb.al). The extension must be installed or available locally. 30 | 31 | Key Features: 32 | - Automatic download of AL extension from VS Code marketplace if not present 33 | - Platform-specific executable detection (Windows/Linux/macOS) 34 | - Special initialization sequence required by AL Language Server 35 | - Custom AL-specific LSP commands (al/gotodefinition, al/setActiveWorkspace) 36 | - File opening requirement before symbol retrieval 37 | """ 38 | 39 | def __init__( 40 | self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings 41 | ): 42 | """ 43 | Initialize the AL Language Server. 44 | 45 | Args: 46 | config: Language server configuration 47 | logger: Logger instance for debugging 48 | repository_root_path: Root path of the AL project (must contain app.json) 49 | solidlsp_settings: Solid LSP settings 50 | 51 | Note: 52 | The initialization process will automatically: 53 | 1. Check for AL extension in the resources directory 54 | 2. Download it from VS Code marketplace if not found 55 | 3. Extract and configure the platform-specific executable 56 | 57 | """ 58 | # Setup runtime dependencies and get the language server command 59 | # This will download the AL extension if needed 60 | cmd = self._setup_runtime_dependencies(logger, config, solidlsp_settings) 61 | 62 | self._project_load_check_supported: bool = True 63 | """Whether the AL server supports the project load status check request. 64 | 65 | Some AL server versions don't support the 'al/hasProjectClosureLoadedRequest' 66 | custom LSP request. This flag starts as True and is set to False if the 67 | request fails, preventing repeated unsuccessful attempts. 68 | """ 69 | 70 | super().__init__( 71 | config, 72 | logger, 73 | repository_root_path, 74 | ProcessLaunchInfo(cmd=cmd, cwd=repository_root_path), 75 | "al", # Language ID for LSP 76 | solidlsp_settings, 77 | ) 78 | 79 | @classmethod 80 | def _download_al_extension(cls, logger: LanguageServerLogger, url: str, target_dir: str) -> bool: 81 | """ 82 | Download and extract the AL extension from VS Code marketplace. 83 | 84 | The VS Code marketplace packages extensions as .vsix files (which are ZIP archives). 85 | This method downloads the VSIX file and extracts it to get the language server binaries. 86 | 87 | Args: 88 | logger: Logger for tracking download progress 89 | url: VS Code marketplace URL for the AL extension 90 | target_dir: Directory where the extension will be extracted 91 | 92 | Returns: 93 | True if successful, False otherwise 94 | 95 | Note: 96 | The download includes progress tracking and proper user-agent headers 97 | to ensure compatibility with the VS Code marketplace. 98 | 99 | """ 100 | try: 101 | logger.log(f"Downloading AL extension from {url}", logging.INFO) 102 | 103 | # Create target directory for the extension 104 | os.makedirs(target_dir, exist_ok=True) 105 | 106 | # Download with proper headers to mimic VS Code marketplace client 107 | # These headers are required for the marketplace to serve the VSIX file 108 | headers = { 109 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", 110 | "Accept": "application/octet-stream, application/vsix, */*", 111 | } 112 | 113 | response = requests.get(url, headers=headers, stream=True, timeout=300) 114 | response.raise_for_status() 115 | 116 | # Save to temporary VSIX file (will be deleted after extraction) 117 | temp_file = os.path.join(target_dir, "al_extension_temp.vsix") 118 | total_size = int(response.headers.get("content-length", 0)) 119 | 120 | logger.log(f"Downloading {total_size / 1024 / 1024:.1f} MB...", logging.INFO) 121 | 122 | with open(temp_file, "wb") as f: 123 | downloaded = 0 124 | for chunk in response.iter_content(chunk_size=8192): 125 | if chunk: 126 | f.write(chunk) 127 | downloaded += len(chunk) 128 | if total_size > 0 and downloaded % (10 * 1024 * 1024) == 0: # Log progress every 10MB 129 | progress = (downloaded / total_size) * 100 130 | logger.log(f"Download progress: {progress:.1f}%", logging.INFO) 131 | 132 | logger.log("Download complete, extracting...", logging.INFO) 133 | 134 | # Extract VSIX file (VSIX files are just ZIP archives with a different extension) 135 | # This will extract the extension folder containing the language server binaries 136 | with zipfile.ZipFile(temp_file, "r") as zip_ref: 137 | zip_ref.extractall(target_dir) 138 | 139 | # Clean up temp file 140 | os.remove(temp_file) 141 | 142 | logger.log("AL extension extracted successfully", logging.INFO) 143 | return True 144 | 145 | except Exception as e: 146 | logger.log(f"Error downloading/extracting AL extension: {e}", logging.ERROR) 147 | return False 148 | 149 | @classmethod 150 | def _setup_runtime_dependencies( 151 | cls, logger: LanguageServerLogger, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings 152 | ) -> str: 153 | """ 154 | Setup runtime dependencies for AL Language Server and return the command to start the server. 155 | 156 | This method handles the complete setup process: 157 | 1. Checks for existing AL extension installations 158 | 2. Downloads from VS Code marketplace if not found 159 | 3. Configures executable permissions on Unix systems 160 | 4. Returns the properly formatted command string 161 | 162 | The AL Language Server executable is located in different paths based on the platform: 163 | - Windows: bin/win32/Microsoft.Dynamics.Nav.EditorServices.Host.exe 164 | - Linux: bin/linux/Microsoft.Dynamics.Nav.EditorServices.Host 165 | - macOS: bin/darwin/Microsoft.Dynamics.Nav.EditorServices.Host 166 | """ 167 | system = platform.system() 168 | 169 | # Find existing extension or download if needed 170 | extension_path = cls._find_al_extension(logger, solidlsp_settings) 171 | if extension_path is None: 172 | logger.log("AL extension not found on disk, attempting to download...", logging.INFO) 173 | extension_path = cls._download_and_install_al_extension(logger, solidlsp_settings) 174 | 175 | if extension_path is None: 176 | raise RuntimeError( 177 | "Failed to locate or download AL Language Server. Please either:\n" 178 | "1. Set AL_EXTENSION_PATH environment variable to the AL extension directory\n" 179 | "2. Install the AL extension in VS Code (ms-dynamics-smb.al)\n" 180 | "3. Ensure internet connection for automatic download" 181 | ) 182 | 183 | # Build executable path based on platform 184 | executable_path = cls._get_executable_path(extension_path, system) 185 | 186 | if not os.path.exists(executable_path): 187 | raise RuntimeError(f"AL Language Server executable not found at: {executable_path}") 188 | 189 | # Prepare and return the executable command 190 | return cls._prepare_executable(executable_path, system, logger) 191 | 192 | @classmethod 193 | def _find_al_extension(cls, logger: LanguageServerLogger, solidlsp_settings: SolidLSPSettings) -> str | None: 194 | """ 195 | Find AL extension in various locations. 196 | 197 | Search order: 198 | 1. Environment variable (AL_EXTENSION_PATH) 199 | 2. Default download location (~/.serena/ls_resources/al-extension) 200 | 3. VS Code installed extensions 201 | 202 | Returns: 203 | Path to AL extension directory or None if not found 204 | 205 | """ 206 | # Check environment variable 207 | env_path = os.environ.get("AL_EXTENSION_PATH") 208 | if env_path and os.path.exists(env_path): 209 | logger.log(f"Found AL extension via AL_EXTENSION_PATH: {env_path}", logging.DEBUG) 210 | return env_path 211 | elif env_path: 212 | logger.log(f"AL_EXTENSION_PATH set but directory not found: {env_path}", logging.WARNING) 213 | 214 | # Check default download location 215 | default_path = os.path.join(cls.ls_resources_dir(solidlsp_settings), "al-extension", "extension") 216 | if os.path.exists(default_path): 217 | logger.log(f"Found AL extension in default location: {default_path}", logging.DEBUG) 218 | return default_path 219 | 220 | # Search VS Code extensions 221 | vscode_path = cls._find_al_extension_in_vscode(logger) 222 | if vscode_path: 223 | logger.log(f"Found AL extension in VS Code: {vscode_path}", logging.DEBUG) 224 | return vscode_path 225 | 226 | logger.log("AL extension not found in any known location", logging.DEBUG) 227 | return None 228 | 229 | @classmethod 230 | def _download_and_install_al_extension(cls, logger: LanguageServerLogger, solidlsp_settings: SolidLSPSettings) -> str | None: 231 | """ 232 | Download and install AL extension from VS Code marketplace. 233 | 234 | Returns: 235 | Path to installed extension or None if download failed 236 | 237 | """ 238 | al_extension_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "al-extension") 239 | 240 | # AL extension version - using latest stable version 241 | AL_VERSION = "latest" 242 | url = f"https://marketplace.visualstudio.com/_apis/public/gallery/publishers/ms-dynamics-smb/vsextensions/al/{AL_VERSION}/vspackage" 243 | 244 | logger.log(f"Downloading AL extension from: {url}", logging.INFO) 245 | 246 | if cls._download_al_extension(logger, url, al_extension_dir): 247 | extension_path = os.path.join(al_extension_dir, "extension") 248 | if os.path.exists(extension_path): 249 | logger.log("AL extension downloaded and installed successfully", logging.INFO) 250 | return extension_path 251 | else: 252 | logger.log(f"Download completed but extension not found at: {extension_path}", logging.ERROR) 253 | else: 254 | logger.log("Failed to download AL extension from marketplace", logging.ERROR) 255 | 256 | return None 257 | 258 | @classmethod 259 | def _get_executable_path(cls, extension_path: str, system: str) -> str: 260 | """ 261 | Build platform-specific executable path. 262 | 263 | Args: 264 | extension_path: Path to AL extension directory 265 | system: Operating system name 266 | 267 | Returns: 268 | Full path to executable 269 | 270 | """ 271 | if system == "Windows": 272 | return os.path.join(extension_path, "bin", "win32", "Microsoft.Dynamics.Nav.EditorServices.Host.exe") 273 | elif system == "Linux": 274 | return os.path.join(extension_path, "bin", "linux", "Microsoft.Dynamics.Nav.EditorServices.Host") 275 | elif system == "Darwin": 276 | return os.path.join(extension_path, "bin", "darwin", "Microsoft.Dynamics.Nav.EditorServices.Host") 277 | else: 278 | raise RuntimeError(f"Unsupported platform: {system}") 279 | 280 | @classmethod 281 | def _prepare_executable(cls, executable_path: str, system: str, logger: LanguageServerLogger) -> str: 282 | """ 283 | Prepare the executable by setting permissions and handling path quoting. 284 | 285 | Args: 286 | executable_path: Path to the executable 287 | system: Operating system name 288 | logger: Logger instance 289 | 290 | Returns: 291 | Properly formatted command string 292 | 293 | """ 294 | # Make sure executable has proper permissions on Unix-like systems 295 | if system in ["Linux", "Darwin"]: 296 | st = os.stat(executable_path) 297 | os.chmod(executable_path, st.st_mode | stat.S_IEXEC) 298 | logger.log(f"Set execute permission on: {executable_path}", logging.DEBUG) 299 | 300 | logger.log(f"Using AL Language Server executable: {executable_path}", logging.INFO) 301 | 302 | # The AL Language Server uses stdio for LSP communication by default 303 | # Use the utility function to handle Windows path quoting 304 | return quote_windows_path(executable_path) 305 | 306 | @classmethod 307 | def _get_language_server_command_fallback(cls, logger: LanguageServerLogger) -> str: 308 | """ 309 | Get the command to start the AL language server. 310 | 311 | Returns: 312 | Command string to launch the AL language server 313 | 314 | Raises: 315 | RuntimeError: If AL extension cannot be found 316 | 317 | """ 318 | # Check if AL extension path is configured via environment variable 319 | al_extension_path = os.environ.get("AL_EXTENSION_PATH") 320 | 321 | if not al_extension_path: 322 | # Try to find the extension in the current working directory 323 | # (for development/testing when extension is in the serena repo) 324 | cwd_path = Path.cwd() 325 | potential_extension = None 326 | 327 | # Look for ms-dynamics-smb.al-* directories 328 | for item in cwd_path.iterdir(): 329 | if item.is_dir() and item.name.startswith("ms-dynamics-smb.al-"): 330 | potential_extension = item 331 | break 332 | 333 | if potential_extension: 334 | al_extension_path = str(potential_extension) 335 | logger.log(f"Found AL extension in current directory: {al_extension_path}", logging.DEBUG) 336 | else: 337 | # Try to find in common VS Code extension locations 338 | al_extension_path = cls._find_al_extension_in_vscode(logger) 339 | 340 | if not al_extension_path: 341 | raise RuntimeError( 342 | "AL Language Server not found. Please either:\n" 343 | "1. Set AL_EXTENSION_PATH environment variable to the VS Code AL extension directory\n" 344 | "2. Install the AL extension in VS Code (ms-dynamics-smb.al)\n" 345 | "3. Place the extension directory in the current working directory" 346 | ) 347 | 348 | # Determine platform-specific executable 349 | system = platform.system() 350 | if system == "Windows": 351 | executable = os.path.join(al_extension_path, "bin", "win32", "Microsoft.Dynamics.Nav.EditorServices.Host.exe") 352 | elif system == "Linux": 353 | executable = os.path.join(al_extension_path, "bin", "linux", "Microsoft.Dynamics.Nav.EditorServices.Host") 354 | elif system == "Darwin": 355 | executable = os.path.join(al_extension_path, "bin", "darwin", "Microsoft.Dynamics.Nav.EditorServices.Host") 356 | else: 357 | raise RuntimeError(f"Unsupported platform: {system}") 358 | 359 | # Verify executable exists 360 | if not os.path.exists(executable): 361 | raise RuntimeError( 362 | f"AL Language Server executable not found at: {executable}\nPlease ensure the AL extension is properly installed." 363 | ) 364 | 365 | # Make sure executable has proper permissions on Unix-like systems 366 | if system in ["Linux", "Darwin"]: 367 | st = os.stat(executable) 368 | os.chmod(executable, st.st_mode | stat.S_IEXEC) 369 | 370 | logger.log(f"Using AL Language Server executable: {executable}", logging.INFO) 371 | 372 | # The AL Language Server uses stdio for LSP communication (no --stdio flag needed) 373 | # Use the utility function to handle Windows path quoting 374 | return quote_windows_path(executable) 375 | 376 | @classmethod 377 | def _find_al_extension_in_vscode(cls, logger: LanguageServerLogger) -> str | None: 378 | """ 379 | Try to find AL extension in common VS Code extension locations. 380 | 381 | Returns: 382 | Path to AL extension directory or None if not found 383 | 384 | """ 385 | home = Path.home() 386 | possible_paths = [] 387 | 388 | # Common VS Code extension paths 389 | if platform.system() == "Windows": 390 | possible_paths.extend( 391 | [ 392 | home / ".vscode" / "extensions", 393 | home / ".vscode-insiders" / "extensions", 394 | Path(os.environ.get("APPDATA", "")) / "Code" / "User" / "extensions", 395 | Path(os.environ.get("APPDATA", "")) / "Code - Insiders" / "User" / "extensions", 396 | ] 397 | ) 398 | else: 399 | possible_paths.extend( 400 | [ 401 | home / ".vscode" / "extensions", 402 | home / ".vscode-server" / "extensions", 403 | home / ".vscode-insiders" / "extensions", 404 | ] 405 | ) 406 | 407 | for base_path in possible_paths: 408 | if base_path.exists(): 409 | logger.log(f"Searching for AL extension in: {base_path}", logging.DEBUG) 410 | # Look for AL extension directories 411 | for item in base_path.iterdir(): 412 | if item.is_dir() and item.name.startswith("ms-dynamics-smb.al-"): 413 | logger.log(f"Found AL extension at: {item}", logging.DEBUG) 414 | return str(item) 415 | 416 | return None 417 | 418 | @staticmethod 419 | def _get_initialize_params(repository_absolute_path: str) -> dict: 420 | """ 421 | Returns the initialize params for the AL Language Server. 422 | """ 423 | # Ensure we have an absolute path for URI generation 424 | repository_path = pathlib.Path(repository_absolute_path).resolve() 425 | root_uri = repository_path.as_uri() 426 | 427 | # AL requires extensive capabilities based on VS Code trace 428 | initialize_params = { 429 | "processId": os.getpid(), 430 | "rootPath": str(repository_path), 431 | "rootUri": root_uri, 432 | "capabilities": { 433 | "workspace": { 434 | "applyEdit": True, 435 | "workspaceEdit": { 436 | "documentChanges": True, 437 | "resourceOperations": ["create", "rename", "delete"], 438 | "failureHandling": "textOnlyTransactional", 439 | "normalizesLineEndings": True, 440 | }, 441 | "configuration": True, 442 | "didChangeWatchedFiles": {"dynamicRegistration": True}, 443 | "symbol": {"dynamicRegistration": True, "symbolKind": {"valueSet": list(range(1, 27))}}, 444 | "executeCommand": {"dynamicRegistration": True}, 445 | "didChangeConfiguration": {"dynamicRegistration": True}, 446 | "workspaceFolders": True, 447 | }, 448 | "textDocument": { 449 | "synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True}, 450 | "completion": { 451 | "dynamicRegistration": True, 452 | "contextSupport": True, 453 | "completionItem": { 454 | "snippetSupport": True, 455 | "commitCharactersSupport": True, 456 | "documentationFormat": ["markdown", "plaintext"], 457 | "deprecatedSupport": True, 458 | "preselectSupport": True, 459 | }, 460 | }, 461 | "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, 462 | "definition": {"dynamicRegistration": True, "linkSupport": True}, 463 | "references": {"dynamicRegistration": True}, 464 | "documentHighlight": {"dynamicRegistration": True}, 465 | "documentSymbol": { 466 | "dynamicRegistration": True, 467 | "symbolKind": {"valueSet": list(range(1, 27))}, 468 | "hierarchicalDocumentSymbolSupport": True, 469 | }, 470 | "codeAction": {"dynamicRegistration": True}, 471 | "formatting": {"dynamicRegistration": True}, 472 | "rangeFormatting": {"dynamicRegistration": True}, 473 | "rename": {"dynamicRegistration": True, "prepareSupport": True}, 474 | }, 475 | "window": { 476 | "showMessage": {"messageActionItem": {"additionalPropertiesSupport": True}}, 477 | "showDocument": {"support": True}, 478 | "workDoneProgress": True, 479 | }, 480 | }, 481 | "trace": "verbose", 482 | "workspaceFolders": [{"uri": root_uri, "name": repository_path.name}], 483 | } 484 | 485 | return initialize_params 486 | 487 | @override 488 | def _start_server(self): 489 | """ 490 | Starts the AL Language Server process and initializes it. 491 | 492 | This method sets up custom notification handlers for AL-specific messages 493 | before starting the server. The AL server sends various notifications 494 | during initialization and project loading that need to be handled. 495 | """ 496 | 497 | # Set up event handlers 498 | def do_nothing(params): 499 | return 500 | 501 | def window_log_message(msg): 502 | self.logger.log(f"AL LSP: window/logMessage: {msg}", logging.INFO) 503 | 504 | def publish_diagnostics(params): 505 | # AL server publishes diagnostics during initialization 506 | uri = params.get("uri", "") 507 | diagnostics = params.get("diagnostics", []) 508 | self.logger.log(f"AL LSP: Diagnostics for {uri}: {len(diagnostics)} issues", logging.DEBUG) 509 | 510 | def handle_al_notifications(params): 511 | # AL server sends custom notifications during project loading 512 | self.logger.log("AL LSP: Notification received", logging.DEBUG) 513 | 514 | # Register handlers for AL-specific notifications 515 | # These notifications are sent by the AL server during initialization and operation 516 | self.server.on_notification("window/logMessage", window_log_message) # Server log messages 517 | self.server.on_notification("textDocument/publishDiagnostics", publish_diagnostics) # Compilation diagnostics 518 | self.server.on_notification("$/progress", do_nothing) # Progress notifications during loading 519 | self.server.on_notification("al/refreshExplorerObjects", handle_al_notifications) # AL-specific object updates 520 | 521 | # Start the server process 522 | self.logger.log("Starting AL Language Server process", logging.INFO) 523 | self.server.start() 524 | 525 | # Send initialize request 526 | initialize_params = self._get_initialize_params(self.repository_root_path) 527 | 528 | self.logger.log( 529 | "Sending initialize request from LSP client to AL LSP server and awaiting response", 530 | logging.INFO, 531 | ) 532 | 533 | # Send initialize and wait for response 534 | resp = self.server.send_request("initialize", initialize_params) 535 | if resp is None: 536 | raise RuntimeError("AL Language Server initialization failed - no response") 537 | 538 | self.logger.log("AL Language Server initialized successfully", logging.INFO) 539 | 540 | # Send initialized notification 541 | self.server.send_notification("initialized", {}) 542 | self.logger.log("Sent initialized notification", logging.INFO) 543 | 544 | @override 545 | def start(self) -> "ALLanguageServer": 546 | """ 547 | Start the AL Language Server with special initialization. 548 | """ 549 | # Call parent start method 550 | super().start() 551 | 552 | # AL-specific post-initialization 553 | self._post_initialize_al_workspace() 554 | 555 | # Note: set_active_workspace() can be called manually if needed for multi-workspace scenarios 556 | # We don't call it automatically to avoid issues during single-workspace initialization 557 | 558 | return self 559 | 560 | def _post_initialize_al_workspace(self) -> None: 561 | """ 562 | Post-initialization setup for AL Language Server. 563 | 564 | The AL server requires additional setup after initialization: 565 | 1. Send workspace configuration - provides AL settings and paths 566 | 2. Open app.json to trigger project loading - AL uses app.json to identify project structure 567 | 3. Optionally wait for project to be loaded if supported 568 | 569 | This special initialization sequence is unique to AL and necessary for proper 570 | symbol resolution and navigation features. 571 | """ 572 | # No sleep needed - server is already initialized 573 | 574 | # Send workspace configuration first 575 | # This tells AL about assembly paths, package caches, and code analysis settings 576 | try: 577 | self.server.send_notification( 578 | "workspace/didChangeConfiguration", 579 | { 580 | "settings": { 581 | "workspacePath": self.repository_root_path, 582 | "alResourceConfigurationSettings": { 583 | "assemblyProbingPaths": ["./.netpackages"], 584 | "codeAnalyzers": [], 585 | "enableCodeAnalysis": False, 586 | "backgroundCodeAnalysis": "Project", 587 | "packageCachePaths": ["./.alpackages"], 588 | "ruleSetPath": None, 589 | "enableCodeActions": True, 590 | "incrementalBuild": False, 591 | "outputAnalyzerStatistics": True, 592 | "enableExternalRulesets": True, 593 | }, 594 | "setActiveWorkspace": True, 595 | "expectedProjectReferenceDefinitions": [], 596 | "activeWorkspaceClosure": [self.repository_root_path], 597 | } 598 | }, 599 | ) 600 | self.logger.log("Sent workspace configuration", logging.DEBUG) 601 | except Exception as e: 602 | self.logger.log(f"Failed to send workspace config: {e}", logging.WARNING) 603 | 604 | # Check if app.json exists and open it 605 | # app.json is the AL project manifest file (similar to package.json for Node.js) 606 | # Opening it triggers AL to load the project and index all AL files 607 | app_json_path = Path(self.repository_root_path) / "app.json" 608 | if app_json_path.exists(): 609 | try: 610 | with open(app_json_path, encoding="utf-8") as f: 611 | app_json_content = f.read() 612 | 613 | # Use forward slashes for URI 614 | app_json_uri = app_json_path.as_uri() 615 | 616 | # Send textDocument/didOpen for app.json 617 | self.server.send_notification( 618 | "textDocument/didOpen", 619 | {"textDocument": {"uri": app_json_uri, "languageId": "json", "version": 1, "text": app_json_content}}, 620 | ) 621 | 622 | self.logger.log(f"Opened app.json: {app_json_uri}", logging.DEBUG) 623 | except Exception as e: 624 | self.logger.log(f"Failed to open app.json: {e}", logging.WARNING) 625 | 626 | # Try to set active workspace (AL-specific custom LSP request) 627 | # This is optional and may not be supported by all AL server versions 628 | workspace_uri = Path(self.repository_root_path).resolve().as_uri() 629 | try: 630 | result = self.server.send_request( 631 | "al/setActiveWorkspace", 632 | { 633 | "currentWorkspaceFolderPath": {"uri": workspace_uri, "name": Path(self.repository_root_path).name, "index": 0}, 634 | "settings": { 635 | "workspacePath": self.repository_root_path, 636 | "setActiveWorkspace": True, 637 | }, 638 | }, 639 | timeout=2, # Quick timeout since this is optional 640 | ) 641 | self.logger.log(f"Set active workspace result: {result}", logging.DEBUG) 642 | except Exception as e: 643 | # This is a custom AL request, not critical if it fails 644 | self.logger.log(f"Failed to set active workspace (non-critical): {e}", logging.DEBUG) 645 | 646 | # Check if project supports load status check (optional) 647 | # Many AL server versions don't support this, so we use a short timeout 648 | # and continue regardless of the result 649 | self._wait_for_project_load(timeout=3) 650 | 651 | @override 652 | def is_ignored_dirname(self, dirname: str) -> bool: 653 | """ 654 | Define AL-specific directories to ignore during file scanning. 655 | 656 | These directories contain generated files, dependencies, or cache data 657 | that should not be analyzed for symbols. 658 | 659 | Args: 660 | dirname: Directory name to check 661 | 662 | Returns: 663 | True if directory should be ignored 664 | 665 | """ 666 | al_ignore_dirs = { 667 | ".alpackages", # AL package cache - downloaded dependencies 668 | ".alcache", # AL compiler cache - intermediate compilation files 669 | ".altemplates", # AL templates - code generation templates 670 | ".snapshots", # Test snapshots - test result snapshots 671 | "out", # Compiled output - generated .app files 672 | ".vscode", # VS Code settings - editor configuration 673 | "Reference", # Reference assemblies - .NET dependencies 674 | ".netpackages", # .NET packages - NuGet packages for AL 675 | "bin", # Binary output - compiled binaries 676 | "obj", # Object files - intermediate build artifacts 677 | } 678 | 679 | # Check parent class ignore list first, then AL-specific 680 | return super().is_ignored_dirname(dirname) or dirname in al_ignore_dirs 681 | 682 | @override 683 | def request_full_symbol_tree(self, within_relative_path: str | None = None, include_body: bool = False) -> list[dict]: 684 | """ 685 | Override to handle AL's requirement of opening files before requesting symbols. 686 | 687 | The AL Language Server requires files to be explicitly opened via textDocument/didOpen 688 | before it can provide meaningful symbols. Without this, it only returns directory symbols. 689 | This is different from most language servers which can provide symbols for unopened files. 690 | 691 | This method: 692 | 1. Scans the repository for all AL files (.al and .dal extensions) 693 | 2. Opens each file with the AL server 694 | 3. Requests symbols for each file 695 | 4. Combines all symbols into a hierarchical tree structure 696 | 5. Closes the files to free resources 697 | 698 | Args: 699 | within_relative_path: Restrict search to this file or directory path 700 | include_body: Whether to include symbol body content 701 | 702 | Returns: 703 | Full symbol tree with all AL symbols from opened files organized by directory 704 | 705 | """ 706 | self.logger.log("AL: Starting request_full_symbol_tree with file opening", logging.DEBUG) 707 | 708 | # Determine the root path for scanning 709 | if within_relative_path is not None: 710 | within_abs_path = os.path.join(self.repository_root_path, within_relative_path) 711 | if not os.path.exists(within_abs_path): 712 | raise FileNotFoundError(f"File or directory not found: {within_abs_path}") 713 | 714 | if os.path.isfile(within_abs_path): 715 | # Single file case - use parent class implementation 716 | _, root_nodes = self.request_document_symbols(within_relative_path, include_body=include_body) 717 | return root_nodes 718 | 719 | # Directory case - scan within this directory 720 | scan_root = Path(within_abs_path) 721 | else: 722 | # Scan entire repository 723 | scan_root = Path(self.repository_root_path) 724 | 725 | # For AL, we always need to open files to get symbols 726 | al_files = [] 727 | 728 | # Walk through the repository to find all AL files 729 | for root, dirs, files in os.walk(scan_root): 730 | # Skip ignored directories 731 | dirs[:] = [d for d in dirs if not self.is_ignored_dirname(d)] 732 | 733 | # Find AL files 734 | for file in files: 735 | if file.endswith((".al", ".dal")): 736 | file_path = Path(root) / file 737 | # Use forward slashes for consistent paths 738 | try: 739 | relative_path = str(file_path.relative_to(self.repository_root_path)).replace("\\", "/") 740 | al_files.append((file_path, relative_path)) 741 | except ValueError: 742 | # File is outside repository root, skip it 743 | continue 744 | 745 | self.logger.log(f"AL: Found {len(al_files)} AL files", logging.DEBUG) 746 | 747 | if not al_files: 748 | self.logger.log("AL: No AL files found in repository", logging.WARNING) 749 | return [] 750 | 751 | # Collect all symbols from all files 752 | all_file_symbols = [] 753 | 754 | for file_path, relative_path in al_files: 755 | try: 756 | # Use our overridden request_document_symbols which handles opening 757 | self.logger.log(f"AL: Getting symbols for {relative_path}", logging.DEBUG) 758 | all_syms, root_syms = self.request_document_symbols(relative_path, include_body=include_body) 759 | 760 | if root_syms: 761 | # Create a file-level symbol containing the document symbols 762 | file_symbol = { 763 | "name": file_path.stem, # Just the filename without extension 764 | "kind": 1, # File 765 | "children": root_syms, 766 | "location": { 767 | "uri": file_path.as_uri(), 768 | "relativePath": relative_path, 769 | "absolutePath": str(file_path), 770 | "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}}, 771 | }, 772 | } 773 | all_file_symbols.append(file_symbol) 774 | self.logger.log(f"AL: Added {len(root_syms)} symbols from {relative_path}", logging.DEBUG) 775 | elif all_syms: 776 | # If we only got all_syms but not root, use all_syms 777 | file_symbol = { 778 | "name": file_path.stem, 779 | "kind": 1, # File 780 | "children": all_syms, 781 | "location": { 782 | "uri": file_path.as_uri(), 783 | "relativePath": relative_path, 784 | "absolutePath": str(file_path), 785 | "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}}, 786 | }, 787 | } 788 | all_file_symbols.append(file_symbol) 789 | self.logger.log(f"AL: Added {len(all_syms)} symbols from {relative_path}", logging.DEBUG) 790 | 791 | except Exception as e: 792 | self.logger.log(f"AL: Failed to get symbols for {relative_path}: {e}", logging.WARNING) 793 | 794 | if all_file_symbols: 795 | self.logger.log(f"AL: Returning symbols from {len(all_file_symbols)} files", logging.DEBUG) 796 | 797 | # Group files by directory 798 | directory_structure = {} 799 | 800 | for file_symbol in all_file_symbols: 801 | rel_path = file_symbol["location"]["relativePath"] 802 | path_parts = rel_path.split("/") 803 | 804 | if len(path_parts) > 1: 805 | # File is in a subdirectory 806 | dir_path = "/".join(path_parts[:-1]) 807 | if dir_path not in directory_structure: 808 | directory_structure[dir_path] = [] 809 | directory_structure[dir_path].append(file_symbol) 810 | else: 811 | # File is in root 812 | if "." not in directory_structure: 813 | directory_structure["."] = [] 814 | directory_structure["."].append(file_symbol) 815 | 816 | # Build hierarchical structure 817 | result = [] 818 | repo_path = Path(self.repository_root_path) 819 | for dir_path, file_symbols in directory_structure.items(): 820 | if dir_path == ".": 821 | # Root level files 822 | result.extend(file_symbols) 823 | else: 824 | # Create directory symbol 825 | dir_symbol = { 826 | "name": Path(dir_path).name, 827 | "kind": 4, # Package/Directory 828 | "children": file_symbols, 829 | "location": { 830 | "relativePath": dir_path, 831 | "absolutePath": str(repo_path / dir_path), 832 | "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}}, 833 | }, 834 | } 835 | result.append(dir_symbol) 836 | 837 | return result 838 | else: 839 | self.logger.log("AL: No symbols found in any files", logging.WARNING) 840 | return [] 841 | 842 | # ===== Phase 1: Custom AL Command Implementations ===== 843 | 844 | @override 845 | def _send_definition_request(self, definition_params: DefinitionParams) -> Definition | list[LocationLink] | None: 846 | """ 847 | Override to use AL's custom gotodefinition command. 848 | 849 | AL Language Server uses 'al/gotodefinition' instead of the standard 850 | 'textDocument/definition' request. This custom command provides better 851 | navigation for AL-specific constructs like table extensions, page extensions, 852 | and codeunit references. 853 | 854 | If the custom command fails, we fall back to the standard LSP method. 855 | """ 856 | # Convert standard params to AL format (same structure, different method) 857 | al_params = {"textDocument": definition_params["textDocument"], "position": definition_params["position"]} 858 | 859 | try: 860 | # Use custom AL command instead of standard LSP 861 | response = self.server.send_request("al/gotodefinition", al_params) 862 | self.logger.log(f"AL gotodefinition response: {response}", logging.DEBUG) 863 | return response 864 | except Exception as e: 865 | self.logger.log(f"Failed to use al/gotodefinition, falling back to standard: {e}", logging.WARNING) 866 | # Fallback to standard LSP method if custom command fails 867 | return super()._send_definition_request(definition_params) 868 | 869 | def check_project_loaded(self) -> bool: 870 | """ 871 | Check if AL project closure is fully loaded. 872 | 873 | Uses AL's custom 'al/hasProjectClosureLoadedRequest' to determine if 874 | the project and all its dependencies have been fully loaded and indexed. 875 | This is important because AL operations may fail or return incomplete 876 | results if the project is still loading. 877 | 878 | Returns: 879 | bool: True if project is loaded, False otherwise 880 | 881 | """ 882 | if not hasattr(self, "server") or not self.server_started: 883 | self.logger.log("Cannot check project load - server not started", logging.DEBUG) 884 | return False 885 | 886 | # Check if we've already determined this request isn't supported 887 | if not self._project_load_check_supported: 888 | return True # Assume loaded if check isn't supported 889 | 890 | try: 891 | # Use a very short timeout since this is just a status check 892 | response = self.server.send_request("al/hasProjectClosureLoadedRequest", {}, timeout=1) 893 | # Response can be boolean directly, dict with 'loaded' field, or None 894 | if isinstance(response, bool): 895 | return response 896 | elif isinstance(response, dict): 897 | return response.get("loaded", False) 898 | elif response is None: 899 | # None typically means the project is still loading 900 | self.logger.log("Project load check returned None", logging.DEBUG) 901 | return False 902 | else: 903 | self.logger.log(f"Unexpected response type for project load check: {type(response)}", logging.DEBUG) 904 | return False 905 | except Exception as e: 906 | # Mark as unsupported to avoid repeated failed attempts 907 | self._project_load_check_supported = False 908 | self.logger.log(f"Project load check not supported by this AL server version: {e}", logging.DEBUG) 909 | # Assume loaded if we can't check 910 | return True 911 | 912 | def _wait_for_project_load(self, timeout: int = 3) -> bool: 913 | """ 914 | Wait for project to be fully loaded. 915 | 916 | Polls the AL server to check if the project is loaded. 917 | This is optional as not all AL server versions support this check. 918 | We use a short timeout and continue regardless of the result. 919 | 920 | Args: 921 | timeout: Maximum time to wait in seconds (default 3s) 922 | 923 | Returns: 924 | bool: True if project loaded within timeout, False otherwise 925 | 926 | """ 927 | start_time = time.time() 928 | self.logger.log(f"Checking AL project load status (timeout: {timeout}s)...", logging.DEBUG) 929 | 930 | while time.time() - start_time < timeout: 931 | if self.check_project_loaded(): 932 | elapsed = time.time() - start_time 933 | self.logger.log(f"AL project fully loaded after {elapsed:.1f}s", logging.INFO) 934 | return True 935 | time.sleep(0.5) 936 | 937 | self.logger.log(f"Project load check timed out after {timeout}s (non-critical)", logging.DEBUG) 938 | return False 939 | 940 | def set_active_workspace(self, workspace_uri: str | None = None) -> None: 941 | """ 942 | Set the active AL workspace. 943 | 944 | This is important when multiple workspaces exist to ensure operations 945 | target the correct workspace. The AL server can handle multiple projects 946 | simultaneously, but only one can be "active" at a time for operations 947 | like symbol search and navigation. 948 | 949 | This uses the custom 'al/setActiveWorkspace' LSP command. 950 | 951 | Args: 952 | workspace_uri: URI of workspace to set as active, or None to use repository root 953 | 954 | """ 955 | if not hasattr(self, "server") or not self.server_started: 956 | self.logger.log("Cannot set active workspace - server not started", logging.DEBUG) 957 | return 958 | 959 | if workspace_uri is None: 960 | workspace_uri = Path(self.repository_root_path).resolve().as_uri() 961 | 962 | params = {"workspaceUri": workspace_uri} 963 | 964 | try: 965 | self.server.send_request("al/setActiveWorkspace", params) 966 | self.logger.log(f"Set active workspace to: {workspace_uri}", logging.INFO) 967 | except Exception as e: 968 | self.logger.log(f"Failed to set active workspace: {e}", logging.WARNING) 969 | # Non-critical error, continue operation 970 | ```