This is page 12 of 14. Use http://codebase.md/oraios/serena?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .devcontainer │ └── devcontainer.json ├── .dockerignore ├── .env.example ├── .github │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE │ │ ├── config.yml │ │ ├── feature_request.md │ │ └── issue--bug--performance-problem--question-.md │ └── workflows │ ├── codespell.yml │ ├── docker.yml │ ├── junie.yml │ ├── lint_and_docs.yaml │ ├── publish.yml │ └── pytest.yml ├── .gitignore ├── .serena │ ├── memories │ │ ├── adding_new_language_support_guide.md │ │ ├── serena_core_concepts_and_architecture.md │ │ ├── serena_repository_structure.md │ │ └── suggested_commands.md │ └── project.yml ├── .vscode │ └── settings.json ├── CHANGELOG.md ├── CLAUDE.md ├── compose.yaml ├── CONTRIBUTING.md ├── docker_build_and_run.sh ├── DOCKER.md ├── Dockerfile ├── docs │ ├── custom_agent.md │ └── serena_on_chatgpt.md ├── flake.lock ├── flake.nix ├── lessons_learned.md ├── LICENSE ├── llms-install.md ├── public │ └── .gitignore ├── pyproject.toml ├── README.md ├── resources │ ├── serena-icons.cdr │ ├── serena-logo-dark-mode.svg │ ├── serena-logo.cdr │ ├── serena-logo.svg │ └── vscode_sponsor_logo.png ├── roadmap.md ├── scripts │ ├── agno_agent.py │ ├── demo_run_tools.py │ ├── gen_prompt_factory.py │ ├── mcp_server.py │ ├── print_mode_context_options.py │ └── print_tool_overview.py ├── src │ ├── interprompt │ │ ├── __init__.py │ │ ├── .syncCommitId.remote │ │ ├── .syncCommitId.this │ │ ├── jinja_template.py │ │ ├── multilang_prompt.py │ │ ├── prompt_factory.py │ │ └── util │ │ ├── __init__.py │ │ └── class_decorators.py │ ├── README.md │ ├── serena │ │ ├── __init__.py │ │ ├── agent.py │ │ ├── agno.py │ │ ├── analytics.py │ │ ├── cli.py │ │ ├── code_editor.py │ │ ├── config │ │ │ ├── __init__.py │ │ │ ├── context_mode.py │ │ │ └── serena_config.py │ │ ├── constants.py │ │ ├── dashboard.py │ │ ├── generated │ │ │ └── generated_prompt_factory.py │ │ ├── gui_log_viewer.py │ │ ├── mcp.py │ │ ├── project.py │ │ ├── prompt_factory.py │ │ ├── resources │ │ │ ├── config │ │ │ │ ├── contexts │ │ │ │ │ ├── agent.yml │ │ │ │ │ ├── chatgpt.yml │ │ │ │ │ ├── codex.yml │ │ │ │ │ ├── context.template.yml │ │ │ │ │ ├── desktop-app.yml │ │ │ │ │ ├── ide-assistant.yml │ │ │ │ │ └── oaicompat-agent.yml │ │ │ │ ├── internal_modes │ │ │ │ │ └── jetbrains.yml │ │ │ │ ├── modes │ │ │ │ │ ├── editing.yml │ │ │ │ │ ├── interactive.yml │ │ │ │ │ ├── mode.template.yml │ │ │ │ │ ├── no-onboarding.yml │ │ │ │ │ ├── onboarding.yml │ │ │ │ │ ├── one-shot.yml │ │ │ │ │ └── planning.yml │ │ │ │ └── prompt_templates │ │ │ │ ├── simple_tool_outputs.yml │ │ │ │ └── system_prompt.yml │ │ │ ├── dashboard │ │ │ │ ├── dashboard.js │ │ │ │ ├── index.html │ │ │ │ ├── jquery.min.js │ │ │ │ ├── serena-icon-16.png │ │ │ │ ├── serena-icon-32.png │ │ │ │ ├── serena-icon-48.png │ │ │ │ ├── serena-logs-dark-mode.png │ │ │ │ └── serena-logs.png │ │ │ ├── project.template.yml │ │ │ └── serena_config.template.yml │ │ ├── symbol.py │ │ ├── text_utils.py │ │ ├── tools │ │ │ ├── __init__.py │ │ │ ├── cmd_tools.py │ │ │ ├── config_tools.py │ │ │ ├── file_tools.py │ │ │ ├── jetbrains_plugin_client.py │ │ │ ├── jetbrains_tools.py │ │ │ ├── memory_tools.py │ │ │ ├── symbol_tools.py │ │ │ ├── tools_base.py │ │ │ └── workflow_tools.py │ │ └── util │ │ ├── class_decorators.py │ │ ├── exception.py │ │ ├── file_system.py │ │ ├── general.py │ │ ├── git.py │ │ ├── inspection.py │ │ ├── logging.py │ │ ├── shell.py │ │ └── thread.py │ └── solidlsp │ ├── __init__.py │ ├── .gitignore │ ├── language_servers │ │ ├── al_language_server.py │ │ ├── bash_language_server.py │ │ ├── clangd_language_server.py │ │ ├── clojure_lsp.py │ │ ├── common.py │ │ ├── csharp_language_server.py │ │ ├── dart_language_server.py │ │ ├── eclipse_jdtls.py │ │ ├── elixir_tools │ │ │ ├── __init__.py │ │ │ ├── elixir_tools.py │ │ │ └── README.md │ │ ├── elm_language_server.py │ │ ├── erlang_language_server.py │ │ ├── gopls.py │ │ ├── intelephense.py │ │ ├── jedi_server.py │ │ ├── kotlin_language_server.py │ │ ├── lua_ls.py │ │ ├── marksman.py │ │ ├── nixd_ls.py │ │ ├── omnisharp │ │ │ ├── initialize_params.json │ │ │ ├── runtime_dependencies.json │ │ │ └── workspace_did_change_configuration.json │ │ ├── omnisharp.py │ │ ├── perl_language_server.py │ │ ├── pyright_server.py │ │ ├── r_language_server.py │ │ ├── ruby_lsp.py │ │ ├── rust_analyzer.py │ │ ├── solargraph.py │ │ ├── sourcekit_lsp.py │ │ ├── terraform_ls.py │ │ ├── typescript_language_server.py │ │ ├── vts_language_server.py │ │ └── zls.py │ ├── ls_config.py │ ├── ls_exceptions.py │ ├── ls_handler.py │ ├── ls_logger.py │ ├── ls_request.py │ ├── ls_types.py │ ├── ls_utils.py │ ├── ls.py │ ├── lsp_protocol_handler │ │ ├── lsp_constants.py │ │ ├── lsp_requests.py │ │ ├── lsp_types.py │ │ └── server.py │ ├── settings.py │ └── util │ ├── subprocess_util.py │ └── zip.py ├── test │ ├── __init__.py │ ├── conftest.py │ ├── resources │ │ └── repos │ │ ├── al │ │ │ └── test_repo │ │ │ ├── app.json │ │ │ └── src │ │ │ ├── Codeunits │ │ │ │ ├── CustomerMgt.Codeunit.al │ │ │ │ └── PaymentProcessorImpl.Codeunit.al │ │ │ ├── Enums │ │ │ │ └── CustomerType.Enum.al │ │ │ ├── Interfaces │ │ │ │ └── IPaymentProcessor.Interface.al │ │ │ ├── Pages │ │ │ │ ├── CustomerCard.Page.al │ │ │ │ └── CustomerList.Page.al │ │ │ ├── TableExtensions │ │ │ │ └── Item.TableExt.al │ │ │ └── Tables │ │ │ └── Customer.Table.al │ │ ├── bash │ │ │ └── test_repo │ │ │ ├── config.sh │ │ │ ├── main.sh │ │ │ └── utils.sh │ │ ├── clojure │ │ │ └── test_repo │ │ │ ├── deps.edn │ │ │ └── src │ │ │ └── test_app │ │ │ ├── core.clj │ │ │ └── utils.clj │ │ ├── csharp │ │ │ └── test_repo │ │ │ ├── .gitignore │ │ │ ├── Models │ │ │ │ └── Person.cs │ │ │ ├── Program.cs │ │ │ ├── serena.sln │ │ │ └── TestProject.csproj │ │ ├── dart │ │ │ └── test_repo │ │ │ ├── .gitignore │ │ │ ├── lib │ │ │ │ ├── helper.dart │ │ │ │ ├── main.dart │ │ │ │ └── models.dart │ │ │ └── pubspec.yaml │ │ ├── elixir │ │ │ └── test_repo │ │ │ ├── .gitignore │ │ │ ├── lib │ │ │ │ ├── examples.ex │ │ │ │ ├── ignored_dir │ │ │ │ │ └── ignored_module.ex │ │ │ │ ├── models.ex │ │ │ │ ├── services.ex │ │ │ │ ├── test_repo.ex │ │ │ │ └── utils.ex │ │ │ ├── mix.exs │ │ │ ├── mix.lock │ │ │ ├── scripts │ │ │ │ └── build_script.ex │ │ │ └── test │ │ │ ├── models_test.exs │ │ │ └── test_repo_test.exs │ │ ├── elm │ │ │ └── test_repo │ │ │ ├── elm.json │ │ │ ├── Main.elm │ │ │ └── Utils.elm │ │ ├── erlang │ │ │ └── test_repo │ │ │ ├── hello.erl │ │ │ ├── ignored_dir │ │ │ │ └── ignored_module.erl │ │ │ ├── include │ │ │ │ ├── records.hrl │ │ │ │ └── types.hrl │ │ │ ├── math_utils.erl │ │ │ ├── rebar.config │ │ │ ├── src │ │ │ │ ├── app.erl │ │ │ │ ├── models.erl │ │ │ │ ├── services.erl │ │ │ │ └── utils.erl │ │ │ └── test │ │ │ ├── models_tests.erl │ │ │ └── utils_tests.erl │ │ ├── go │ │ │ └── test_repo │ │ │ └── main.go │ │ ├── java │ │ │ └── test_repo │ │ │ ├── pom.xml │ │ │ └── src │ │ │ └── main │ │ │ └── java │ │ │ └── test_repo │ │ │ ├── Main.java │ │ │ ├── Model.java │ │ │ ├── ModelUser.java │ │ │ └── Utils.java │ │ ├── kotlin │ │ │ └── test_repo │ │ │ ├── .gitignore │ │ │ ├── build.gradle.kts │ │ │ └── src │ │ │ └── main │ │ │ └── kotlin │ │ │ └── test_repo │ │ │ ├── Main.kt │ │ │ ├── Model.kt │ │ │ ├── ModelUser.kt │ │ │ └── Utils.kt │ │ ├── lua │ │ │ └── test_repo │ │ │ ├── .gitignore │ │ │ ├── main.lua │ │ │ ├── src │ │ │ │ ├── calculator.lua │ │ │ │ └── utils.lua │ │ │ └── tests │ │ │ └── test_calculator.lua │ │ ├── markdown │ │ │ └── test_repo │ │ │ ├── api.md │ │ │ ├── CONTRIBUTING.md │ │ │ ├── guide.md │ │ │ └── README.md │ │ ├── nix │ │ │ └── test_repo │ │ │ ├── .gitignore │ │ │ ├── default.nix │ │ │ ├── flake.nix │ │ │ ├── lib │ │ │ │ └── utils.nix │ │ │ ├── modules │ │ │ │ └── example.nix │ │ │ └── scripts │ │ │ └── hello.sh │ │ ├── perl │ │ │ └── test_repo │ │ │ ├── helper.pl │ │ │ └── main.pl │ │ ├── php │ │ │ └── test_repo │ │ │ ├── helper.php │ │ │ ├── index.php │ │ │ └── simple_var.php │ │ ├── python │ │ │ └── test_repo │ │ │ ├── .gitignore │ │ │ ├── custom_test │ │ │ │ ├── __init__.py │ │ │ │ └── advanced_features.py │ │ │ ├── examples │ │ │ │ ├── __init__.py │ │ │ │ └── user_management.py │ │ │ ├── ignore_this_dir_with_postfix │ │ │ │ └── ignored_module.py │ │ │ ├── scripts │ │ │ │ ├── __init__.py │ │ │ │ └── run_app.py │ │ │ └── test_repo │ │ │ ├── __init__.py │ │ │ ├── complex_types.py │ │ │ ├── models.py │ │ │ ├── name_collisions.py │ │ │ ├── nested_base.py │ │ │ ├── nested.py │ │ │ ├── overloaded.py │ │ │ ├── services.py │ │ │ ├── utils.py │ │ │ └── variables.py │ │ ├── r │ │ │ └── test_repo │ │ │ ├── .Rbuildignore │ │ │ ├── DESCRIPTION │ │ │ ├── examples │ │ │ │ └── analysis.R │ │ │ ├── NAMESPACE │ │ │ └── R │ │ │ ├── models.R │ │ │ └── utils.R │ │ ├── ruby │ │ │ └── test_repo │ │ │ ├── .solargraph.yml │ │ │ ├── examples │ │ │ │ └── user_management.rb │ │ │ ├── lib.rb │ │ │ ├── main.rb │ │ │ ├── models.rb │ │ │ ├── nested.rb │ │ │ ├── services.rb │ │ │ └── variables.rb │ │ ├── rust │ │ │ ├── test_repo │ │ │ │ ├── Cargo.lock │ │ │ │ ├── Cargo.toml │ │ │ │ └── src │ │ │ │ ├── lib.rs │ │ │ │ └── main.rs │ │ │ └── test_repo_2024 │ │ │ ├── Cargo.lock │ │ │ ├── Cargo.toml │ │ │ └── src │ │ │ ├── lib.rs │ │ │ └── main.rs │ │ ├── swift │ │ │ └── test_repo │ │ │ ├── Package.swift │ │ │ └── src │ │ │ ├── main.swift │ │ │ └── utils.swift │ │ ├── terraform │ │ │ └── test_repo │ │ │ ├── data.tf │ │ │ ├── main.tf │ │ │ ├── outputs.tf │ │ │ └── variables.tf │ │ ├── typescript │ │ │ └── test_repo │ │ │ ├── .serena │ │ │ │ └── project.yml │ │ │ ├── index.ts │ │ │ ├── tsconfig.json │ │ │ └── use_helper.ts │ │ └── zig │ │ └── test_repo │ │ ├── .gitignore │ │ ├── build.zig │ │ ├── src │ │ │ ├── calculator.zig │ │ │ ├── main.zig │ │ │ └── math_utils.zig │ │ └── zls.json │ ├── serena │ │ ├── __init__.py │ │ ├── __snapshots__ │ │ │ └── test_symbol_editing.ambr │ │ ├── config │ │ │ ├── __init__.py │ │ │ └── test_serena_config.py │ │ ├── test_edit_marker.py │ │ ├── test_mcp.py │ │ ├── test_serena_agent.py │ │ ├── test_symbol_editing.py │ │ ├── test_symbol.py │ │ ├── test_text_utils.py │ │ ├── test_tool_parameter_types.py │ │ └── util │ │ ├── test_exception.py │ │ └── test_file_system.py │ └── solidlsp │ ├── al │ │ └── test_al_basic.py │ ├── bash │ │ ├── __init__.py │ │ └── test_bash_basic.py │ ├── clojure │ │ ├── __init__.py │ │ └── test_clojure_basic.py │ ├── csharp │ │ └── test_csharp_basic.py │ ├── dart │ │ ├── __init__.py │ │ └── test_dart_basic.py │ ├── elixir │ │ ├── __init__.py │ │ ├── conftest.py │ │ ├── test_elixir_basic.py │ │ ├── test_elixir_ignored_dirs.py │ │ ├── test_elixir_integration.py │ │ └── test_elixir_symbol_retrieval.py │ ├── elm │ │ └── test_elm_basic.py │ ├── erlang │ │ ├── __init__.py │ │ ├── conftest.py │ │ ├── test_erlang_basic.py │ │ ├── test_erlang_ignored_dirs.py │ │ └── test_erlang_symbol_retrieval.py │ ├── go │ │ └── test_go_basic.py │ ├── java │ │ └── test_java_basic.py │ ├── kotlin │ │ └── test_kotlin_basic.py │ ├── lua │ │ └── test_lua_basic.py │ ├── markdown │ │ ├── __init__.py │ │ └── test_markdown_basic.py │ ├── nix │ │ └── test_nix_basic.py │ ├── perl │ │ └── test_perl_basic.py │ ├── php │ │ └── test_php_basic.py │ ├── python │ │ ├── test_python_basic.py │ │ ├── test_retrieval_with_ignored_dirs.py │ │ └── test_symbol_retrieval.py │ ├── r │ │ ├── __init__.py │ │ └── test_r_basic.py │ ├── ruby │ │ ├── test_ruby_basic.py │ │ └── test_ruby_symbol_retrieval.py │ ├── rust │ │ ├── test_rust_2024_edition.py │ │ └── test_rust_basic.py │ ├── swift │ │ └── test_swift_basic.py │ ├── terraform │ │ └── test_terraform_basic.py │ ├── typescript │ │ └── test_typescript_basic.py │ ├── util │ │ └── test_zip.py │ └── zig │ └── test_zig_basic.py └── uv.lock ``` # Files -------------------------------------------------------------------------------- /src/solidlsp/language_servers/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 | ``` -------------------------------------------------------------------------------- /src/solidlsp/ls.py: -------------------------------------------------------------------------------- ```python 1 | import dataclasses 2 | import hashlib 3 | import json 4 | import logging 5 | import os 6 | import pathlib 7 | import pickle 8 | import shutil 9 | import subprocess 10 | import threading 11 | from abc import ABC, abstractmethod 12 | from collections import defaultdict 13 | from collections.abc import Iterator 14 | from contextlib import contextmanager 15 | from copy import copy 16 | from pathlib import Path, PurePath 17 | from time import sleep 18 | from typing import Self, Union, cast 19 | 20 | import pathspec 21 | 22 | from serena.text_utils import MatchedConsecutiveLines 23 | from serena.util.file_system import match_path 24 | from solidlsp import ls_types 25 | from solidlsp.ls_config import Language, LanguageServerConfig 26 | from solidlsp.ls_exceptions import SolidLSPException 27 | from solidlsp.ls_handler import SolidLanguageServerHandler 28 | from solidlsp.ls_logger import LanguageServerLogger 29 | from solidlsp.ls_types import UnifiedSymbolInformation 30 | from solidlsp.ls_utils import FileUtils, PathUtils, TextUtils 31 | from solidlsp.lsp_protocol_handler import lsp_types 32 | from solidlsp.lsp_protocol_handler import lsp_types as LSPTypes 33 | from solidlsp.lsp_protocol_handler.lsp_constants import LSPConstants 34 | from solidlsp.lsp_protocol_handler.lsp_types import Definition, DefinitionParams, LocationLink, SymbolKind 35 | from solidlsp.lsp_protocol_handler.server import ( 36 | LSPError, 37 | ProcessLaunchInfo, 38 | StringDict, 39 | ) 40 | from solidlsp.settings import SolidLSPSettings 41 | 42 | GenericDocumentSymbol = Union[LSPTypes.DocumentSymbol, LSPTypes.SymbolInformation, ls_types.UnifiedSymbolInformation] 43 | 44 | 45 | @dataclasses.dataclass(kw_only=True) 46 | class ReferenceInSymbol: 47 | """A symbol retrieved when requesting reference to a symbol, together with the location of the reference""" 48 | 49 | symbol: ls_types.UnifiedSymbolInformation 50 | line: int 51 | character: int 52 | 53 | 54 | @dataclasses.dataclass 55 | class LSPFileBuffer: 56 | """ 57 | This class is used to store the contents of an open LSP file in memory. 58 | """ 59 | 60 | # uri of the file 61 | uri: str 62 | 63 | # The contents of the file 64 | contents: str 65 | 66 | # The version of the file 67 | version: int 68 | 69 | # The language id of the file 70 | language_id: str 71 | 72 | # reference count of the file 73 | ref_count: int 74 | 75 | content_hash: str = "" 76 | 77 | def __post_init__(self): 78 | self.content_hash = hashlib.md5(self.contents.encode("utf-8")).hexdigest() 79 | 80 | 81 | class SolidLanguageServer(ABC): 82 | """ 83 | The LanguageServer class provides a language agnostic interface to the Language Server Protocol. 84 | It is used to communicate with Language Servers of different programming languages. 85 | """ 86 | 87 | CACHE_FOLDER_NAME = "cache" 88 | 89 | # To be overridden and extended by subclasses 90 | def is_ignored_dirname(self, dirname: str) -> bool: 91 | """ 92 | A language-specific condition for directories that should always be ignored. For example, venv 93 | in Python and node_modules in JS/TS should be ignored always. 94 | """ 95 | return dirname.startswith(".") 96 | 97 | @classmethod 98 | def get_language_enum_instance(cls) -> Language: 99 | return Language.from_ls_class(cls) 100 | 101 | @classmethod 102 | def ls_resources_dir(cls, solidlsp_settings: SolidLSPSettings, mkdir: bool = True) -> str: 103 | """ 104 | Returns the directory where the language server resources are downloaded. 105 | This is used to store language server binaries, configuration files, etc. 106 | """ 107 | result = os.path.join(solidlsp_settings.ls_resources_dir, cls.__name__) 108 | 109 | # Migration of previously downloaded LS resources that were downloaded to a subdir of solidlsp instead of to the user's home 110 | pre_migration_ls_resources_dir = os.path.join(os.path.dirname(__file__), "language_servers", "static", cls.__name__) 111 | if os.path.exists(pre_migration_ls_resources_dir): 112 | if os.path.exists(result): 113 | # if the directory already exists, we just remove the old resources 114 | shutil.rmtree(result, ignore_errors=True) 115 | else: 116 | # move old resources to the new location 117 | shutil.move(pre_migration_ls_resources_dir, result) 118 | if mkdir: 119 | os.makedirs(result, exist_ok=True) 120 | return result 121 | 122 | @classmethod 123 | def create( 124 | cls, 125 | config: LanguageServerConfig, 126 | logger: LanguageServerLogger, 127 | repository_root_path: str, 128 | timeout: float | None = None, 129 | solidlsp_settings: SolidLSPSettings | None = None, 130 | ) -> "SolidLanguageServer": 131 | """ 132 | Creates a language specific LanguageServer instance based on the given configuration, and appropriate settings for the programming language. 133 | 134 | If language is Java, then ensure that jdk-17.0.6 or higher is installed, `java` is in PATH, and JAVA_HOME is set to the installation directory. 135 | If language is JS/TS, then ensure that node (v18.16.0 or higher) is installed and in PATH. 136 | 137 | :param repository_root_path: The root path of the repository. 138 | :param config: language server configuration. 139 | :param logger: The logger to use. 140 | :param timeout: the timeout for requests to the language server. If None, no timeout will be used. 141 | :param solidlsp_settings: additional settings 142 | :return LanguageServer: A language specific LanguageServer instance. 143 | """ 144 | ls: SolidLanguageServer 145 | if solidlsp_settings is None: 146 | solidlsp_settings = SolidLSPSettings() 147 | 148 | ls_class = config.code_language.get_ls_class() 149 | # For now, we assume that all language server implementations have the same signature of the constructor 150 | # (which, unfortunately, differs from the signature of the base class). 151 | # If this assumption is ever violated, we need branching logic here. 152 | ls = ls_class(config, logger, repository_root_path, solidlsp_settings) # type: ignore 153 | ls.set_request_timeout(timeout) 154 | return ls 155 | 156 | def __init__( 157 | self, 158 | config: LanguageServerConfig, 159 | logger: LanguageServerLogger, 160 | repository_root_path: str, 161 | process_launch_info: ProcessLaunchInfo, 162 | language_id: str, 163 | solidlsp_settings: SolidLSPSettings, 164 | ): 165 | """ 166 | Initializes a LanguageServer instance. 167 | 168 | Do not instantiate this class directly. Use `LanguageServer.create` method instead. 169 | 170 | :param config: The Multilspy configuration. 171 | :param logger: The logger to use. 172 | :param repository_root_path: The root path of the repository. 173 | :param process_launch_info: Each language server has a specific command used to start the server. 174 | This parameter is the command to launch the language server process. 175 | The command must pass appropriate flags to the binary, so that it runs in the stdio mode, 176 | as opposed to HTTP, TCP modes supported by some language servers. 177 | """ 178 | self._solidlsp_settings = solidlsp_settings 179 | self.logger = logger 180 | self.repository_root_path: str = repository_root_path 181 | self.logger.log( 182 | f"Creating language server instance for {repository_root_path=} with {language_id=} and process launch info: {process_launch_info}", 183 | logging.DEBUG, 184 | ) 185 | 186 | self.language_id = language_id 187 | self.open_file_buffers: dict[str, LSPFileBuffer] = {} 188 | self.language = Language(language_id) 189 | 190 | # load cache first to prevent any racing conditions due to asyncio stuff 191 | self._document_symbols_cache: dict[ 192 | str, tuple[str, tuple[list[ls_types.UnifiedSymbolInformation], list[ls_types.UnifiedSymbolInformation]]] 193 | ] = {} 194 | """Maps file paths to a tuple of (file_content_hash, result_of_request_document_symbols)""" 195 | self._cache_lock = threading.Lock() 196 | self._cache_has_changed: bool = False 197 | self.load_cache() 198 | 199 | self.server_started = False 200 | self.completions_available = threading.Event() 201 | if config.trace_lsp_communication: 202 | 203 | def logging_fn(source: str, target: str, msg: StringDict | str): 204 | self.logger.log(f"LSP: {source} -> {target}: {msg!s}", self.logger.logger.level) 205 | 206 | else: 207 | logging_fn = None 208 | 209 | # cmd is obtained from the child classes, which provide the language specific command to start the language server 210 | # LanguageServerHandler provides the functionality to start the language server and communicate with it 211 | self.logger.log( 212 | f"Creating language server instance with {language_id=} and process launch info: {process_launch_info}", logging.DEBUG 213 | ) 214 | self.server = SolidLanguageServerHandler( 215 | process_launch_info, 216 | logger=logging_fn, 217 | start_independent_lsp_process=config.start_independent_lsp_process, 218 | ) 219 | 220 | # Set up the pathspec matcher for the ignored paths 221 | # for all absolute paths in ignored_paths, convert them to relative paths 222 | processed_patterns = [] 223 | for pattern in set(config.ignored_paths): 224 | # Normalize separators (pathspec expects forward slashes) 225 | pattern = pattern.replace(os.path.sep, "/") 226 | processed_patterns.append(pattern) 227 | self.logger.log(f"Processing {len(processed_patterns)} ignored paths from the config", logging.DEBUG) 228 | 229 | # Create a pathspec matcher from the processed patterns 230 | self._ignore_spec = pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, processed_patterns) 231 | 232 | self._server_context = None 233 | self._request_timeout: float | None = None 234 | 235 | self._has_waited_for_cross_file_references = False 236 | 237 | def _get_wait_time_for_cross_file_referencing(self) -> float: 238 | """Meant to be overridden by subclasses for LS that don't have a reliable "finished initializing" signal. 239 | 240 | LS may return incomplete results on calls to `request_references` (only references found in the same file), 241 | if the LS is not fully initialized yet. 242 | """ 243 | return 2 244 | 245 | def set_request_timeout(self, timeout: float | None) -> None: 246 | """ 247 | :param timeout: the timeout, in seconds, for requests to the language server. 248 | """ 249 | self.server.set_request_timeout(timeout) 250 | 251 | def get_ignore_spec(self) -> pathspec.PathSpec: 252 | """Returns the pathspec matcher for the paths that were configured to be ignored through 253 | the multilspy config. 254 | 255 | This is is a subset of the full language-specific ignore spec that determines 256 | which files are relevant for the language server. 257 | 258 | This matcher is useful for operations outside of the language server, 259 | such as when searching for relevant non-language files in the project. 260 | """ 261 | return self._ignore_spec 262 | 263 | def is_ignored_path(self, relative_path: str, ignore_unsupported_files: bool = True) -> bool: 264 | """ 265 | Determine if a path should be ignored based on file type 266 | and ignore patterns. 267 | 268 | :param relative_path: Relative path to check 269 | :param ignore_unsupported_files: whether files that are not supported source files should be ignored 270 | 271 | :return: True if the path should be ignored, False otherwise 272 | """ 273 | abs_path = os.path.join(self.repository_root_path, relative_path) 274 | if not os.path.exists(abs_path): 275 | raise FileNotFoundError(f"File {abs_path} not found, the ignore check cannot be performed") 276 | 277 | # Check file extension if it's a file 278 | is_file = os.path.isfile(abs_path) 279 | if is_file and ignore_unsupported_files: 280 | fn_matcher = self.language.get_source_fn_matcher() 281 | if not fn_matcher.is_relevant_filename(abs_path): 282 | return True 283 | 284 | # Create normalized path for consistent handling 285 | rel_path = Path(relative_path) 286 | 287 | # Check each part of the path against always fulfilled ignore conditions 288 | dir_parts = rel_path.parts 289 | if is_file: 290 | dir_parts = dir_parts[:-1] 291 | for part in dir_parts: 292 | if not part: # Skip empty parts (e.g., from leading '/') 293 | continue 294 | if self.is_ignored_dirname(part): 295 | return True 296 | 297 | return match_path(relative_path, self.get_ignore_spec(), root_path=self.repository_root_path) 298 | 299 | def _shutdown(self, timeout: float = 5.0): 300 | """ 301 | A robust shutdown process designed to terminate cleanly on all platforms, including Windows, 302 | by explicitly closing all I/O pipes. 303 | """ 304 | if not self.server.is_running(): 305 | self.logger.log("Server process not running, skipping shutdown.", logging.DEBUG) 306 | return 307 | 308 | self.logger.log(f"Initiating final robust shutdown with a {timeout}s timeout...", logging.INFO) 309 | process = self.server.process 310 | 311 | # --- Main Shutdown Logic --- 312 | # Stage 1: Graceful Termination Request 313 | # Send LSP shutdown and close stdin to signal no more input. 314 | try: 315 | self.logger.log("Sending LSP shutdown request...", logging.DEBUG) 316 | # Use a thread to timeout the LSP shutdown call since it can hang 317 | shutdown_thread = threading.Thread(target=self.server.shutdown) 318 | shutdown_thread.daemon = True 319 | shutdown_thread.start() 320 | shutdown_thread.join(timeout=2.0) # 2 second timeout for LSP shutdown 321 | 322 | if shutdown_thread.is_alive(): 323 | self.logger.log("LSP shutdown request timed out, proceeding to terminate...", logging.DEBUG) 324 | else: 325 | self.logger.log("LSP shutdown request completed.", logging.DEBUG) 326 | 327 | if process.stdin and not process.stdin.is_closing(): 328 | process.stdin.close() 329 | self.logger.log("Stage 1 shutdown complete.", logging.DEBUG) 330 | except Exception as e: 331 | self.logger.log(f"Exception during graceful shutdown: {e}", logging.DEBUG) 332 | # Ignore errors here, we are proceeding to terminate anyway. 333 | 334 | # Stage 2: Terminate and Wait for Process to Exit 335 | self.logger.log(f"Terminating process {process.pid}, current status: {process.poll()}", logging.DEBUG) 336 | process.terminate() 337 | 338 | # Stage 3: Wait for process termination with timeout 339 | try: 340 | self.logger.log(f"Waiting for process {process.pid} to terminate...", logging.DEBUG) 341 | exit_code = process.wait(timeout=timeout) 342 | self.logger.log(f"Language server process terminated successfully with exit code {exit_code}.", logging.INFO) 343 | except subprocess.TimeoutExpired: 344 | # If termination failed, forcefully kill the process 345 | self.logger.log(f"Process {process.pid} termination timed out, killing process forcefully...", logging.WARNING) 346 | process.kill() 347 | try: 348 | exit_code = process.wait(timeout=2.0) 349 | self.logger.log(f"Language server process killed successfully with exit code {exit_code}.", logging.INFO) 350 | except subprocess.TimeoutExpired: 351 | self.logger.log(f"Process {process.pid} could not be killed within timeout.", logging.ERROR) 352 | except Exception as e: 353 | self.logger.log(f"Error during process shutdown: {e}", logging.ERROR) 354 | 355 | @contextmanager 356 | def start_server(self) -> Iterator["SolidLanguageServer"]: 357 | self.start() 358 | yield self 359 | self.stop() 360 | 361 | def _start_server_process(self) -> None: 362 | self.server_started = True 363 | self._start_server() 364 | 365 | @abstractmethod 366 | def _start_server(self): 367 | pass 368 | 369 | @contextmanager 370 | def open_file(self, relative_file_path: str) -> Iterator[LSPFileBuffer]: 371 | """ 372 | Open a file in the Language Server. This is required before making any requests to the Language Server. 373 | 374 | :param relative_file_path: The relative path of the file to open. 375 | """ 376 | if not self.server_started: 377 | self.logger.log( 378 | "open_file called before Language Server started", 379 | logging.ERROR, 380 | ) 381 | raise SolidLSPException("Language Server not started") 382 | 383 | absolute_file_path = str(PurePath(self.repository_root_path, relative_file_path)) 384 | uri = pathlib.Path(absolute_file_path).as_uri() 385 | 386 | if uri in self.open_file_buffers: 387 | assert self.open_file_buffers[uri].uri == uri 388 | assert self.open_file_buffers[uri].ref_count >= 1 389 | 390 | self.open_file_buffers[uri].ref_count += 1 391 | yield self.open_file_buffers[uri] 392 | self.open_file_buffers[uri].ref_count -= 1 393 | else: 394 | contents = FileUtils.read_file(self.logger, absolute_file_path) 395 | 396 | version = 0 397 | self.open_file_buffers[uri] = LSPFileBuffer(uri, contents, version, self.language_id, 1) 398 | 399 | self.server.notify.did_open_text_document( 400 | { 401 | LSPConstants.TEXT_DOCUMENT: { 402 | LSPConstants.URI: uri, 403 | LSPConstants.LANGUAGE_ID: self.language_id, 404 | LSPConstants.VERSION: 0, 405 | LSPConstants.TEXT: contents, 406 | } 407 | } 408 | ) 409 | yield self.open_file_buffers[uri] 410 | self.open_file_buffers[uri].ref_count -= 1 411 | 412 | if self.open_file_buffers[uri].ref_count == 0: 413 | self.server.notify.did_close_text_document( 414 | { 415 | LSPConstants.TEXT_DOCUMENT: { 416 | LSPConstants.URI: uri, 417 | } 418 | } 419 | ) 420 | del self.open_file_buffers[uri] 421 | 422 | def insert_text_at_position(self, relative_file_path: str, line: int, column: int, text_to_be_inserted: str) -> ls_types.Position: 423 | """ 424 | Insert text at the given line and column in the given file and return 425 | the updated cursor position after inserting the text. 426 | 427 | :param relative_file_path: The relative path of the file to open. 428 | :param line: The line number at which text should be inserted. 429 | :param column: The column number at which text should be inserted. 430 | :param text_to_be_inserted: The text to insert. 431 | """ 432 | if not self.server_started: 433 | self.logger.log( 434 | "insert_text_at_position called before Language Server started", 435 | logging.ERROR, 436 | ) 437 | raise SolidLSPException("Language Server not started") 438 | 439 | absolute_file_path = str(PurePath(self.repository_root_path, relative_file_path)) 440 | uri = pathlib.Path(absolute_file_path).as_uri() 441 | 442 | # Ensure the file is open 443 | assert uri in self.open_file_buffers 444 | 445 | file_buffer = self.open_file_buffers[uri] 446 | file_buffer.version += 1 447 | 448 | new_contents, new_l, new_c = TextUtils.insert_text_at_position(file_buffer.contents, line, column, text_to_be_inserted) 449 | file_buffer.contents = new_contents 450 | self.server.notify.did_change_text_document( 451 | { 452 | LSPConstants.TEXT_DOCUMENT: { 453 | LSPConstants.VERSION: file_buffer.version, 454 | LSPConstants.URI: file_buffer.uri, 455 | }, 456 | LSPConstants.CONTENT_CHANGES: [ 457 | { 458 | LSPConstants.RANGE: { 459 | "start": {"line": line, "character": column}, 460 | "end": {"line": line, "character": column}, 461 | }, 462 | "text": text_to_be_inserted, 463 | } 464 | ], 465 | } 466 | ) 467 | return ls_types.Position(line=new_l, character=new_c) 468 | 469 | def delete_text_between_positions( 470 | self, 471 | relative_file_path: str, 472 | start: ls_types.Position, 473 | end: ls_types.Position, 474 | ) -> str: 475 | """ 476 | Delete text between the given start and end positions in the given file and return the deleted text. 477 | """ 478 | if not self.server_started: 479 | self.logger.log( 480 | "insert_text_at_position called before Language Server started", 481 | logging.ERROR, 482 | ) 483 | raise SolidLSPException("Language Server not started") 484 | 485 | absolute_file_path = str(PurePath(self.repository_root_path, relative_file_path)) 486 | uri = pathlib.Path(absolute_file_path).as_uri() 487 | 488 | # Ensure the file is open 489 | assert uri in self.open_file_buffers 490 | 491 | file_buffer = self.open_file_buffers[uri] 492 | file_buffer.version += 1 493 | new_contents, deleted_text = TextUtils.delete_text_between_positions( 494 | file_buffer.contents, start_line=start["line"], start_col=start["character"], end_line=end["line"], end_col=end["character"] 495 | ) 496 | file_buffer.contents = new_contents 497 | self.server.notify.did_change_text_document( 498 | { 499 | LSPConstants.TEXT_DOCUMENT: { 500 | LSPConstants.VERSION: file_buffer.version, 501 | LSPConstants.URI: file_buffer.uri, 502 | }, 503 | LSPConstants.CONTENT_CHANGES: [{LSPConstants.RANGE: {"start": start, "end": end}, "text": ""}], 504 | } 505 | ) 506 | return deleted_text 507 | 508 | def _send_definition_request(self, definition_params: DefinitionParams) -> Definition | list[LocationLink] | None: 509 | return self.server.send.definition(definition_params) 510 | 511 | def request_definition(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]: 512 | """ 513 | Raise a [textDocument/definition](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_definition) request to the Language Server 514 | for the symbol at the given line and column in the given file. Wait for the response and return the result. 515 | 516 | :param relative_file_path: The relative path of the file that has the symbol for which definition should be looked up 517 | :param line: The line number of the symbol 518 | :param column: The column number of the symbol 519 | 520 | :return List[multilspy_types.Location]: A list of locations where the symbol is defined 521 | """ 522 | if not self.server_started: 523 | self.logger.log( 524 | "request_definition called before Language Server started", 525 | logging.ERROR, 526 | ) 527 | raise SolidLSPException("Language Server not started") 528 | 529 | if not self._has_waited_for_cross_file_references: 530 | # Some LS require waiting for a while before they can return cross-file definitions. 531 | # This is a workaround for such LS that don't have a reliable "finished initializing" signal. 532 | sleep(self._get_wait_time_for_cross_file_referencing()) 533 | self._has_waited_for_cross_file_references = True 534 | 535 | with self.open_file(relative_file_path): 536 | # sending request to the language server and waiting for response 537 | definition_params = cast( 538 | DefinitionParams, 539 | { 540 | LSPConstants.TEXT_DOCUMENT: { 541 | LSPConstants.URI: pathlib.Path(str(PurePath(self.repository_root_path, relative_file_path))).as_uri() 542 | }, 543 | LSPConstants.POSITION: { 544 | LSPConstants.LINE: line, 545 | LSPConstants.CHARACTER: column, 546 | }, 547 | }, 548 | ) 549 | response = self._send_definition_request(definition_params) 550 | 551 | ret: list[ls_types.Location] = [] 552 | if isinstance(response, list): 553 | # response is either of type Location[] or LocationLink[] 554 | for item in response: 555 | assert isinstance(item, dict) 556 | if LSPConstants.URI in item and LSPConstants.RANGE in item: 557 | new_item: ls_types.Location = {} 558 | new_item.update(item) 559 | new_item["absolutePath"] = PathUtils.uri_to_path(new_item["uri"]) 560 | new_item["relativePath"] = PathUtils.get_relative_path(new_item["absolutePath"], self.repository_root_path) 561 | ret.append(ls_types.Location(new_item)) 562 | elif LSPConstants.TARGET_URI in item and LSPConstants.TARGET_RANGE in item and LSPConstants.TARGET_SELECTION_RANGE in item: 563 | new_item: ls_types.Location = {} 564 | new_item["uri"] = item[LSPConstants.TARGET_URI] 565 | new_item["absolutePath"] = PathUtils.uri_to_path(new_item["uri"]) 566 | new_item["relativePath"] = PathUtils.get_relative_path(new_item["absolutePath"], self.repository_root_path) 567 | new_item["range"] = item[LSPConstants.TARGET_SELECTION_RANGE] 568 | ret.append(ls_types.Location(**new_item)) 569 | else: 570 | assert False, f"Unexpected response from Language Server: {item}" 571 | elif isinstance(response, dict): 572 | # response is of type Location 573 | assert LSPConstants.URI in response 574 | assert LSPConstants.RANGE in response 575 | 576 | new_item: ls_types.Location = {} 577 | new_item.update(response) 578 | new_item["absolutePath"] = PathUtils.uri_to_path(new_item["uri"]) 579 | new_item["relativePath"] = PathUtils.get_relative_path(new_item["absolutePath"], self.repository_root_path) 580 | ret.append(ls_types.Location(**new_item)) 581 | elif response is None: 582 | # Some language servers return None when they cannot find a definition 583 | # This is expected for certain symbol types like generics or types with incomplete information 584 | self.logger.log( 585 | f"Language server returned None for definition request at {relative_file_path}:{line}:{column}", 586 | logging.WARNING, 587 | ) 588 | else: 589 | assert False, f"Unexpected response from Language Server: {response}" 590 | 591 | return ret 592 | 593 | # Some LS cause problems with this, so the call is isolated from the rest to allow overriding in subclasses 594 | def _send_references_request(self, relative_file_path: str, line: int, column: int) -> list[lsp_types.Location] | None: 595 | return self.server.send.references( 596 | { 597 | "textDocument": {"uri": PathUtils.path_to_uri(os.path.join(self.repository_root_path, relative_file_path))}, 598 | "position": {"line": line, "character": column}, 599 | "context": {"includeDeclaration": False}, 600 | } 601 | ) 602 | 603 | def request_references(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]: 604 | """ 605 | Raise a [textDocument/references](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_references) request to the Language Server 606 | to find references to the symbol at the given line and column in the given file. Wait for the response and return the result. 607 | Filters out references located in ignored directories. 608 | 609 | :param relative_file_path: The relative path of the file that has the symbol for which references should be looked up 610 | :param line: The line number of the symbol 611 | :param column: The column number of the symbol 612 | 613 | :return: A list of locations where the symbol is referenced (excluding ignored directories) 614 | """ 615 | if not self.server_started: 616 | self.logger.log( 617 | "request_references called before Language Server started", 618 | logging.ERROR, 619 | ) 620 | raise SolidLSPException("Language Server not started") 621 | 622 | if not self._has_waited_for_cross_file_references: 623 | # Some LS require waiting for a while before they can return cross-file references. 624 | # This is a workaround for such LS that don't have a reliable "finished initializing" signal. 625 | sleep(self._get_wait_time_for_cross_file_referencing()) 626 | self._has_waited_for_cross_file_references = True 627 | 628 | with self.open_file(relative_file_path): 629 | try: 630 | response = self._send_references_request(relative_file_path, line=line, column=column) 631 | except Exception as e: 632 | # Catch LSP internal error (-32603) and raise a more informative exception 633 | if isinstance(e, LSPError) and getattr(e, "code", None) == -32603: 634 | raise RuntimeError( 635 | f"LSP internal error (-32603) when requesting references for {relative_file_path}:{line}:{column}. " 636 | "This often occurs when requesting references for a symbol not referenced in the expected way. " 637 | ) from e 638 | raise 639 | if response is None: 640 | return [] 641 | 642 | ret: list[ls_types.Location] = [] 643 | assert isinstance(response, list), f"Unexpected response from Language Server (expected list, got {type(response)}): {response}" 644 | for item in response: 645 | assert isinstance(item, dict), f"Unexpected response from Language Server (expected dict, got {type(item)}): {item}" 646 | assert LSPConstants.URI in item 647 | assert LSPConstants.RANGE in item 648 | 649 | abs_path = PathUtils.uri_to_path(item[LSPConstants.URI]) 650 | if not Path(abs_path).is_relative_to(self.repository_root_path): 651 | self.logger.log( 652 | "Found a reference in a path outside the repository, probably the LS is parsing things in installed packages or in the standardlib! " 653 | f"Path: {abs_path}. This is a bug but we currently simply skip these references.", 654 | logging.WARNING, 655 | ) 656 | continue 657 | 658 | rel_path = Path(abs_path).relative_to(self.repository_root_path) 659 | if self.is_ignored_path(str(rel_path)): 660 | self.logger.log(f"Ignoring reference in {rel_path} since it should be ignored", logging.DEBUG) 661 | continue 662 | 663 | new_item: ls_types.Location = {} 664 | new_item.update(item) 665 | new_item["absolutePath"] = str(abs_path) 666 | new_item["relativePath"] = str(rel_path) 667 | ret.append(ls_types.Location(**new_item)) 668 | 669 | return ret 670 | 671 | def request_text_document_diagnostics(self, relative_file_path: str) -> list[ls_types.Diagnostic]: 672 | """ 673 | Raise a [textDocument/diagnostic](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_diagnostic) request to the Language Server 674 | to find diagnostics for the given file. Wait for the response and return the result. 675 | 676 | :param relative_file_path: The relative path of the file to retrieve diagnostics for 677 | 678 | :return: A list of diagnostics for the file 679 | """ 680 | if not self.server_started: 681 | self.logger.log( 682 | "request_text_document_diagnostics called before Language Server started", 683 | logging.ERROR, 684 | ) 685 | raise SolidLSPException("Language Server not started") 686 | 687 | with self.open_file(relative_file_path): 688 | response = self.server.send.text_document_diagnostic( 689 | { 690 | LSPConstants.TEXT_DOCUMENT: { 691 | LSPConstants.URI: pathlib.Path(str(PurePath(self.repository_root_path, relative_file_path))).as_uri() 692 | } 693 | } 694 | ) 695 | 696 | if response is None: 697 | return [] 698 | 699 | assert isinstance(response, dict), f"Unexpected response from Language Server (expected list, got {type(response)}): {response}" 700 | ret: list[ls_types.Diagnostic] = [] 701 | for item in response["items"]: 702 | new_item: ls_types.Diagnostic = { 703 | "uri": pathlib.Path(str(PurePath(self.repository_root_path, relative_file_path))).as_uri(), 704 | "severity": item["severity"], 705 | "message": item["message"], 706 | "range": item["range"], 707 | "code": item["code"], 708 | } 709 | ret.append(ls_types.Diagnostic(new_item)) 710 | 711 | return ret 712 | 713 | def retrieve_full_file_content(self, file_path: str) -> str: 714 | """ 715 | Retrieve the full content of the given file. 716 | """ 717 | if os.path.isabs(file_path): 718 | file_path = os.path.relpath(file_path, self.repository_root_path) 719 | with self.open_file(file_path) as file_data: 720 | return file_data.contents 721 | 722 | def retrieve_content_around_line( 723 | self, relative_file_path: str, line: int, context_lines_before: int = 0, context_lines_after: int = 0 724 | ) -> MatchedConsecutiveLines: 725 | """ 726 | Retrieve the content of the given file around the given line. 727 | 728 | :param relative_file_path: The relative path of the file to retrieve the content from 729 | :param line: The line number to retrieve the content around 730 | :param context_lines_before: The number of lines to retrieve before the given line 731 | :param context_lines_after: The number of lines to retrieve after the given line 732 | 733 | :return MatchedConsecutiveLines: A container with the desired lines. 734 | """ 735 | with self.open_file(relative_file_path) as file_data: 736 | file_contents = file_data.contents 737 | return MatchedConsecutiveLines.from_file_contents( 738 | file_contents, 739 | line=line, 740 | context_lines_before=context_lines_before, 741 | context_lines_after=context_lines_after, 742 | source_file_path=relative_file_path, 743 | ) 744 | 745 | def request_completions( 746 | self, relative_file_path: str, line: int, column: int, allow_incomplete: bool = False 747 | ) -> list[ls_types.CompletionItem]: 748 | """ 749 | Raise a [textDocument/completion](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_completion) request to the Language Server 750 | to find completions at the given line and column in the given file. Wait for the response and return the result. 751 | 752 | :param relative_file_path: The relative path of the file that has the symbol for which completions should be looked up 753 | :param line: The line number of the symbol 754 | :param column: The column number of the symbol 755 | 756 | :return List[multilspy_types.CompletionItem]: A list of completions 757 | """ 758 | with self.open_file(relative_file_path): 759 | open_file_buffer = self.open_file_buffers[pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)).as_uri()] 760 | completion_params: LSPTypes.CompletionParams = { 761 | "position": {"line": line, "character": column}, 762 | "textDocument": {"uri": open_file_buffer.uri}, 763 | "context": {"triggerKind": LSPTypes.CompletionTriggerKind.Invoked}, 764 | } 765 | response: list[LSPTypes.CompletionItem] | LSPTypes.CompletionList | None = None 766 | 767 | num_retries = 0 768 | while response is None or (response["isIncomplete"] and num_retries < 30): 769 | self.completions_available.wait() 770 | response: list[LSPTypes.CompletionItem] | LSPTypes.CompletionList | None = self.server.send.completion(completion_params) 771 | if isinstance(response, list): 772 | response = {"items": response, "isIncomplete": False} 773 | num_retries += 1 774 | 775 | # TODO: Understand how to appropriately handle `isIncomplete` 776 | if response is None or (response["isIncomplete"] and not (allow_incomplete)): 777 | return [] 778 | 779 | if "items" in response: 780 | response = response["items"] 781 | 782 | response = cast(list[LSPTypes.CompletionItem], response) 783 | 784 | # TODO: Handle the case when the completion is a keyword 785 | items = [item for item in response if item["kind"] != LSPTypes.CompletionItemKind.Keyword] 786 | 787 | completions_list: list[ls_types.CompletionItem] = [] 788 | 789 | for item in items: 790 | assert "insertText" in item or "textEdit" in item 791 | assert "kind" in item 792 | completion_item = {} 793 | if "detail" in item: 794 | completion_item["detail"] = item["detail"] 795 | 796 | if "label" in item: 797 | completion_item["completionText"] = item["label"] 798 | completion_item["kind"] = item["kind"] 799 | elif "insertText" in item: 800 | completion_item["completionText"] = item["insertText"] 801 | completion_item["kind"] = item["kind"] 802 | elif "textEdit" in item and "newText" in item["textEdit"]: 803 | completion_item["completionText"] = item["textEdit"]["newText"] 804 | completion_item["kind"] = item["kind"] 805 | elif "textEdit" in item and "range" in item["textEdit"]: 806 | new_dot_lineno, new_dot_colno = ( 807 | completion_params["position"]["line"], 808 | completion_params["position"]["character"], 809 | ) 810 | assert all( 811 | ( 812 | item["textEdit"]["range"]["start"]["line"] == new_dot_lineno, 813 | item["textEdit"]["range"]["start"]["character"] == new_dot_colno, 814 | item["textEdit"]["range"]["start"]["line"] == item["textEdit"]["range"]["end"]["line"], 815 | item["textEdit"]["range"]["start"]["character"] == item["textEdit"]["range"]["end"]["character"], 816 | ) 817 | ) 818 | 819 | completion_item["completionText"] = item["textEdit"]["newText"] 820 | completion_item["kind"] = item["kind"] 821 | elif "textEdit" in item and "insert" in item["textEdit"]: 822 | assert False 823 | else: 824 | assert False 825 | 826 | completion_item = ls_types.CompletionItem(**completion_item) 827 | completions_list.append(completion_item) 828 | 829 | return [json.loads(json_repr) for json_repr in set(json.dumps(item, sort_keys=True) for item in completions_list)] 830 | 831 | def request_document_symbols( 832 | self, relative_file_path: str, include_body: bool = False 833 | ) -> tuple[list[ls_types.UnifiedSymbolInformation], list[ls_types.UnifiedSymbolInformation]]: 834 | """ 835 | Raise a [textDocument/documentSymbol](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_documentSymbol) request to the Language Server 836 | to find symbols in the given file. Wait for the response and return the result. 837 | 838 | :param relative_file_path: The relative path of the file that has the symbols 839 | :param include_body: whether to include the body of the symbols in the result. 840 | :return: A list of symbols in the file, and a list of root symbols that represent the tree structure of the symbols. 841 | All symbols will have a location, a children, and a parent attribute, 842 | where the parent attribute is None for root symbols. 843 | Note that this is slightly different from the call to request_full_symbol_tree, 844 | where the parent attribute will be the file symbol which in turn may have a package symbol as parent. 845 | If you need a symbol tree that contains file symbols as well, you should use `request_full_symbol_tree` instead. 846 | """ 847 | # TODO: it's kinda dumb to not use the cache if include_body is False after include_body was True once 848 | # Should be fixed in the future, it's a small performance optimization 849 | cache_key = f"{relative_file_path}-{include_body}" 850 | with self.open_file(relative_file_path) as file_data: 851 | with self._cache_lock: 852 | file_hash_and_result = self._document_symbols_cache.get(cache_key) 853 | if file_hash_and_result is not None: 854 | file_hash, result = file_hash_and_result 855 | if file_hash == file_data.content_hash: 856 | self.logger.log(f"Returning cached document symbols for {relative_file_path}", logging.DEBUG) 857 | return result 858 | else: 859 | self.logger.log(f"Content for {relative_file_path} has changed. Will overwrite in-memory cache", logging.DEBUG) 860 | else: 861 | self.logger.log(f"No cache hit for symbols with {include_body=} in {relative_file_path}", logging.DEBUG) 862 | 863 | self.logger.log(f"Requesting document symbols for {relative_file_path} from the Language Server", logging.DEBUG) 864 | response = self.server.send.document_symbol( 865 | {"textDocument": {"uri": pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)).as_uri()}} 866 | ) 867 | if response is None: 868 | self.logger.log( 869 | f"Received None response from the Language Server for document symbols in {relative_file_path}. " 870 | f"This means the language server can't understand this file (possibly due to syntax errors). It may also be due to a bug or misconfiguration of the LS. " 871 | f"Returning empty list", 872 | logging.WARNING, 873 | ) 874 | return [], [] 875 | assert isinstance(response, list), f"Unexpected response from Language Server: {response}" 876 | self.logger.log( 877 | f"Received {len(response)} document symbols for {relative_file_path} from the Language Server", 878 | logging.DEBUG, 879 | ) 880 | 881 | def turn_item_into_symbol_with_children(item: GenericDocumentSymbol): 882 | item = cast(ls_types.UnifiedSymbolInformation, item) 883 | absolute_path = os.path.join(self.repository_root_path, relative_file_path) 884 | 885 | # handle missing entries in location 886 | if "location" not in item: 887 | uri = pathlib.Path(absolute_path).as_uri() 888 | assert "range" in item 889 | tree_location = ls_types.Location( 890 | uri=uri, 891 | range=item["range"], 892 | absolutePath=absolute_path, 893 | relativePath=relative_file_path, 894 | ) 895 | item["location"] = tree_location 896 | location = item["location"] 897 | if "absolutePath" not in location: 898 | location["absolutePath"] = absolute_path 899 | if "relativePath" not in location: 900 | location["relativePath"] = relative_file_path 901 | if include_body: 902 | item["body"] = self.retrieve_symbol_body(item) 903 | # handle missing selectionRange 904 | if "selectionRange" not in item: 905 | if "range" in item: 906 | item["selectionRange"] = item["range"] 907 | else: 908 | item["selectionRange"] = item["location"]["range"] 909 | children = item.get(LSPConstants.CHILDREN, []) 910 | for child in children: 911 | child["parent"] = item 912 | item[LSPConstants.CHILDREN] = children 913 | 914 | flat_all_symbol_list: list[ls_types.UnifiedSymbolInformation] = [] 915 | root_nodes: list[ls_types.UnifiedSymbolInformation] = [] 916 | for root_item in response: 917 | if "range" not in root_item and "location" not in root_item: 918 | if root_item["kind"] in [SymbolKind.File, SymbolKind.Module]: 919 | ... 920 | 921 | # mutation is more convenient than creating a new dict, 922 | # so we cast and rename the var after the mutating call to turn_item_into_symbol_with_children 923 | # which turned and item into a "symbol" 924 | turn_item_into_symbol_with_children(root_item) 925 | root_symbol = cast(ls_types.UnifiedSymbolInformation, root_item) 926 | root_symbol["parent"] = None 927 | 928 | root_nodes.append(root_symbol) 929 | assert isinstance(root_symbol, dict) 930 | assert LSPConstants.NAME in root_symbol 931 | assert LSPConstants.KIND in root_symbol 932 | 933 | if LSPConstants.CHILDREN in root_symbol: 934 | # TODO: l_tree should be a list of TreeRepr. Define the following function to return TreeRepr as well 935 | 936 | def visit_tree_nodes_and_build_tree_repr(node: GenericDocumentSymbol) -> list[ls_types.UnifiedSymbolInformation]: 937 | node = cast(ls_types.UnifiedSymbolInformation, node) 938 | l: list[ls_types.UnifiedSymbolInformation] = [] 939 | turn_item_into_symbol_with_children(node) 940 | assert LSPConstants.CHILDREN in node 941 | children = node[LSPConstants.CHILDREN] 942 | l.append(node) 943 | for child in children: 944 | l.extend(visit_tree_nodes_and_build_tree_repr(child)) 945 | return l 946 | 947 | flat_all_symbol_list.extend(visit_tree_nodes_and_build_tree_repr(root_symbol)) 948 | else: 949 | flat_all_symbol_list.append(ls_types.UnifiedSymbolInformation(**root_symbol)) 950 | 951 | result = flat_all_symbol_list, root_nodes 952 | self.logger.log(f"Caching document symbols for {relative_file_path}", logging.DEBUG) 953 | with self._cache_lock: 954 | self._document_symbols_cache[cache_key] = (file_data.content_hash, result) 955 | self._cache_has_changed = True 956 | return result 957 | 958 | def request_full_symbol_tree( 959 | self, within_relative_path: str | None = None, include_body: bool = False 960 | ) -> list[ls_types.UnifiedSymbolInformation]: 961 | """ 962 | Will go through all files in the project or within a relative path and build a tree of symbols. 963 | Note: this may be slow the first time it is called, especially if `within_relative_path` is not used to restrict the search. 964 | 965 | For each file, a symbol of kind File (2) will be created. For directories, a symbol of kind Package (4) will be created. 966 | All symbols will have a children attribute, thereby representing the tree structure of all symbols in the project 967 | that are within the repository. 968 | All symbols except the root packages will have a parent attribute. 969 | Will ignore directories starting with '.', language-specific defaults 970 | and user-configured directories (e.g. from .gitignore). 971 | 972 | :param within_relative_path: pass a relative path to only consider symbols within this path. 973 | If a file is passed, only the symbols within this file will be considered. 974 | If a directory is passed, all files within this directory will be considered. 975 | :param include_body: whether to include the body of the symbols in the result. 976 | 977 | :return: A list of root symbols representing the top-level packages/modules in the project. 978 | """ 979 | if within_relative_path is not None: 980 | within_abs_path = os.path.join(self.repository_root_path, within_relative_path) 981 | if not os.path.exists(within_abs_path): 982 | raise FileNotFoundError(f"File or directory not found: {within_abs_path}") 983 | if os.path.isfile(within_abs_path): 984 | if self.is_ignored_path(within_relative_path): 985 | self.logger.log( 986 | f"You passed a file explicitly, but it is ignored. This is probably an error. File: {within_relative_path}", 987 | logging.ERROR, 988 | ) 989 | return [] 990 | else: 991 | _, root_nodes = self.request_document_symbols(within_relative_path, include_body=include_body) 992 | return root_nodes 993 | 994 | # Helper function to recursively process directories 995 | def process_directory(rel_dir_path: str) -> list[ls_types.UnifiedSymbolInformation]: 996 | abs_dir_path = self.repository_root_path if rel_dir_path == "." else os.path.join(self.repository_root_path, rel_dir_path) 997 | abs_dir_path = os.path.realpath(abs_dir_path) 998 | 999 | if self.is_ignored_path(str(Path(abs_dir_path).relative_to(self.repository_root_path))): 1000 | self.logger.log(f"Skipping directory: {rel_dir_path}\n(because it should be ignored)", logging.DEBUG) 1001 | return [] 1002 | 1003 | result = [] 1004 | try: 1005 | contained_dir_or_file_names = os.listdir(abs_dir_path) 1006 | except OSError: 1007 | return [] 1008 | 1009 | # Create package symbol for directory 1010 | package_symbol = ls_types.UnifiedSymbolInformation( # type: ignore 1011 | name=os.path.basename(abs_dir_path), 1012 | kind=ls_types.SymbolKind.Package, 1013 | location=ls_types.Location( 1014 | uri=str(pathlib.Path(abs_dir_path).as_uri()), 1015 | range={"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}}, 1016 | absolutePath=str(abs_dir_path), 1017 | relativePath=str(Path(abs_dir_path).resolve().relative_to(self.repository_root_path)), 1018 | ), 1019 | children=[], 1020 | ) 1021 | result.append(package_symbol) 1022 | 1023 | for contained_dir_or_file_name in contained_dir_or_file_names: 1024 | contained_dir_or_file_abs_path = os.path.join(abs_dir_path, contained_dir_or_file_name) 1025 | 1026 | # obtain relative path 1027 | try: 1028 | contained_dir_or_file_rel_path = str( 1029 | Path(contained_dir_or_file_abs_path).resolve().relative_to(self.repository_root_path) 1030 | ) 1031 | except ValueError as e: 1032 | # Typically happens when the path is not under the repository root (e.g., symlink pointing outside) 1033 | self.logger.log( 1034 | f"Skipping path {contained_dir_or_file_abs_path}; likely outside of the repository root {self.repository_root_path} [cause: {e}]", 1035 | logging.WARNING, 1036 | ) 1037 | continue 1038 | 1039 | if self.is_ignored_path(contained_dir_or_file_rel_path): 1040 | self.logger.log(f"Skipping item: {contained_dir_or_file_rel_path}\n(because it should be ignored)", logging.DEBUG) 1041 | continue 1042 | 1043 | if os.path.isdir(contained_dir_or_file_abs_path): 1044 | child_symbols = process_directory(contained_dir_or_file_rel_path) 1045 | package_symbol["children"].extend(child_symbols) 1046 | for child in child_symbols: 1047 | child["parent"] = package_symbol 1048 | 1049 | elif os.path.isfile(contained_dir_or_file_abs_path): 1050 | _, file_root_nodes = self.request_document_symbols(contained_dir_or_file_rel_path, include_body=include_body) 1051 | 1052 | # Create file symbol, link with children 1053 | file_rel_path = str(Path(contained_dir_or_file_abs_path).resolve().relative_to(self.repository_root_path)) 1054 | with self.open_file(file_rel_path) as file_data: 1055 | fileRange = self._get_range_from_file_content(file_data.contents) 1056 | file_symbol = ls_types.UnifiedSymbolInformation( # type: ignore 1057 | name=os.path.splitext(contained_dir_or_file_name)[0], 1058 | kind=ls_types.SymbolKind.File, 1059 | range=fileRange, 1060 | selectionRange=fileRange, 1061 | location=ls_types.Location( 1062 | uri=str(pathlib.Path(contained_dir_or_file_abs_path).as_uri()), 1063 | range=fileRange, 1064 | absolutePath=str(contained_dir_or_file_abs_path), 1065 | relativePath=str(Path(contained_dir_or_file_abs_path).resolve().relative_to(self.repository_root_path)), 1066 | ), 1067 | children=file_root_nodes, 1068 | parent=package_symbol, 1069 | ) 1070 | for child in file_root_nodes: 1071 | child["parent"] = file_symbol 1072 | 1073 | # Link file symbol with package 1074 | package_symbol["children"].append(file_symbol) 1075 | 1076 | # TODO: Not sure if this is actually still needed given recent changes to relative path handling 1077 | def fix_relative_path(nodes: list[ls_types.UnifiedSymbolInformation]): 1078 | for node in nodes: 1079 | if "location" in node and "relativePath" in node["location"]: 1080 | path = Path(node["location"]["relativePath"]) 1081 | if path.is_absolute(): 1082 | try: 1083 | path = path.relative_to(self.repository_root_path) 1084 | node["location"]["relativePath"] = str(path) 1085 | except Exception: 1086 | pass 1087 | if "children" in node: 1088 | fix_relative_path(node["children"]) 1089 | 1090 | fix_relative_path(file_root_nodes) 1091 | 1092 | return result 1093 | 1094 | # Start from the root or the specified directory 1095 | start_rel_path = within_relative_path or "." 1096 | return process_directory(start_rel_path) 1097 | 1098 | @staticmethod 1099 | def _get_range_from_file_content(file_content: str) -> ls_types.Range: 1100 | """ 1101 | Get the range for the given file. 1102 | """ 1103 | lines = file_content.split("\n") 1104 | end_line = len(lines) 1105 | end_column = len(lines[-1]) 1106 | return ls_types.Range(start=ls_types.Position(line=0, character=0), end=ls_types.Position(line=end_line, character=end_column)) 1107 | 1108 | def request_dir_overview(self, relative_dir_path: str) -> dict[str, list[UnifiedSymbolInformation]]: 1109 | """ 1110 | :return: A mapping of all relative paths analyzed to lists of top-level symbols in the corresponding file. 1111 | """ 1112 | symbol_tree = self.request_full_symbol_tree(relative_dir_path) 1113 | # Initialize result dictionary 1114 | result: dict[str, list[UnifiedSymbolInformation]] = defaultdict(list) 1115 | 1116 | # Helper function to process a symbol and its children 1117 | def process_symbol(symbol: ls_types.UnifiedSymbolInformation): 1118 | if symbol["kind"] == ls_types.SymbolKind.File: 1119 | # For file symbols, process their children (top-level symbols) 1120 | for child in symbol["children"]: 1121 | # Handle cross-platform path resolution (fixes Docker/macOS path issues) 1122 | absolute_path = Path(child["location"]["absolutePath"]).resolve() 1123 | repository_root = Path(self.repository_root_path).resolve() 1124 | 1125 | # Try pathlib first, fallback to alternative approach if paths are incompatible 1126 | try: 1127 | path = absolute_path.relative_to(repository_root) 1128 | except ValueError: 1129 | # If paths are from different roots (e.g., /workspaces vs /Users), 1130 | # use the relativePath from location if available, or extract from absolutePath 1131 | if "relativePath" in child["location"] and child["location"]["relativePath"]: 1132 | path = Path(child["location"]["relativePath"]) 1133 | else: 1134 | # Extract relative path by finding common structure 1135 | # Example: /workspaces/.../test_repo/file.py -> test_repo/file.py 1136 | path_parts = absolute_path.parts 1137 | 1138 | # Find the last common part or use a fallback 1139 | if "test_repo" in path_parts: 1140 | test_repo_idx = path_parts.index("test_repo") 1141 | path = Path(*path_parts[test_repo_idx:]) 1142 | else: 1143 | # Last resort: use filename only 1144 | path = Path(absolute_path.name) 1145 | result[str(path)].append(child) 1146 | # For package/directory symbols, process their children 1147 | for child in symbol["children"]: 1148 | process_symbol(child) 1149 | 1150 | # Process each root symbol 1151 | for root in symbol_tree: 1152 | process_symbol(root) 1153 | return result 1154 | 1155 | def request_document_overview(self, relative_file_path: str) -> list[UnifiedSymbolInformation]: 1156 | """ 1157 | :return: the top-level symbols in the given file. 1158 | """ 1159 | _, document_roots = self.request_document_symbols(relative_file_path) 1160 | return document_roots 1161 | 1162 | def request_overview(self, within_relative_path: str) -> dict[str, list[UnifiedSymbolInformation]]: 1163 | """ 1164 | An overview of all symbols in the given file or directory. 1165 | 1166 | :param within_relative_path: the relative path to the file or directory to get the overview of. 1167 | :return: A mapping of all relative paths analyzed to lists of top-level symbols in the corresponding file. 1168 | """ 1169 | abs_path = (Path(self.repository_root_path) / within_relative_path).resolve() 1170 | if not abs_path.exists(): 1171 | raise FileNotFoundError(f"File or directory not found: {abs_path}") 1172 | 1173 | if abs_path.is_file(): 1174 | symbols_overview = self.request_document_overview(within_relative_path) 1175 | return {within_relative_path: symbols_overview} 1176 | else: 1177 | return self.request_dir_overview(within_relative_path) 1178 | 1179 | def request_hover(self, relative_file_path: str, line: int, column: int) -> ls_types.Hover | None: 1180 | """ 1181 | Raise a [textDocument/hover](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_hover) request to the Language Server 1182 | to find the hover information at the given line and column in the given file. Wait for the response and return the result. 1183 | 1184 | :param relative_file_path: The relative path of the file that has the hover information 1185 | :param line: The line number of the symbol 1186 | :param column: The column number of the symbol 1187 | 1188 | :return None 1189 | """ 1190 | with self.open_file(relative_file_path): 1191 | response = self.server.send.hover( 1192 | { 1193 | "textDocument": {"uri": pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)).as_uri()}, 1194 | "position": { 1195 | "line": line, 1196 | "character": column, 1197 | }, 1198 | } 1199 | ) 1200 | 1201 | if response is None: 1202 | return None 1203 | 1204 | assert isinstance(response, dict) 1205 | 1206 | return ls_types.Hover(**response) 1207 | 1208 | def retrieve_symbol_body(self, symbol: ls_types.UnifiedSymbolInformation | LSPTypes.DocumentSymbol | LSPTypes.SymbolInformation) -> str: 1209 | """ 1210 | Load the body of the given symbol. If the body is already contained in the symbol, just return it. 1211 | """ 1212 | existing_body = symbol.get("body", None) 1213 | if existing_body: 1214 | return existing_body 1215 | 1216 | assert "location" in symbol 1217 | symbol_start_line = symbol["location"]["range"]["start"]["line"] 1218 | symbol_end_line = symbol["location"]["range"]["end"]["line"] 1219 | assert "relativePath" in symbol["location"] 1220 | symbol_file = self.retrieve_full_file_content(symbol["location"]["relativePath"]) 1221 | symbol_lines = symbol_file.split("\n") 1222 | symbol_body = "\n".join(symbol_lines[symbol_start_line : symbol_end_line + 1]) 1223 | 1224 | # remove leading indentation 1225 | symbol_start_column = symbol["location"]["range"]["start"]["character"] 1226 | symbol_body = symbol_body[symbol_start_column:] 1227 | return symbol_body 1228 | 1229 | def request_referencing_symbols( 1230 | self, 1231 | relative_file_path: str, 1232 | line: int, 1233 | column: int, 1234 | include_imports: bool = True, 1235 | include_self: bool = False, 1236 | include_body: bool = False, 1237 | include_file_symbols: bool = False, 1238 | ) -> list[ReferenceInSymbol]: 1239 | """ 1240 | Finds all symbols that reference the symbol at the given location. 1241 | This is similar to request_references but filters to only include symbols 1242 | (functions, methods, classes, etc.) that reference the target symbol. 1243 | 1244 | :param relative_file_path: The relative path to the file. 1245 | :param line: The 0-indexed line number. 1246 | :param column: The 0-indexed column number. 1247 | :param include_imports: whether to also include imports as references. 1248 | Unfortunately, the LSP does not have an import type, so the references corresponding to imports 1249 | will not be easily distinguishable from definitions. 1250 | :param include_self: whether to include the references that is the "input symbol" itself. 1251 | Only has an effect if the relative_file_path, line and column point to a symbol, for example a definition. 1252 | :param include_body: whether to include the body of the symbols in the result. 1253 | :param include_file_symbols: whether to include references that are file symbols. This 1254 | is often a fallback mechanism for when the reference cannot be resolved to a symbol. 1255 | :return: List of objects containing the symbol and the location of the reference. 1256 | """ 1257 | if not self.server_started: 1258 | self.logger.log( 1259 | "request_referencing_symbols called before Language Server started", 1260 | logging.ERROR, 1261 | ) 1262 | raise SolidLSPException("Language Server not started") 1263 | 1264 | # First, get all references to the symbol 1265 | references = self.request_references(relative_file_path, line, column) 1266 | if not references: 1267 | return [] 1268 | 1269 | # For each reference, find the containing symbol 1270 | result = [] 1271 | incoming_symbol = None 1272 | for ref in references: 1273 | ref_path = ref["relativePath"] 1274 | ref_line = ref["range"]["start"]["line"] 1275 | ref_col = ref["range"]["start"]["character"] 1276 | 1277 | with self.open_file(ref_path) as file_data: 1278 | # Get the containing symbol for this reference 1279 | containing_symbol = self.request_containing_symbol(ref_path, ref_line, ref_col, include_body=include_body) 1280 | if containing_symbol is None: 1281 | # TODO: HORRIBLE HACK! I don't know how to do it better for now... 1282 | # THIS IS BOUND TO BREAK IN MANY CASES! IT IS ALSO SPECIFIC TO PYTHON! 1283 | # Background: 1284 | # When a variable is used to change something, like 1285 | # 1286 | # instance = MyClass() 1287 | # instance.status = "new status" 1288 | # 1289 | # we can't find the containing symbol for the reference to `status` 1290 | # since there is no container on the line of the reference 1291 | # The hack is to try to find a variable symbol in the containing module 1292 | # by using the text of the reference to find the variable name (In a very heuristic way) 1293 | # and then look for a symbol with that name and kind Variable 1294 | ref_text = file_data.contents.split("\n")[ref_line] 1295 | if "." in ref_text: 1296 | containing_symbol_name = ref_text.split(".")[0] 1297 | all_symbols, _ = self.request_document_symbols(ref_path) 1298 | for symbol in all_symbols: 1299 | if symbol["name"] == containing_symbol_name and symbol["kind"] == ls_types.SymbolKind.Variable: 1300 | containing_symbol = copy(symbol) 1301 | containing_symbol["location"] = ref 1302 | containing_symbol["range"] = ref["range"] 1303 | break 1304 | 1305 | # We failed retrieving the symbol, falling back to creating a file symbol 1306 | if containing_symbol is None and include_file_symbols: 1307 | self.logger.log( 1308 | f"Could not find containing symbol for {ref_path}:{ref_line}:{ref_col}. Returning file symbol instead", 1309 | logging.WARNING, 1310 | ) 1311 | fileRange = self._get_range_from_file_content(file_data.contents) 1312 | location = ls_types.Location( 1313 | uri=str(pathlib.Path(os.path.join(self.repository_root_path, ref_path)).as_uri()), 1314 | range=fileRange, 1315 | absolutePath=str(os.path.join(self.repository_root_path, ref_path)), 1316 | relativePath=ref_path, 1317 | ) 1318 | name = os.path.splitext(os.path.basename(ref_path))[0] 1319 | 1320 | if include_body: 1321 | body = self.retrieve_full_file_content(ref_path) 1322 | else: 1323 | body = "" 1324 | 1325 | containing_symbol = ls_types.UnifiedSymbolInformation( 1326 | kind=ls_types.SymbolKind.File, 1327 | range=fileRange, 1328 | selectionRange=fileRange, 1329 | location=location, 1330 | name=name, 1331 | children=[], 1332 | body=body, 1333 | ) 1334 | if containing_symbol is None or (not include_file_symbols and containing_symbol["kind"] == ls_types.SymbolKind.File): 1335 | continue 1336 | 1337 | assert "location" in containing_symbol 1338 | assert "selectionRange" in containing_symbol 1339 | 1340 | # Checking for self-reference 1341 | if ( 1342 | containing_symbol["location"]["relativePath"] == relative_file_path 1343 | and containing_symbol["selectionRange"]["start"]["line"] == ref_line 1344 | and containing_symbol["selectionRange"]["start"]["character"] == ref_col 1345 | ): 1346 | incoming_symbol = containing_symbol 1347 | if include_self: 1348 | result.append(ReferenceInSymbol(symbol=containing_symbol, line=ref_line, character=ref_col)) 1349 | continue 1350 | self.logger.log(f"Found self-reference for {incoming_symbol['name']}, skipping it since {include_self=}", logging.DEBUG) 1351 | continue 1352 | 1353 | # checking whether reference is an import 1354 | # This is neither really safe nor elegant, but if we don't do it, 1355 | # there is no way to distinguish between definitions and imports as import is not a symbol-type 1356 | # and we get the type referenced symbol resulting from imports... 1357 | if ( 1358 | not include_imports 1359 | and incoming_symbol is not None 1360 | and containing_symbol["name"] == incoming_symbol["name"] 1361 | and containing_symbol["kind"] == incoming_symbol["kind"] 1362 | ): 1363 | self.logger.log( 1364 | f"Found import of referenced symbol {incoming_symbol['name']}" 1365 | f"in {containing_symbol['location']['relativePath']}, skipping", 1366 | logging.DEBUG, 1367 | ) 1368 | continue 1369 | 1370 | result.append(ReferenceInSymbol(symbol=containing_symbol, line=ref_line, character=ref_col)) 1371 | 1372 | return result 1373 | 1374 | def request_containing_symbol( 1375 | self, 1376 | relative_file_path: str, 1377 | line: int, 1378 | column: int | None = None, 1379 | strict: bool = False, 1380 | include_body: bool = False, 1381 | ) -> ls_types.UnifiedSymbolInformation | None: 1382 | """ 1383 | Finds the first symbol containing the position for the given file. 1384 | For Python, container symbols are considered to be those with kinds corresponding to 1385 | functions, methods, or classes (typically: Function (12), Method (6), Class (5)). 1386 | 1387 | The method operates as follows: 1388 | - Request the document symbols for the file. 1389 | - Filter symbols to those that start at or before the given line. 1390 | - From these, first look for symbols whose range contains the (line, column). 1391 | - If one or more symbols contain the position, return the one with the greatest starting position 1392 | (i.e. the innermost container). 1393 | - If none (strictly) contain the position, return the symbol with the greatest starting position 1394 | among those above the given line. 1395 | - If no container candidates are found, return None. 1396 | 1397 | :param relative_file_path: The relative path to the Python file. 1398 | :param line: The 0-indexed line number. 1399 | :param column: The 0-indexed column (also called character). If not passed, the lookup will be based 1400 | only on the line. 1401 | :param strict: If True, the position must be strictly within the range of the symbol. 1402 | Setting to True is useful for example for finding the parent of a symbol, as with strict=False, 1403 | and the line pointing to a symbol itself, the containing symbol will be the symbol itself 1404 | (and not the parent). 1405 | :param include_body: Whether to include the body of the symbol in the result. 1406 | :return: The container symbol (if found) or None. 1407 | """ 1408 | # checking if the line is empty, unfortunately ugly and duplicating code, but I don't want to refactor 1409 | with self.open_file(relative_file_path): 1410 | absolute_file_path = str(PurePath(self.repository_root_path, relative_file_path)) 1411 | content = FileUtils.read_file(self.logger, absolute_file_path) 1412 | if content.split("\n")[line].strip() == "": 1413 | self.logger.log( 1414 | f"Passing empty lines to request_container_symbol is currently not supported, {relative_file_path=}, {line=}", 1415 | logging.ERROR, 1416 | ) 1417 | return None 1418 | 1419 | symbols, _ = self.request_document_symbols(relative_file_path) 1420 | 1421 | # make jedi and pyright api compatible 1422 | # the former has no location, the later has no range 1423 | # we will just always add location of the desired format to all symbols 1424 | for symbol in symbols: 1425 | if "location" not in symbol: 1426 | range = symbol["range"] 1427 | location = ls_types.Location( 1428 | uri=f"file:/{absolute_file_path}", 1429 | range=range, 1430 | absolutePath=absolute_file_path, 1431 | relativePath=relative_file_path, 1432 | ) 1433 | symbol["location"] = location 1434 | else: 1435 | location = symbol["location"] 1436 | assert "range" in location 1437 | location["absolutePath"] = absolute_file_path 1438 | location["relativePath"] = relative_file_path 1439 | location["uri"] = Path(absolute_file_path).as_uri() 1440 | 1441 | # Allowed container kinds, currently only for Python 1442 | container_symbol_kinds = {ls_types.SymbolKind.Method, ls_types.SymbolKind.Function, ls_types.SymbolKind.Class} 1443 | 1444 | def is_position_in_range(line: int, range_d: ls_types.Range) -> bool: 1445 | start = range_d["start"] 1446 | end = range_d["end"] 1447 | 1448 | column_condition = True 1449 | if strict: 1450 | line_condition = end["line"] >= line > start["line"] 1451 | if column is not None and line == start["line"]: 1452 | column_condition = column > start["character"] 1453 | else: 1454 | line_condition = end["line"] >= line >= start["line"] 1455 | if column is not None and line == start["line"]: 1456 | column_condition = column >= start["character"] 1457 | return line_condition and column_condition 1458 | 1459 | # Only consider containers that are not one-liners (otherwise we may get imports) 1460 | candidate_containers = [ 1461 | s 1462 | for s in symbols 1463 | if s["kind"] in container_symbol_kinds and s["location"]["range"]["start"]["line"] != s["location"]["range"]["end"]["line"] 1464 | ] 1465 | var_containers = [s for s in symbols if s["kind"] == ls_types.SymbolKind.Variable] 1466 | candidate_containers.extend(var_containers) 1467 | 1468 | if not candidate_containers: 1469 | return None 1470 | 1471 | # From the candidates, find those whose range contains the given position. 1472 | containing_symbols = [] 1473 | for symbol in candidate_containers: 1474 | s_range = symbol["location"]["range"] 1475 | if not is_position_in_range(line, s_range): 1476 | continue 1477 | containing_symbols.append(symbol) 1478 | 1479 | if containing_symbols: 1480 | # Return the one with the greatest starting position (i.e. the innermost container). 1481 | containing_symbol = max(containing_symbols, key=lambda s: s["location"]["range"]["start"]["line"]) 1482 | if include_body: 1483 | containing_symbol["body"] = self.retrieve_symbol_body(containing_symbol) 1484 | return containing_symbol 1485 | else: 1486 | return None 1487 | 1488 | def request_container_of_symbol( 1489 | self, symbol: ls_types.UnifiedSymbolInformation, include_body: bool = False 1490 | ) -> ls_types.UnifiedSymbolInformation | None: 1491 | """ 1492 | Finds the container of the given symbol if there is one. If the parent attribute is present, the parent is returned 1493 | without further searching. 1494 | 1495 | :param symbol: The symbol to find the container of. 1496 | :param include_body: whether to include the body of the symbol in the result. 1497 | :return: The container of the given symbol or None if no container is found. 1498 | """ 1499 | if "parent" in symbol: 1500 | return symbol["parent"] 1501 | assert "location" in symbol, f"Symbol {symbol} has no location and no parent attribute" 1502 | return self.request_containing_symbol( 1503 | symbol["location"]["relativePath"], 1504 | symbol["location"]["range"]["start"]["line"], 1505 | symbol["location"]["range"]["start"]["character"], 1506 | strict=True, 1507 | include_body=include_body, 1508 | ) 1509 | 1510 | def request_defining_symbol( 1511 | self, 1512 | relative_file_path: str, 1513 | line: int, 1514 | column: int, 1515 | include_body: bool = False, 1516 | ) -> ls_types.UnifiedSymbolInformation | None: 1517 | """ 1518 | Finds the symbol that defines the symbol at the given location. 1519 | 1520 | This method first finds the definition of the symbol at the given position, 1521 | then retrieves the full symbol information for that definition. 1522 | 1523 | :param relative_file_path: The relative path to the file. 1524 | :param line: The 0-indexed line number. 1525 | :param column: The 0-indexed column number. 1526 | :param include_body: whether to include the body of the symbol in the result. 1527 | :return: The symbol information for the definition, or None if not found. 1528 | """ 1529 | if not self.server_started: 1530 | self.logger.log( 1531 | "request_defining_symbol called before Language Server started", 1532 | logging.ERROR, 1533 | ) 1534 | raise SolidLSPException("Language Server not started") 1535 | 1536 | # Get the definition location(s) 1537 | definitions = self.request_definition(relative_file_path, line, column) 1538 | if not definitions: 1539 | return None 1540 | 1541 | # Use the first definition location 1542 | definition = definitions[0] 1543 | def_path = definition["relativePath"] 1544 | def_line = definition["range"]["start"]["line"] 1545 | def_col = definition["range"]["start"]["character"] 1546 | 1547 | # Find the symbol at or containing this location 1548 | defining_symbol = self.request_containing_symbol(def_path, def_line, def_col, strict=False, include_body=include_body) 1549 | 1550 | return defining_symbol 1551 | 1552 | @property 1553 | def cache_path(self) -> Path: 1554 | """ 1555 | The path to the cache file for the document symbols. 1556 | """ 1557 | return ( 1558 | Path(self.repository_root_path) 1559 | / self._solidlsp_settings.project_data_relative_path 1560 | / self.CACHE_FOLDER_NAME 1561 | / self.language_id 1562 | / "document_symbols_cache_v23-06-25.pkl" 1563 | ) 1564 | 1565 | def save_cache(self): 1566 | with self._cache_lock: 1567 | if not self._cache_has_changed: 1568 | self.logger.log("No changes to document symbols cache, skipping save", logging.DEBUG) 1569 | return 1570 | 1571 | self.logger.log(f"Saving updated document symbols cache to {self.cache_path}", logging.INFO) 1572 | self.cache_path.parent.mkdir(parents=True, exist_ok=True) 1573 | try: 1574 | with open(self.cache_path, "wb") as f: 1575 | pickle.dump(self._document_symbols_cache, f) 1576 | self._cache_has_changed = False 1577 | except Exception as e: 1578 | self.logger.log( 1579 | f"Failed to save document symbols cache to {self.cache_path}: {e}. " 1580 | "Note: this may have resulted in a corrupted cache file.", 1581 | logging.ERROR, 1582 | ) 1583 | 1584 | def load_cache(self): 1585 | if not self.cache_path.exists(): 1586 | return 1587 | 1588 | with self._cache_lock: 1589 | self.logger.log(f"Loading document symbols cache from {self.cache_path}", logging.INFO) 1590 | try: 1591 | with open(self.cache_path, "rb") as f: 1592 | self._document_symbols_cache = pickle.load(f) 1593 | self.logger.log(f"Loaded {len(self._document_symbols_cache)} document symbols from cache.", logging.INFO) 1594 | except Exception as e: 1595 | # cache often becomes corrupt, so just skip loading it 1596 | self.logger.log( 1597 | f"Failed to load document symbols cache from {self.cache_path}: {e}. Possible cause: the cache file is corrupted. " 1598 | "Check for any errors related to saving the cache in the logs.", 1599 | logging.ERROR, 1600 | ) 1601 | 1602 | def request_workspace_symbol(self, query: str) -> list[ls_types.UnifiedSymbolInformation] | None: 1603 | """ 1604 | Raise a [workspace/symbol](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_symbol) request to the Language Server 1605 | to find symbols across the whole workspace. Wait for the response and return the result. 1606 | 1607 | :param query: The query string to filter symbols by 1608 | 1609 | :return: A list of matching symbols 1610 | """ 1611 | response = self.server.send.workspace_symbol({"query": query}) 1612 | if response is None: 1613 | return None 1614 | 1615 | assert isinstance(response, list) 1616 | 1617 | ret: list[ls_types.UnifiedSymbolInformation] = [] 1618 | for item in response: 1619 | assert isinstance(item, dict) 1620 | 1621 | assert LSPConstants.NAME in item 1622 | assert LSPConstants.KIND in item 1623 | assert LSPConstants.LOCATION in item 1624 | 1625 | ret.append(ls_types.UnifiedSymbolInformation(**item)) 1626 | 1627 | return ret 1628 | 1629 | def start(self) -> "SolidLanguageServer": 1630 | """ 1631 | Starts the language server process and connects to it. Call shutdown when ready. 1632 | 1633 | :return: self for method chaining 1634 | """ 1635 | self.logger.log( 1636 | f"Starting language server with language {self.language_server.language} for {self.language_server.repository_root_path}", 1637 | logging.INFO, 1638 | ) 1639 | self._server_context = self._start_server_process() 1640 | return self 1641 | 1642 | def stop(self, shutdown_timeout: float = 2.0) -> None: 1643 | self._shutdown(timeout=shutdown_timeout) 1644 | 1645 | @property 1646 | def language_server(self) -> Self: 1647 | return self 1648 | 1649 | def is_running(self) -> bool: 1650 | return self.server.is_running() 1651 | ```