#
tokens: 36590/50000 2/294 files (page 12/14)
lines: on (toggle) GitHub
raw markdown copy reset
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 | 
```
Page 12/14FirstPrevNextLast