This is page 5 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/clojure_lsp.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Provides Clojure specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Clojure. 3 | """ 4 | 5 | import logging 6 | import os 7 | import pathlib 8 | import shutil 9 | import subprocess 10 | import threading 11 | 12 | from solidlsp.ls import SolidLanguageServer 13 | from solidlsp.ls_config import LanguageServerConfig 14 | from solidlsp.ls_logger import LanguageServerLogger 15 | from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams 16 | from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo 17 | from solidlsp.settings import SolidLSPSettings 18 | 19 | from .common import RuntimeDependency, RuntimeDependencyCollection 20 | 21 | 22 | def run_command(cmd: list, capture_output: bool = True) -> subprocess.CompletedProcess: 23 | return subprocess.run( 24 | cmd, stdout=subprocess.PIPE if capture_output else None, stderr=subprocess.STDOUT if capture_output else None, text=True, check=True 25 | ) 26 | 27 | 28 | def verify_clojure_cli(): 29 | install_msg = "Please install the official Clojure CLI from:\n https://clojure.org/guides/getting_started" 30 | if shutil.which("clojure") is None: 31 | raise FileNotFoundError("`clojure` not found.\n" + install_msg) 32 | 33 | help_proc = run_command(["clojure", "--help"]) 34 | if "-Aaliases" not in help_proc.stdout: 35 | raise RuntimeError("Detected a Clojure executable, but it does not support '-Aaliases'.\n" + install_msg) 36 | 37 | spath_proc = run_command(["clojure", "-Spath"], capture_output=False) 38 | if spath_proc.returncode != 0: 39 | raise RuntimeError("`clojure -Spath` failed; please upgrade to Clojure CLI ≥ 1.10.") 40 | 41 | 42 | class ClojureLSP(SolidLanguageServer): 43 | """ 44 | Provides a clojure-lsp specific instantiation of the LanguageServer class. Contains various configurations and settings specific to clojure. 45 | """ 46 | 47 | clojure_lsp_releases = "https://github.com/clojure-lsp/clojure-lsp/releases/latest/download" 48 | runtime_dependencies = RuntimeDependencyCollection( 49 | [ 50 | RuntimeDependency( 51 | id="clojure-lsp", 52 | url=f"{clojure_lsp_releases}/clojure-lsp-native-macos-aarch64.zip", 53 | platform_id="osx-arm64", 54 | archive_type="zip", 55 | binary_name="clojure-lsp", 56 | ), 57 | RuntimeDependency( 58 | id="clojure-lsp", 59 | url=f"{clojure_lsp_releases}/clojure-lsp-native-macos-amd64.zip", 60 | platform_id="osx-x64", 61 | archive_type="zip", 62 | binary_name="clojure-lsp", 63 | ), 64 | RuntimeDependency( 65 | id="clojure-lsp", 66 | url=f"{clojure_lsp_releases}/clojure-lsp-native-linux-aarch64.zip", 67 | platform_id="linux-arm64", 68 | archive_type="zip", 69 | binary_name="clojure-lsp", 70 | ), 71 | RuntimeDependency( 72 | id="clojure-lsp", 73 | url=f"{clojure_lsp_releases}/clojure-lsp-native-linux-amd64.zip", 74 | platform_id="linux-x64", 75 | archive_type="zip", 76 | binary_name="clojure-lsp", 77 | ), 78 | RuntimeDependency( 79 | id="clojure-lsp", 80 | url=f"{clojure_lsp_releases}/clojure-lsp-native-windows-amd64.zip", 81 | platform_id="win-x64", 82 | archive_type="zip", 83 | binary_name="clojure-lsp.exe", 84 | ), 85 | ] 86 | ) 87 | 88 | def __init__( 89 | self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings 90 | ): 91 | """ 92 | Creates a ClojureLSP instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. 93 | """ 94 | clojure_lsp_executable_path = self._setup_runtime_dependencies(logger, config, solidlsp_settings) 95 | super().__init__( 96 | config, 97 | logger, 98 | repository_root_path, 99 | ProcessLaunchInfo(cmd=clojure_lsp_executable_path, cwd=repository_root_path), 100 | "clojure", 101 | solidlsp_settings, 102 | ) 103 | self.server_ready = threading.Event() 104 | self.initialize_searcher_command_available = threading.Event() 105 | self.resolve_main_method_available = threading.Event() 106 | self.service_ready_event = threading.Event() 107 | 108 | @classmethod 109 | def _setup_runtime_dependencies( 110 | cls, logger: LanguageServerLogger, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings 111 | ) -> str: 112 | """Setup runtime dependencies for clojure-lsp and return the command to start the server.""" 113 | verify_clojure_cli() 114 | deps = ClojureLSP.runtime_dependencies 115 | dependency = deps.get_single_dep_for_current_platform() 116 | 117 | clojurelsp_ls_dir = cls.ls_resources_dir(solidlsp_settings) 118 | clojurelsp_executable_path = deps.binary_path(clojurelsp_ls_dir) 119 | if not os.path.exists(clojurelsp_executable_path): 120 | logger.log( 121 | f"Downloading and extracting clojure-lsp from {dependency.url} to {clojurelsp_ls_dir}", 122 | logging.INFO, 123 | ) 124 | deps.install(logger, clojurelsp_ls_dir) 125 | if not os.path.exists(clojurelsp_executable_path): 126 | raise FileNotFoundError(f"Download failed? Could not find clojure-lsp executable at {clojurelsp_executable_path}") 127 | os.chmod(clojurelsp_executable_path, 0o755) 128 | return clojurelsp_executable_path 129 | 130 | @staticmethod 131 | def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: 132 | """Returns the init params for clojure-lsp.""" 133 | root_uri = pathlib.Path(repository_absolute_path).as_uri() 134 | return { # type: ignore 135 | "processId": os.getpid(), 136 | "rootPath": repository_absolute_path, 137 | "rootUri": root_uri, 138 | "capabilities": { 139 | "workspace": { 140 | "applyEdit": True, 141 | "workspaceEdit": {"documentChanges": True}, 142 | "symbol": {"symbolKind": {"valueSet": list(range(1, 27))}}, 143 | "workspaceFolders": True, 144 | }, 145 | "textDocument": { 146 | "synchronization": {"didSave": True}, 147 | "publishDiagnostics": {"relatedInformation": True, "tagSupport": {"valueSet": [1, 2]}}, 148 | "definition": {"linkSupport": True}, 149 | "references": {}, 150 | "hover": {"contentFormat": ["markdown", "plaintext"]}, 151 | "documentSymbol": { 152 | "hierarchicalDocumentSymbolSupport": True, 153 | "symbolKind": {"valueSet": list(range(1, 27))}, # 154 | }, 155 | }, 156 | "general": {"positionEncodings": ["utf-16"]}, 157 | }, 158 | "initializationOptions": {"dependency-scheme": "jar", "text-document-sync-kind": "incremental"}, 159 | "trace": "off", 160 | "workspaceFolders": [{"uri": root_uri, "name": os.path.basename(repository_absolute_path)}], 161 | } 162 | 163 | def _start_server(self): 164 | def register_capability_handler(params): 165 | assert "registrations" in params 166 | for registration in params["registrations"]: 167 | if registration["method"] == "workspace/executeCommand": 168 | self.initialize_searcher_command_available.set() 169 | self.resolve_main_method_available.set() 170 | return 171 | 172 | def lang_status_handler(params): 173 | # TODO: Should we wait for 174 | # server -> client: {'jsonrpc': '2.0', 'method': 'language/status', 'params': {'type': 'ProjectStatus', 'message': 'OK'}} 175 | # Before proceeding? 176 | if params["type"] == "ServiceReady" and params["message"] == "ServiceReady": 177 | self.service_ready_event.set() 178 | 179 | def execute_client_command_handler(params): 180 | return [] 181 | 182 | def do_nothing(params): 183 | return 184 | 185 | def check_experimental_status(params): 186 | if params["quiescent"] == True: 187 | self.server_ready.set() 188 | 189 | def window_log_message(msg): 190 | self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) 191 | 192 | self.server.on_request("client/registerCapability", register_capability_handler) 193 | self.server.on_notification("language/status", lang_status_handler) 194 | self.server.on_notification("window/logMessage", window_log_message) 195 | self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) 196 | self.server.on_notification("$/progress", do_nothing) 197 | self.server.on_notification("textDocument/publishDiagnostics", do_nothing) 198 | self.server.on_notification("language/actionableNotification", do_nothing) 199 | self.server.on_notification("experimental/serverStatus", check_experimental_status) 200 | 201 | self.logger.log("Starting clojure-lsp server process", logging.INFO) 202 | self.server.start() 203 | 204 | initialize_params = self._get_initialize_params(self.repository_root_path) 205 | 206 | self.logger.log( 207 | "Sending initialize request from LSP client to LSP server and awaiting response", 208 | logging.INFO, 209 | ) 210 | init_response = self.server.send.initialize(initialize_params) 211 | assert init_response["capabilities"]["textDocumentSync"]["change"] == 2 212 | assert "completionProvider" in init_response["capabilities"] 213 | # Clojure-lsp completion provider capabilities are more flexible than other servers' 214 | completion_provider = init_response["capabilities"]["completionProvider"] 215 | assert completion_provider["resolveProvider"] == True 216 | assert "triggerCharacters" in completion_provider 217 | self.server.notify.initialized({}) 218 | # after initialize, Clojure-lsp is ready to serve 219 | self.server_ready.set() 220 | self.completions_available.set() 221 | ``` -------------------------------------------------------------------------------- /src/solidlsp/language_servers/vts_language_server.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Language Server implementation for TypeScript/JavaScript using https://github.com/yioneko/vtsls, 3 | which provides TypeScript language server functionality via VSCode's TypeScript extension 4 | (contrary to typescript-language-server, which uses the TypeScript compiler directly). 5 | """ 6 | 7 | import logging 8 | import os 9 | import pathlib 10 | import shutil 11 | import threading 12 | 13 | from overrides import override 14 | 15 | from solidlsp.ls import SolidLanguageServer 16 | from solidlsp.ls_config import LanguageServerConfig 17 | from solidlsp.ls_logger import LanguageServerLogger 18 | from solidlsp.ls_utils import PlatformId, PlatformUtils 19 | from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams 20 | from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo 21 | from solidlsp.settings import SolidLSPSettings 22 | 23 | from .common import RuntimeDependency, RuntimeDependencyCollection 24 | 25 | 26 | class VtsLanguageServer(SolidLanguageServer): 27 | """ 28 | Provides TypeScript specific instantiation of the LanguageServer class using vtsls. 29 | Contains various configurations and settings specific to TypeScript via vtsls wrapper. 30 | """ 31 | 32 | def __init__( 33 | self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings 34 | ): 35 | """ 36 | Creates a VtsLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. 37 | """ 38 | vts_lsp_executable_path = self._setup_runtime_dependencies(logger, config, solidlsp_settings) 39 | super().__init__( 40 | config, 41 | logger, 42 | repository_root_path, 43 | ProcessLaunchInfo(cmd=vts_lsp_executable_path, cwd=repository_root_path), 44 | "typescript", 45 | solidlsp_settings, 46 | ) 47 | self.server_ready = threading.Event() 48 | self.initialize_searcher_command_available = threading.Event() 49 | 50 | @override 51 | def is_ignored_dirname(self, dirname: str) -> bool: 52 | return super().is_ignored_dirname(dirname) or dirname in [ 53 | "node_modules", 54 | "dist", 55 | "build", 56 | "coverage", 57 | ] 58 | 59 | @classmethod 60 | def _setup_runtime_dependencies( 61 | cls, logger: LanguageServerLogger, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings 62 | ) -> str: 63 | """ 64 | Setup runtime dependencies for VTS Language Server and return the command to start the server. 65 | """ 66 | platform_id = PlatformUtils.get_platform_id() 67 | 68 | valid_platforms = [ 69 | PlatformId.LINUX_x64, 70 | PlatformId.LINUX_arm64, 71 | PlatformId.OSX, 72 | PlatformId.OSX_x64, 73 | PlatformId.OSX_arm64, 74 | PlatformId.WIN_x64, 75 | PlatformId.WIN_arm64, 76 | ] 77 | assert platform_id in valid_platforms, f"Platform {platform_id} is not supported for vtsls at the moment" 78 | 79 | deps = RuntimeDependencyCollection( 80 | [ 81 | RuntimeDependency( 82 | id="vtsls", 83 | description="vtsls language server package", 84 | command="npm install --prefix ./ @vtsls/[email protected]", 85 | platform_id="any", 86 | ), 87 | ] 88 | ) 89 | vts_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "vts-lsp") 90 | vts_executable_path = os.path.join(vts_ls_dir, "vtsls") 91 | 92 | # Verify both node and npm are installed 93 | is_node_installed = shutil.which("node") is not None 94 | assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." 95 | is_npm_installed = shutil.which("npm") is not None 96 | assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." 97 | 98 | # Install vtsls if not already installed 99 | if not os.path.exists(vts_ls_dir): 100 | os.makedirs(vts_ls_dir, exist_ok=True) 101 | deps.install(logger, vts_ls_dir) 102 | 103 | vts_executable_path = os.path.join(vts_ls_dir, "node_modules", ".bin", "vtsls") 104 | 105 | assert os.path.exists(vts_executable_path), "vtsls executable not found. Please install @vtsls/language-server and try again." 106 | return f"{vts_executable_path} --stdio" 107 | 108 | @staticmethod 109 | def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: 110 | """ 111 | Returns the initialize params for the VTS Language Server. 112 | """ 113 | root_uri = pathlib.Path(repository_absolute_path).as_uri() 114 | initialize_params = { 115 | "locale": "en", 116 | "capabilities": { 117 | "textDocument": { 118 | "synchronization": {"didSave": True, "dynamicRegistration": True}, 119 | "definition": {"dynamicRegistration": True}, 120 | "references": {"dynamicRegistration": True}, 121 | "documentSymbol": { 122 | "dynamicRegistration": True, 123 | "hierarchicalDocumentSymbolSupport": True, 124 | "symbolKind": {"valueSet": list(range(1, 27))}, 125 | }, 126 | "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, 127 | "signatureHelp": {"dynamicRegistration": True}, 128 | "codeAction": {"dynamicRegistration": True}, 129 | }, 130 | "workspace": { 131 | "workspaceFolders": True, 132 | "didChangeConfiguration": {"dynamicRegistration": True}, 133 | "symbol": {"dynamicRegistration": True}, 134 | "configuration": True, # This might be needed for vtsls 135 | }, 136 | }, 137 | "processId": os.getpid(), 138 | "rootPath": repository_absolute_path, 139 | "rootUri": root_uri, 140 | "workspaceFolders": [ 141 | { 142 | "uri": root_uri, 143 | "name": os.path.basename(repository_absolute_path), 144 | } 145 | ], 146 | } 147 | return initialize_params 148 | 149 | def _start_server(self): 150 | """ 151 | Starts the VTS Language Server, waits for the server to be ready and yields the LanguageServer instance. 152 | 153 | Usage: 154 | ``` 155 | async with lsp.start_server(): 156 | # LanguageServer has been initialized and ready to serve requests 157 | await lsp.request_definition(...) 158 | await lsp.request_references(...) 159 | # Shutdown the LanguageServer on exit from scope 160 | # LanguageServer has been shutdown 161 | """ 162 | 163 | def register_capability_handler(params): 164 | assert "registrations" in params 165 | for registration in params["registrations"]: 166 | if registration["method"] == "workspace/executeCommand": 167 | self.initialize_searcher_command_available.set() 168 | return 169 | 170 | def execute_client_command_handler(params): 171 | return [] 172 | 173 | def workspace_configuration_handler(params): 174 | # VTS may request workspace configuration 175 | # Return empty configuration for each requested item 176 | if "items" in params: 177 | return [{}] * len(params["items"]) 178 | return {} 179 | 180 | def do_nothing(params): 181 | return 182 | 183 | def window_log_message(msg): 184 | self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) 185 | 186 | def check_experimental_status(params): 187 | """ 188 | Also listen for experimental/serverStatus as a backup signal 189 | """ 190 | if params.get("quiescent") is True: 191 | self.server_ready.set() 192 | self.completions_available.set() 193 | 194 | self.server.on_request("client/registerCapability", register_capability_handler) 195 | self.server.on_notification("window/logMessage", window_log_message) 196 | self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) 197 | self.server.on_request("workspace/configuration", workspace_configuration_handler) 198 | self.server.on_notification("$/progress", do_nothing) 199 | self.server.on_notification("textDocument/publishDiagnostics", do_nothing) 200 | self.server.on_notification("experimental/serverStatus", check_experimental_status) 201 | 202 | self.logger.log("Starting VTS server process", logging.INFO) 203 | self.server.start() 204 | initialize_params = self._get_initialize_params(self.repository_root_path) 205 | 206 | self.logger.log( 207 | "Sending initialize request from LSP client to LSP server and awaiting response", 208 | logging.INFO, 209 | ) 210 | init_response = self.server.send.initialize(initialize_params) 211 | 212 | # VTS-specific capability checks 213 | # Be more flexible with capabilities since vtsls might have different structure 214 | self.logger.log(f"VTS init response capabilities: {init_response['capabilities']}", logging.DEBUG) 215 | 216 | # Basic checks to ensure essential capabilities are present 217 | assert "textDocumentSync" in init_response["capabilities"] 218 | assert "completionProvider" in init_response["capabilities"] 219 | 220 | # Log the actual values for debugging 221 | self.logger.log(f"textDocumentSync: {init_response['capabilities']['textDocumentSync']}", logging.DEBUG) 222 | self.logger.log(f"completionProvider: {init_response['capabilities']['completionProvider']}", logging.DEBUG) 223 | 224 | self.server.notify.initialized({}) 225 | if self.server_ready.wait(timeout=1.0): 226 | self.logger.log("VTS server is ready", logging.INFO) 227 | else: 228 | self.logger.log("Timeout waiting for VTS server to become ready, proceeding anyway", logging.INFO) 229 | # Fallback: assume server is ready after timeout 230 | self.server_ready.set() 231 | self.completions_available.set() 232 | 233 | @override 234 | def _get_wait_time_for_cross_file_referencing(self) -> float: 235 | return 1 236 | ``` -------------------------------------------------------------------------------- /src/solidlsp/language_servers/bash_language_server.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Provides Bash specific instantiation of the LanguageServer class using bash-language-server. 3 | Contains various configurations and settings specific to Bash scripting. 4 | """ 5 | 6 | import logging 7 | import os 8 | import pathlib 9 | import shutil 10 | import threading 11 | 12 | from solidlsp import ls_types 13 | from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection 14 | from solidlsp.ls import SolidLanguageServer 15 | from solidlsp.ls_config import LanguageServerConfig 16 | from solidlsp.ls_logger import LanguageServerLogger 17 | from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams 18 | from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo 19 | from solidlsp.settings import SolidLSPSettings 20 | 21 | 22 | class BashLanguageServer(SolidLanguageServer): 23 | """ 24 | Provides Bash specific instantiation of the LanguageServer class using bash-language-server. 25 | Contains various configurations and settings specific to Bash scripting. 26 | """ 27 | 28 | def __init__( 29 | self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings 30 | ): 31 | """ 32 | Creates a BashLanguageServer instance. This class is not meant to be instantiated directly. 33 | Use LanguageServer.create() instead. 34 | """ 35 | bash_lsp_executable_path = self._setup_runtime_dependencies(logger, config, solidlsp_settings) 36 | super().__init__( 37 | config, 38 | logger, 39 | repository_root_path, 40 | ProcessLaunchInfo(cmd=bash_lsp_executable_path, cwd=repository_root_path), 41 | "bash", 42 | solidlsp_settings, 43 | ) 44 | self.server_ready = threading.Event() 45 | self.initialize_searcher_command_available = threading.Event() 46 | 47 | @classmethod 48 | def _setup_runtime_dependencies( 49 | cls, logger: LanguageServerLogger, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings 50 | ) -> str: 51 | """ 52 | Setup runtime dependencies for Bash Language Server and return the command to start the server. 53 | """ 54 | # Verify both node and npm are installed 55 | is_node_installed = shutil.which("node") is not None 56 | assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." 57 | is_npm_installed = shutil.which("npm") is not None 58 | assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." 59 | 60 | deps = RuntimeDependencyCollection( 61 | [ 62 | RuntimeDependency( 63 | id="bash-language-server", 64 | description="bash-language-server package", 65 | command="npm install --prefix ./ [email protected]", 66 | platform_id="any", 67 | ), 68 | ] 69 | ) 70 | 71 | # Install bash-language-server if not already installed 72 | bash_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "bash-lsp") 73 | bash_executable_path = os.path.join(bash_ls_dir, "node_modules", ".bin", "bash-language-server") 74 | 75 | # Handle Windows executable extension 76 | if os.name == "nt": 77 | bash_executable_path += ".cmd" 78 | 79 | if not os.path.exists(bash_executable_path): 80 | logger.log(f"Bash Language Server executable not found at {bash_executable_path}. Installing...", logging.INFO) 81 | deps.install(logger, bash_ls_dir) 82 | logger.log("Bash language server dependencies installed successfully", logging.INFO) 83 | 84 | if not os.path.exists(bash_executable_path): 85 | raise FileNotFoundError( 86 | f"bash-language-server executable not found at {bash_executable_path}, something went wrong with the installation." 87 | ) 88 | return f"{bash_executable_path} start" 89 | 90 | @staticmethod 91 | def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: 92 | """ 93 | Returns the initialize params for the Bash Language Server. 94 | """ 95 | root_uri = pathlib.Path(repository_absolute_path).as_uri() 96 | initialize_params = { 97 | "locale": "en", 98 | "capabilities": { 99 | "textDocument": { 100 | "synchronization": {"didSave": True, "dynamicRegistration": True}, 101 | "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, 102 | "definition": {"dynamicRegistration": True}, 103 | "references": {"dynamicRegistration": True}, 104 | "documentSymbol": { 105 | "dynamicRegistration": True, 106 | "hierarchicalDocumentSymbolSupport": True, 107 | "symbolKind": {"valueSet": list(range(1, 27))}, 108 | }, 109 | "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, 110 | "signatureHelp": {"dynamicRegistration": True}, 111 | "codeAction": {"dynamicRegistration": True}, 112 | }, 113 | "workspace": { 114 | "workspaceFolders": True, 115 | "didChangeConfiguration": {"dynamicRegistration": True}, 116 | "symbol": {"dynamicRegistration": True}, 117 | }, 118 | }, 119 | "processId": os.getpid(), 120 | "rootPath": repository_absolute_path, 121 | "rootUri": root_uri, 122 | "workspaceFolders": [ 123 | { 124 | "uri": root_uri, 125 | "name": os.path.basename(repository_absolute_path), 126 | } 127 | ], 128 | } 129 | return initialize_params 130 | 131 | def _start_server(self): 132 | """ 133 | Starts the Bash Language Server, waits for the server to be ready and yields the LanguageServer instance. 134 | """ 135 | 136 | def register_capability_handler(params): 137 | assert "registrations" in params 138 | for registration in params["registrations"]: 139 | if registration["method"] == "workspace/executeCommand": 140 | self.initialize_searcher_command_available.set() 141 | return 142 | 143 | def execute_client_command_handler(params): 144 | return [] 145 | 146 | def do_nothing(params): 147 | return 148 | 149 | def window_log_message(msg): 150 | self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) 151 | # Check for bash-language-server ready signals 152 | message_text = msg.get("message", "") 153 | if "Analyzing" in message_text or "analysis complete" in message_text.lower(): 154 | self.logger.log("Bash language server analysis signals detected", logging.INFO) 155 | self.server_ready.set() 156 | self.completions_available.set() 157 | 158 | self.server.on_request("client/registerCapability", register_capability_handler) 159 | self.server.on_notification("window/logMessage", window_log_message) 160 | self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) 161 | self.server.on_notification("$/progress", do_nothing) 162 | self.server.on_notification("textDocument/publishDiagnostics", do_nothing) 163 | 164 | self.logger.log("Starting Bash server process", logging.INFO) 165 | self.server.start() 166 | initialize_params = self._get_initialize_params(self.repository_root_path) 167 | 168 | self.logger.log( 169 | "Sending initialize request from LSP client to LSP server and awaiting response", 170 | logging.INFO, 171 | ) 172 | init_response = self.server.send.initialize(initialize_params) 173 | self.logger.log(f"Received initialize response from bash server: {init_response}", logging.DEBUG) 174 | 175 | # Enhanced capability checks for bash-language-server 5.6.0 176 | assert init_response["capabilities"]["textDocumentSync"] in [1, 2] # Full or Incremental 177 | assert "completionProvider" in init_response["capabilities"] 178 | 179 | # Verify document symbol support is available 180 | if "documentSymbolProvider" in init_response["capabilities"]: 181 | self.logger.log("Bash server supports document symbols", logging.INFO) 182 | else: 183 | self.logger.log("Warning: Bash server does not report document symbol support", logging.WARNING) 184 | 185 | self.server.notify.initialized({}) 186 | 187 | # Wait for server readiness with timeout 188 | self.logger.log("Waiting for Bash language server to be ready...", logging.INFO) 189 | if not self.server_ready.wait(timeout=3.0): 190 | # Fallback: assume server is ready after timeout 191 | self.logger.log("Timeout waiting for bash server ready signal, proceeding anyway", logging.WARNING) 192 | self.server_ready.set() 193 | self.completions_available.set() 194 | else: 195 | self.logger.log("Bash server initialization complete", logging.INFO) 196 | 197 | def request_document_symbols( 198 | self, relative_file_path: str, include_body: bool = False 199 | ) -> tuple[list[ls_types.UnifiedSymbolInformation], list[ls_types.UnifiedSymbolInformation]]: 200 | """ 201 | Request document symbols from bash-language-server via LSP. 202 | 203 | Uses the standard LSP documentSymbol request which provides reliable function detection 204 | for all bash function syntaxes including: 205 | - function name() { ... } (with function keyword) 206 | - name() { ... } (traditional syntax) 207 | - Functions with various indentation levels 208 | - Functions with comments before/after/inside 209 | 210 | Args: 211 | relative_file_path: Path to the bash file relative to repository root 212 | include_body: Whether to include function bodies in symbol information 213 | 214 | Returns: 215 | Tuple of (all_symbols, root_symbols) detected by the LSP server 216 | 217 | """ 218 | self.logger.log(f"Requesting document symbols via LSP for {relative_file_path}", logging.DEBUG) 219 | 220 | # Use the standard LSP approach - bash-language-server handles all function syntaxes correctly 221 | all_symbols, root_symbols = super().request_document_symbols(relative_file_path, include_body) 222 | 223 | # Log detection results for debugging 224 | functions = [s for s in all_symbols if s.get("kind") == 12] 225 | self.logger.log( 226 | f"LSP function detection for {relative_file_path}: Found {len(functions)} functions", 227 | logging.INFO, 228 | ) 229 | 230 | return all_symbols, root_symbols 231 | ``` -------------------------------------------------------------------------------- /src/solidlsp/language_servers/zls.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Provides Zig specific instantiation of the LanguageServer class using ZLS (Zig Language Server). 3 | """ 4 | 5 | import logging 6 | import os 7 | import pathlib 8 | import platform 9 | import shutil 10 | import subprocess 11 | import threading 12 | 13 | from overrides import override 14 | 15 | from solidlsp.ls import SolidLanguageServer 16 | from solidlsp.ls_config import LanguageServerConfig 17 | from solidlsp.ls_logger import LanguageServerLogger 18 | from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams 19 | from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo 20 | from solidlsp.settings import SolidLSPSettings 21 | 22 | 23 | class ZigLanguageServer(SolidLanguageServer): 24 | """ 25 | Provides Zig specific instantiation of the LanguageServer class using ZLS. 26 | """ 27 | 28 | @override 29 | def is_ignored_dirname(self, dirname: str) -> bool: 30 | # For Zig projects, we should ignore: 31 | # - zig-cache: build cache directory 32 | # - zig-out: default build output directory 33 | # - .zig-cache: alternative cache location 34 | # - node_modules: if the project has JavaScript components 35 | return super().is_ignored_dirname(dirname) or dirname in ["zig-cache", "zig-out", ".zig-cache", "node_modules", "build", "dist"] 36 | 37 | @staticmethod 38 | def _get_zig_version(): 39 | """Get the installed Zig version or None if not found.""" 40 | try: 41 | result = subprocess.run(["zig", "version"], capture_output=True, text=True, check=False) 42 | if result.returncode == 0: 43 | return result.stdout.strip() 44 | except FileNotFoundError: 45 | return None 46 | return None 47 | 48 | @staticmethod 49 | def _get_zls_version(): 50 | """Get the installed ZLS version or None if not found.""" 51 | try: 52 | result = subprocess.run(["zls", "--version"], capture_output=True, text=True, check=False) 53 | if result.returncode == 0: 54 | return result.stdout.strip() 55 | except FileNotFoundError: 56 | return None 57 | return None 58 | 59 | @staticmethod 60 | def _check_zls_installed(): 61 | """Check if ZLS is installed in the system.""" 62 | return shutil.which("zls") is not None 63 | 64 | @staticmethod 65 | def _setup_runtime_dependency(): 66 | """ 67 | Check if required Zig runtime dependencies are available. 68 | Raises RuntimeError with helpful message if dependencies are missing. 69 | """ 70 | # Check for Windows and provide error message 71 | if platform.system() == "Windows": 72 | raise RuntimeError( 73 | "Windows is not supported by ZLS in this integration. " 74 | "Cross-file references don't work reliably on Windows. Reason unknown." 75 | ) 76 | 77 | zig_version = ZigLanguageServer._get_zig_version() 78 | if not zig_version: 79 | raise RuntimeError( 80 | "Zig is not installed. Please install Zig from https://ziglang.org/download/ and make sure it is added to your PATH." 81 | ) 82 | 83 | if not ZigLanguageServer._check_zls_installed(): 84 | zls_version = ZigLanguageServer._get_zls_version() 85 | if not zls_version: 86 | raise RuntimeError( 87 | "Found Zig but ZLS (Zig Language Server) is not installed.\n" 88 | "Please install ZLS from https://github.com/zigtools/zls\n" 89 | "You can install it via:\n" 90 | " - Package managers (brew install zls, scoop install zls, etc.)\n" 91 | " - Download pre-built binaries from GitHub releases\n" 92 | " - Build from source with: zig build -Doptimize=ReleaseSafe\n\n" 93 | "After installation, make sure 'zls' is added to your PATH." 94 | ) 95 | 96 | return True 97 | 98 | def __init__( 99 | self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings 100 | ): 101 | self._setup_runtime_dependency() 102 | 103 | super().__init__( 104 | config, 105 | logger, 106 | repository_root_path, 107 | ProcessLaunchInfo(cmd="zls", cwd=repository_root_path), 108 | "zig", 109 | solidlsp_settings, 110 | ) 111 | self.server_ready = threading.Event() 112 | self.request_id = 0 113 | 114 | @staticmethod 115 | def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: 116 | """ 117 | Returns the initialize params for the Zig Language Server. 118 | """ 119 | root_uri = pathlib.Path(repository_absolute_path).as_uri() 120 | initialize_params = { 121 | "locale": "en", 122 | "capabilities": { 123 | "textDocument": { 124 | "synchronization": {"didSave": True, "dynamicRegistration": True}, 125 | "definition": {"dynamicRegistration": True}, 126 | "references": {"dynamicRegistration": True}, 127 | "documentSymbol": { 128 | "dynamicRegistration": True, 129 | "hierarchicalDocumentSymbolSupport": True, 130 | "symbolKind": {"valueSet": list(range(1, 27))}, 131 | }, 132 | "completion": { 133 | "dynamicRegistration": True, 134 | "completionItem": { 135 | "snippetSupport": True, 136 | "commitCharactersSupport": True, 137 | "documentationFormat": ["markdown", "plaintext"], 138 | "deprecatedSupport": True, 139 | "preselectSupport": True, 140 | }, 141 | }, 142 | "hover": { 143 | "dynamicRegistration": True, 144 | "contentFormat": ["markdown", "plaintext"], 145 | }, 146 | }, 147 | "workspace": { 148 | "workspaceFolders": True, 149 | "didChangeConfiguration": {"dynamicRegistration": True}, 150 | "configuration": True, 151 | }, 152 | }, 153 | "processId": os.getpid(), 154 | "rootPath": repository_absolute_path, 155 | "rootUri": root_uri, 156 | "workspaceFolders": [ 157 | { 158 | "uri": root_uri, 159 | "name": os.path.basename(repository_absolute_path), 160 | } 161 | ], 162 | "initializationOptions": { 163 | # ZLS specific options based on schema.json 164 | # Critical paths for ZLS to understand the project 165 | "zig_exe_path": shutil.which("zig"), # Path to zig executable 166 | "zig_lib_path": None, # Let ZLS auto-detect 167 | "build_runner_path": None, # Let ZLS use its built-in runner 168 | "global_cache_path": None, # Let ZLS use default cache 169 | # Build configuration 170 | "enable_build_on_save": True, # Enable to analyze project structure 171 | "build_on_save_args": ["build"], 172 | # Features 173 | "enable_snippets": True, 174 | "enable_argument_placeholders": True, 175 | "semantic_tokens": "full", 176 | "warn_style": False, 177 | "highlight_global_var_declarations": False, 178 | "skip_std_references": False, 179 | "prefer_ast_check_as_child_process": True, 180 | "completion_label_details": True, 181 | # Inlay hints configuration 182 | "inlay_hints_show_variable_type_hints": True, 183 | "inlay_hints_show_struct_literal_field_type": True, 184 | "inlay_hints_show_parameter_name": True, 185 | "inlay_hints_show_builtin": True, 186 | "inlay_hints_exclude_single_argument": True, 187 | "inlay_hints_hide_redundant_param_names": False, 188 | "inlay_hints_hide_redundant_param_names_last_token": False, 189 | }, 190 | } 191 | return initialize_params 192 | 193 | def _start_server(self): 194 | """Start ZLS server process""" 195 | 196 | def register_capability_handler(params): 197 | return 198 | 199 | def window_log_message(msg): 200 | self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) 201 | 202 | def do_nothing(params): 203 | return 204 | 205 | self.server.on_request("client/registerCapability", register_capability_handler) 206 | self.server.on_notification("window/logMessage", window_log_message) 207 | self.server.on_notification("$/progress", do_nothing) 208 | self.server.on_notification("textDocument/publishDiagnostics", do_nothing) 209 | 210 | self.logger.log("Starting ZLS server process", logging.INFO) 211 | self.server.start() 212 | initialize_params = self._get_initialize_params(self.repository_root_path) 213 | 214 | self.logger.log( 215 | "Sending initialize request from LSP client to LSP server and awaiting response", 216 | logging.INFO, 217 | ) 218 | init_response = self.server.send.initialize(initialize_params) 219 | 220 | # Verify server capabilities 221 | assert "textDocumentSync" in init_response["capabilities"] 222 | assert "definitionProvider" in init_response["capabilities"] 223 | assert "documentSymbolProvider" in init_response["capabilities"] 224 | assert "referencesProvider" in init_response["capabilities"] 225 | 226 | self.server.notify.initialized({}) 227 | self.completions_available.set() 228 | 229 | # ZLS server is ready after initialization 230 | self.server_ready.set() 231 | self.server_ready.wait() 232 | 233 | # Open build.zig if it exists to help ZLS understand project structure 234 | build_zig_path = os.path.join(self.repository_root_path, "build.zig") 235 | if os.path.exists(build_zig_path): 236 | try: 237 | with open(build_zig_path, encoding="utf-8") as f: 238 | content = f.read() 239 | uri = pathlib.Path(build_zig_path).as_uri() 240 | self.server.notify.did_open_text_document( 241 | { 242 | "textDocument": { 243 | "uri": uri, 244 | "languageId": "zig", 245 | "version": 1, 246 | "text": content, 247 | } 248 | } 249 | ) 250 | self.logger.log("Opened build.zig to provide project context to ZLS", logging.INFO) 251 | except Exception as e: 252 | self.logger.log(f"Failed to open build.zig: {e}", logging.WARNING) 253 | ``` -------------------------------------------------------------------------------- /test/serena/test_symbol.py: -------------------------------------------------------------------------------- ```python 1 | import pytest 2 | 3 | from src.serena.symbol import LanguageServerSymbol 4 | 5 | 6 | class TestSymbolNameMatching: 7 | def _create_assertion_error_message( 8 | self, 9 | name_path_pattern: str, 10 | symbol_name_path_parts: list[str], 11 | is_substring_match: bool, 12 | expected_result: bool, 13 | actual_result: bool, 14 | ) -> str: 15 | """Helper to create a detailed error message for assertions.""" 16 | qnp_repr = "/".join(symbol_name_path_parts) 17 | 18 | return ( 19 | f"Pattern '{name_path_pattern}' (substring: {is_substring_match}) vs " 20 | f"Qualname parts {symbol_name_path_parts} (as '{qnp_repr}'). " 21 | f"Expected: {expected_result}, Got: {actual_result}" 22 | ) 23 | 24 | @pytest.mark.parametrize( 25 | "name_path_pattern, symbol_name_path_parts, is_substring_match, expected", 26 | [ 27 | # Exact matches, anywhere in the name (is_substring_match=False) 28 | pytest.param("foo", ["foo"], False, True, id="'foo' matches 'foo' exactly (simple)"), 29 | pytest.param("foo/", ["foo"], False, True, id="'foo/' matches 'foo' exactly (simple)"), 30 | pytest.param("foo", ["bar", "foo"], False, True, id="'foo' matches ['bar', 'foo'] exactly (simple, last element)"), 31 | pytest.param("foo", ["foobar"], False, False, id="'foo' does not match 'foobar' exactly (simple)"), 32 | pytest.param( 33 | "foo", ["bar", "foobar"], False, False, id="'foo' does not match ['bar', 'foobar'] exactly (simple, last element)" 34 | ), 35 | pytest.param( 36 | "foo", ["path", "to", "foo"], False, True, id="'foo' matches ['path', 'to', 'foo'] exactly (simple, last element)" 37 | ), 38 | # Exact matches, absolute patterns (is_substring_match=False) 39 | pytest.param("/foo", ["foo"], False, True, id="'/foo' matches ['foo'] exactly (absolute simple)"), 40 | pytest.param("/foo", ["foo", "bar"], False, False, id="'/foo' does not match ['foo', 'bar'] (absolute simple, len mismatch)"), 41 | pytest.param("/foo", ["bar"], False, False, id="'/foo' does not match ['bar'] (absolute simple, name mismatch)"), 42 | pytest.param( 43 | "/foo", ["bar", "foo"], False, False, id="'/foo' does not match ['bar', 'foo'] (absolute simple, position mismatch)" 44 | ), 45 | # Substring matches, anywhere in the name (is_substring_match=True) 46 | pytest.param("foo", ["foobar"], True, True, id="'foo' matches 'foobar' as substring (simple)"), 47 | pytest.param("foo", ["bar", "foobar"], True, True, id="'foo' matches ['bar', 'foobar'] as substring (simple, last element)"), 48 | pytest.param( 49 | "foo", ["barfoo"], True, True, id="'foo' matches 'barfoo' as substring (simple)" 50 | ), # This was potentially ambiguous before 51 | pytest.param("foo", ["baz"], True, False, id="'foo' does not match 'baz' as substring (simple)"), 52 | pytest.param("foo", ["bar", "baz"], True, False, id="'foo' does not match ['bar', 'baz'] as substring (simple, last element)"), 53 | pytest.param("foo", ["my_foobar_func"], True, True, id="'foo' matches 'my_foobar_func' as substring (simple)"), 54 | pytest.param( 55 | "foo", 56 | ["ClassA", "my_foobar_method"], 57 | True, 58 | True, 59 | id="'foo' matches ['ClassA', 'my_foobar_method'] as substring (simple, last element)", 60 | ), 61 | pytest.param("foo", ["my_bar_func"], True, False, id="'foo' does not match 'my_bar_func' as substring (simple)"), 62 | # Substring matches, absolute patterns (is_substring_match=True) 63 | pytest.param("/foo", ["foobar"], True, True, id="'/foo' matches ['foobar'] as substring (absolute simple)"), 64 | pytest.param("/foo/", ["foobar"], True, True, id="'/foo/' matches ['foobar'] as substring (absolute simple, last element)"), 65 | pytest.param("/foo", ["barfoobaz"], True, True, id="'/foo' matches ['barfoobaz'] as substring (absolute simple)"), 66 | pytest.param( 67 | "/foo", ["foo", "bar"], True, False, id="'/foo' does not match ['foo', 'bar'] as substring (absolute simple, len mismatch)" 68 | ), 69 | pytest.param("/foo", ["bar"], True, False, id="'/foo' does not match ['bar'] (absolute simple, no substr)"), 70 | pytest.param( 71 | "/foo", ["bar", "foo"], True, False, id="'/foo' does not match ['bar', 'foo'] (absolute simple, position mismatch)" 72 | ), 73 | pytest.param( 74 | "/foo/", ["bar", "foo"], True, False, id="'/foo/' does not match ['bar', 'foo'] (absolute simple, position mismatch)" 75 | ), 76 | ], 77 | ) 78 | def test_match_simple_name(self, name_path_pattern, symbol_name_path_parts, is_substring_match, expected): 79 | """Tests matching for simple names (no '/' in pattern).""" 80 | result = LanguageServerSymbol.match_name_path(name_path_pattern, symbol_name_path_parts, is_substring_match) 81 | error_msg = self._create_assertion_error_message(name_path_pattern, symbol_name_path_parts, is_substring_match, expected, result) 82 | assert result == expected, error_msg 83 | 84 | @pytest.mark.parametrize( 85 | "name_path_pattern, symbol_name_path_parts, is_substring_match, expected", 86 | [ 87 | # --- Relative patterns (suffix matching) --- 88 | # Exact matches, relative patterns (is_substring_match=False) 89 | pytest.param("bar/foo", ["bar", "foo"], False, True, id="R: 'bar/foo' matches ['bar', 'foo'] exactly"), 90 | pytest.param("bar/foo", ["mod", "bar", "foo"], False, True, id="R: 'bar/foo' matches ['mod', 'bar', 'foo'] exactly (suffix)"), 91 | pytest.param( 92 | "bar/foo", ["bar", "foo", "baz"], False, False, id="R: 'bar/foo' does not match ['bar', 'foo', 'baz'] (pattern shorter)" 93 | ), 94 | pytest.param("bar/foo", ["bar"], False, False, id="R: 'bar/foo' does not match ['bar'] (pattern longer)"), 95 | pytest.param("bar/foo", ["baz", "foo"], False, False, id="R: 'bar/foo' does not match ['baz', 'foo'] (first part mismatch)"), 96 | pytest.param("bar/foo", ["bar", "baz"], False, False, id="R: 'bar/foo' does not match ['bar', 'baz'] (last part mismatch)"), 97 | pytest.param("bar/foo", ["foo"], False, False, id="R: 'bar/foo' does not match ['foo'] (pattern longer)"), 98 | pytest.param( 99 | "bar/foo", ["other", "foo"], False, False, id="R: 'bar/foo' does not match ['other', 'foo'] (first part mismatch)" 100 | ), 101 | pytest.param( 102 | "bar/foo", ["bar", "otherfoo"], False, False, id="R: 'bar/foo' does not match ['bar', 'otherfoo'] (last part mismatch)" 103 | ), 104 | # Substring matches, relative patterns (is_substring_match=True) 105 | pytest.param("bar/foo", ["bar", "foobar"], True, True, id="R: 'bar/foo' matches ['bar', 'foobar'] as substring"), 106 | pytest.param( 107 | "bar/foo", ["mod", "bar", "foobar"], True, True, id="R: 'bar/foo' matches ['mod', 'bar', 'foobar'] as substring (suffix)" 108 | ), 109 | pytest.param("bar/foo", ["bar", "bazfoo"], True, True, id="R: 'bar/foo' matches ['bar', 'bazfoo'] as substring"), 110 | pytest.param("bar/fo", ["bar", "foo"], True, True, id="R: 'bar/fo' matches ['bar', 'foo'] as substring"), # codespell:ignore 111 | pytest.param("bar/foo", ["bar", "baz"], True, False, id="R: 'bar/foo' does not match ['bar', 'baz'] (last no substr)"), 112 | pytest.param( 113 | "bar/foo", ["baz", "foobar"], True, False, id="R: 'bar/foo' does not match ['baz', 'foobar'] (first part mismatch)" 114 | ), 115 | pytest.param( 116 | "bar/foo", ["bar", "my_foobar_method"], True, True, id="R: 'bar/foo' matches ['bar', 'my_foobar_method'] as substring" 117 | ), 118 | pytest.param( 119 | "bar/foo", 120 | ["mod", "bar", "my_foobar_method"], 121 | True, 122 | True, 123 | id="R: 'bar/foo' matches ['mod', 'bar', 'my_foobar_method'] as substring (suffix)", 124 | ), 125 | pytest.param( 126 | "bar/foo", 127 | ["bar", "another_method"], 128 | True, 129 | False, 130 | id="R: 'bar/foo' does not match ['bar', 'another_method'] (last no substr)", 131 | ), 132 | pytest.param( 133 | "bar/foo", 134 | ["other", "my_foobar_method"], 135 | True, 136 | False, 137 | id="R: 'bar/foo' does not match ['other', 'my_foobar_method'] (first part mismatch)", 138 | ), 139 | pytest.param("bar/f", ["bar", "foo"], True, True, id="R: 'bar/f' matches ['bar', 'foo'] as substring"), 140 | # Exact matches, absolute patterns (is_substring_match=False) 141 | pytest.param("/bar/foo", ["bar", "foo"], False, True, id="A: '/bar/foo' matches ['bar', 'foo'] exactly"), 142 | pytest.param( 143 | "/bar/foo", ["bar", "foo", "baz"], False, False, id="A: '/bar/foo' does not match ['bar', 'foo', 'baz'] (pattern shorter)" 144 | ), 145 | pytest.param("/bar/foo", ["bar"], False, False, id="A: '/bar/foo' does not match ['bar'] (pattern longer)"), 146 | pytest.param("/bar/foo", ["baz", "foo"], False, False, id="A: '/bar/foo' does not match ['baz', 'foo'] (first part mismatch)"), 147 | pytest.param("/bar/foo", ["bar", "baz"], False, False, id="A: '/bar/foo' does not match ['bar', 'baz'] (last part mismatch)"), 148 | # Substring matches (is_substring_match=True) 149 | pytest.param("/bar/foo", ["bar", "foobar"], True, True, id="A: '/bar/foo' matches ['bar', 'foobar'] as substring"), 150 | pytest.param("/bar/foo", ["bar", "bazfoo"], True, True, id="A: '/bar/foo' matches ['bar', 'bazfoo'] as substring"), 151 | pytest.param("/bar/fo", ["bar", "foo"], True, True, id="A: '/bar/fo' matches ['bar', 'foo'] as substring"), # codespell:ignore 152 | pytest.param("/bar/foo", ["bar", "baz"], True, False, id="A: '/bar/foo' does not match ['bar', 'baz'] (last no substr)"), 153 | pytest.param( 154 | "/bar/foo", ["baz", "foobar"], True, False, id="A: '/bar/foo' does not match ['baz', 'foobar'] (first part mismatch)" 155 | ), 156 | ], 157 | ) 158 | def test_match_name_path_pattern_path_len_2(self, name_path_pattern, symbol_name_path_parts, is_substring_match, expected): 159 | """Tests matching for qualified names (e.g. 'module/class/func').""" 160 | result = LanguageServerSymbol.match_name_path(name_path_pattern, symbol_name_path_parts, is_substring_match) 161 | error_msg = self._create_assertion_error_message(name_path_pattern, symbol_name_path_parts, is_substring_match, expected, result) 162 | assert result == expected, error_msg 163 | ``` -------------------------------------------------------------------------------- /src/solidlsp/language_servers/typescript_language_server.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Provides TypeScript specific instantiation of the LanguageServer class. Contains various configurations and settings specific to TypeScript. 3 | """ 4 | 5 | import logging 6 | import os 7 | import pathlib 8 | import shutil 9 | import threading 10 | 11 | from overrides import override 12 | from sensai.util.logging import LogTime 13 | 14 | from solidlsp.ls import SolidLanguageServer 15 | from solidlsp.ls_config import LanguageServerConfig 16 | from solidlsp.ls_logger import LanguageServerLogger 17 | from solidlsp.ls_utils import PlatformId, PlatformUtils 18 | from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams 19 | from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo 20 | from solidlsp.settings import SolidLSPSettings 21 | 22 | from .common import RuntimeDependency, RuntimeDependencyCollection 23 | 24 | # Platform-specific imports 25 | if os.name != "nt": # Unix-like systems 26 | import pwd 27 | else: 28 | # Dummy pwd module for Windows 29 | class pwd: 30 | @staticmethod 31 | def getpwuid(uid): 32 | return type("obj", (), {"pw_name": os.environ.get("USERNAME", "unknown")})() 33 | 34 | 35 | # Conditionally import pwd module (Unix-only) 36 | if not PlatformUtils.get_platform_id().value.startswith("win"): 37 | pass 38 | 39 | 40 | class TypeScriptLanguageServer(SolidLanguageServer): 41 | """ 42 | Provides TypeScript specific instantiation of the LanguageServer class. Contains various configurations and settings specific to TypeScript. 43 | """ 44 | 45 | def __init__( 46 | self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings 47 | ): 48 | """ 49 | Creates a TypeScriptLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. 50 | """ 51 | ts_lsp_executable_path = self._setup_runtime_dependencies(logger, config, solidlsp_settings) 52 | super().__init__( 53 | config, 54 | logger, 55 | repository_root_path, 56 | ProcessLaunchInfo(cmd=ts_lsp_executable_path, cwd=repository_root_path), 57 | "typescript", 58 | solidlsp_settings, 59 | ) 60 | self.server_ready = threading.Event() 61 | self.initialize_searcher_command_available = threading.Event() 62 | 63 | @override 64 | def is_ignored_dirname(self, dirname: str) -> bool: 65 | return super().is_ignored_dirname(dirname) or dirname in [ 66 | "node_modules", 67 | "dist", 68 | "build", 69 | "coverage", 70 | ] 71 | 72 | @classmethod 73 | def _setup_runtime_dependencies( 74 | cls, logger: LanguageServerLogger, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings 75 | ) -> list[str]: 76 | """ 77 | Setup runtime dependencies for TypeScript Language Server and return the command to start the server. 78 | """ 79 | platform_id = PlatformUtils.get_platform_id() 80 | 81 | valid_platforms = [ 82 | PlatformId.LINUX_x64, 83 | PlatformId.LINUX_arm64, 84 | PlatformId.OSX, 85 | PlatformId.OSX_x64, 86 | PlatformId.OSX_arm64, 87 | PlatformId.WIN_x64, 88 | PlatformId.WIN_arm64, 89 | ] 90 | assert platform_id in valid_platforms, f"Platform {platform_id} is not supported for multilspy javascript/typescript at the moment" 91 | 92 | deps = RuntimeDependencyCollection( 93 | [ 94 | RuntimeDependency( 95 | id="typescript", 96 | description="typescript package", 97 | command=["npm", "install", "--prefix", "./", "[email protected]"], 98 | platform_id="any", 99 | ), 100 | RuntimeDependency( 101 | id="typescript-language-server", 102 | description="typescript-language-server package", 103 | command=["npm", "install", "--prefix", "./", "[email protected]"], 104 | platform_id="any", 105 | ), 106 | ] 107 | ) 108 | 109 | # Verify both node and npm are installed 110 | is_node_installed = shutil.which("node") is not None 111 | assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." 112 | is_npm_installed = shutil.which("npm") is not None 113 | assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." 114 | 115 | # Verify both node and npm are installed 116 | is_node_installed = shutil.which("node") is not None 117 | assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." 118 | is_npm_installed = shutil.which("npm") is not None 119 | assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." 120 | 121 | # Install typescript and typescript-language-server if not already installed 122 | tsserver_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "ts-lsp") 123 | tsserver_executable_path = os.path.join(tsserver_ls_dir, "node_modules", ".bin", "typescript-language-server") 124 | if not os.path.exists(tsserver_executable_path): 125 | logger.log(f"Typescript Language Server executable not found at {tsserver_executable_path}. Installing...", logging.INFO) 126 | with LogTime("Installation of TypeScript language server dependencies", logger=logger.logger): 127 | deps.install(logger, tsserver_ls_dir) 128 | 129 | if not os.path.exists(tsserver_executable_path): 130 | raise FileNotFoundError( 131 | f"typescript-language-server executable not found at {tsserver_executable_path}, something went wrong with the installation." 132 | ) 133 | return [tsserver_executable_path, "--stdio"] 134 | 135 | @staticmethod 136 | def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: 137 | """ 138 | Returns the initialize params for the TypeScript Language Server. 139 | """ 140 | root_uri = pathlib.Path(repository_absolute_path).as_uri() 141 | initialize_params = { 142 | "locale": "en", 143 | "capabilities": { 144 | "textDocument": { 145 | "synchronization": {"didSave": True, "dynamicRegistration": True}, 146 | "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, 147 | "definition": {"dynamicRegistration": True}, 148 | "references": {"dynamicRegistration": True}, 149 | "documentSymbol": { 150 | "dynamicRegistration": True, 151 | "hierarchicalDocumentSymbolSupport": True, 152 | "symbolKind": {"valueSet": list(range(1, 27))}, 153 | }, 154 | "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, 155 | "signatureHelp": {"dynamicRegistration": True}, 156 | "codeAction": {"dynamicRegistration": True}, 157 | }, 158 | "workspace": { 159 | "workspaceFolders": True, 160 | "didChangeConfiguration": {"dynamicRegistration": True}, 161 | "symbol": {"dynamicRegistration": True}, 162 | }, 163 | }, 164 | "processId": os.getpid(), 165 | "rootPath": repository_absolute_path, 166 | "rootUri": root_uri, 167 | "workspaceFolders": [ 168 | { 169 | "uri": root_uri, 170 | "name": os.path.basename(repository_absolute_path), 171 | } 172 | ], 173 | } 174 | return initialize_params 175 | 176 | def _start_server(self): 177 | """ 178 | Starts the TypeScript Language Server, waits for the server to be ready and yields the LanguageServer instance. 179 | 180 | Usage: 181 | ``` 182 | async with lsp.start_server(): 183 | # LanguageServer has been initialized and ready to serve requests 184 | await lsp.request_definition(...) 185 | await lsp.request_references(...) 186 | # Shutdown the LanguageServer on exit from scope 187 | # LanguageServer has been shutdown 188 | """ 189 | 190 | def register_capability_handler(params): 191 | assert "registrations" in params 192 | for registration in params["registrations"]: 193 | if registration["method"] == "workspace/executeCommand": 194 | self.initialize_searcher_command_available.set() 195 | # TypeScript doesn't have a direct equivalent to resolve_main_method 196 | # You might want to set a different flag or remove this line 197 | # self.resolve_main_method_available.set() 198 | return 199 | 200 | def execute_client_command_handler(params): 201 | return [] 202 | 203 | def do_nothing(params): 204 | return 205 | 206 | def window_log_message(msg): 207 | self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) 208 | 209 | def check_experimental_status(params): 210 | """ 211 | Also listen for experimental/serverStatus as a backup signal 212 | """ 213 | if params.get("quiescent") == True: 214 | self.server_ready.set() 215 | self.completions_available.set() 216 | 217 | self.server.on_request("client/registerCapability", register_capability_handler) 218 | self.server.on_notification("window/logMessage", window_log_message) 219 | self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) 220 | self.server.on_notification("$/progress", do_nothing) 221 | self.server.on_notification("textDocument/publishDiagnostics", do_nothing) 222 | self.server.on_notification("experimental/serverStatus", check_experimental_status) 223 | 224 | self.logger.log("Starting TypeScript server process", logging.INFO) 225 | self.server.start() 226 | initialize_params = self._get_initialize_params(self.repository_root_path) 227 | 228 | self.logger.log( 229 | "Sending initialize request from LSP client to LSP server and awaiting response", 230 | logging.INFO, 231 | ) 232 | init_response = self.server.send.initialize(initialize_params) 233 | 234 | # TypeScript-specific capability checks 235 | assert init_response["capabilities"]["textDocumentSync"] == 2 236 | assert "completionProvider" in init_response["capabilities"] 237 | assert init_response["capabilities"]["completionProvider"] == { 238 | "triggerCharacters": [".", '"', "'", "/", "@", "<"], 239 | "resolveProvider": True, 240 | } 241 | 242 | self.server.notify.initialized({}) 243 | if self.server_ready.wait(timeout=1.0): 244 | self.logger.log("TypeScript server is ready", logging.INFO) 245 | else: 246 | self.logger.log("Timeout waiting for TypeScript server to become ready, proceeding anyway", logging.INFO) 247 | # Fallback: assume server is ready after timeout 248 | self.server_ready.set() 249 | self.completions_available.set() 250 | 251 | @override 252 | def _get_wait_time_for_cross_file_referencing(self) -> float: 253 | return 1 254 | ``` -------------------------------------------------------------------------------- /test/solidlsp/nix/test_nix_basic.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Tests for the Nix language server implementation using nixd. 3 | 4 | These tests validate symbol finding and cross-file reference capabilities for Nix expressions. 5 | """ 6 | 7 | import platform 8 | 9 | import pytest 10 | 11 | from solidlsp import SolidLanguageServer 12 | from solidlsp.ls_config import Language 13 | 14 | # Skip all Nix tests on Windows as Nix doesn't support Windows 15 | pytestmark = pytest.mark.skipif(platform.system() == "Windows", reason="Nix and nil are not available on Windows") 16 | 17 | 18 | @pytest.mark.nix 19 | class TestNixLanguageServer: 20 | """Test Nix language server symbol finding capabilities.""" 21 | 22 | @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) 23 | def test_find_symbols_in_default_nix(self, language_server: SolidLanguageServer) -> None: 24 | """Test finding specific symbols in default.nix.""" 25 | symbols = language_server.request_document_symbols("default.nix") 26 | 27 | assert symbols is not None 28 | assert len(symbols) > 0 29 | 30 | # Extract symbol names from the returned structure 31 | symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols 32 | symbol_names = {sym.get("name") for sym in symbol_list if isinstance(sym, dict)} 33 | 34 | # Verify specific function exists 35 | assert "makeGreeting" in symbol_names, "makeGreeting function not found" 36 | 37 | # Verify exact attribute sets are found 38 | expected_attrs = {"listUtils", "stringUtils"} 39 | found_attrs = symbol_names & expected_attrs 40 | assert found_attrs == expected_attrs, f"Expected exactly {expected_attrs}, found {found_attrs}" 41 | 42 | @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) 43 | def test_find_symbols_in_utils(self, language_server: SolidLanguageServer) -> None: 44 | """Test finding symbols in lib/utils.nix.""" 45 | symbols = language_server.request_document_symbols("lib/utils.nix") 46 | 47 | assert symbols is not None 48 | assert len(symbols) > 0 49 | 50 | symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols 51 | symbol_names = {sym.get("name") for sym in symbol_list if isinstance(sym, dict)} 52 | 53 | # Verify exact utility modules are found 54 | expected_modules = {"math", "strings", "lists", "attrs"} 55 | found_modules = symbol_names & expected_modules 56 | assert found_modules == expected_modules, f"Expected exactly {expected_modules}, found {found_modules}" 57 | 58 | @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) 59 | def test_find_symbols_in_flake(self, language_server: SolidLanguageServer) -> None: 60 | """Test finding symbols in flake.nix.""" 61 | symbols = language_server.request_document_symbols("flake.nix") 62 | 63 | assert symbols is not None 64 | assert len(symbols) > 0 65 | 66 | symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols 67 | symbol_names = {sym.get("name") for sym in symbol_list if isinstance(sym, dict)} 68 | 69 | # Flakes must have either inputs or outputs 70 | assert "inputs" in symbol_names or "outputs" in symbol_names, "Flake must have inputs or outputs" 71 | 72 | @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) 73 | def test_find_symbols_in_module(self, language_server: SolidLanguageServer) -> None: 74 | """Test finding symbols in a NixOS module.""" 75 | symbols = language_server.request_document_symbols("modules/example.nix") 76 | 77 | assert symbols is not None 78 | assert len(symbols) > 0 79 | 80 | symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols 81 | symbol_names = {sym.get("name") for sym in symbol_list if isinstance(sym, dict)} 82 | 83 | # NixOS modules must have either options or config 84 | assert "options" in symbol_names or "config" in symbol_names, "Module must have options or config" 85 | 86 | @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) 87 | def test_find_references_within_file(self, language_server: SolidLanguageServer) -> None: 88 | """Test finding references within the same file.""" 89 | symbols = language_server.request_document_symbols("default.nix") 90 | 91 | assert symbols is not None 92 | symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols 93 | 94 | # Find makeGreeting function 95 | greeting_symbol = None 96 | for sym in symbol_list: 97 | if sym.get("name") == "makeGreeting": 98 | greeting_symbol = sym 99 | break 100 | 101 | assert greeting_symbol is not None, "makeGreeting function not found" 102 | assert "range" in greeting_symbol, "Symbol must have range information" 103 | 104 | range_start = greeting_symbol["range"]["start"] 105 | refs = language_server.request_references("default.nix", range_start["line"], range_start["character"]) 106 | 107 | assert refs is not None 108 | assert isinstance(refs, list) 109 | # nixd finds at least the inherit statement (line 67) 110 | assert len(refs) >= 1, f"Should find at least 1 reference to makeGreeting, found {len(refs)}" 111 | 112 | # Verify makeGreeting is referenced at expected locations 113 | if refs: 114 | ref_lines = sorted([ref["range"]["start"]["line"] for ref in refs]) 115 | # Check if we found the inherit (line 67, 0-indexed: 66) 116 | assert 66 in ref_lines, f"Should find makeGreeting inherit at line 67, found at lines {[l+1 for l in ref_lines]}" 117 | 118 | @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) 119 | def test_hover_information(self, language_server: SolidLanguageServer) -> None: 120 | """Test hover information for symbols.""" 121 | # Get hover info for makeGreeting function 122 | hover_info = language_server.request_hover("default.nix", 9, 5) # Position near makeGreeting 123 | 124 | assert hover_info is not None, "Should provide hover information" 125 | 126 | if isinstance(hover_info, dict) and len(hover_info) > 0: 127 | # If hover info is provided, it should have proper structure 128 | assert "contents" in hover_info or "value" in hover_info, "Hover should have contents or value" 129 | 130 | @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) 131 | def test_cross_file_references_utils_import(self, language_server: SolidLanguageServer) -> None: 132 | """Test finding cross-file references for imported utils.""" 133 | # Find references to 'utils' which is imported in default.nix from lib/utils.nix 134 | # Line 10 in default.nix: utils = import ./lib/utils.nix { inherit lib; }; 135 | refs = language_server.request_references("default.nix", 9, 2) # Position of 'utils' 136 | 137 | assert refs is not None 138 | assert isinstance(refs, list) 139 | 140 | # Should find references within default.nix where utils is used 141 | default_refs = [ref for ref in refs if "default.nix" in ref.get("uri", "")] 142 | # utils is: imported (line 10), used in listUtils.unique (line 24), inherited in exports (line 69) 143 | assert len(default_refs) >= 2, f"Should find at least 2 references to utils in default.nix, found {len(default_refs)}" 144 | 145 | # Verify utils is referenced at expected locations (0-indexed) 146 | if default_refs: 147 | ref_lines = sorted([ref["range"]["start"]["line"] for ref in default_refs]) 148 | # Check for key references - at least the import (line 10) or usage (line 24) 149 | assert ( 150 | 9 in ref_lines or 23 in ref_lines 151 | ), f"Should find utils import or usage, found references at lines {[l+1 for l in ref_lines]}" 152 | 153 | @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) 154 | def test_verify_imports_exist(self, language_server: SolidLanguageServer) -> None: 155 | """Verify that our test files have proper imports set up.""" 156 | # Verify that default.nix imports utils from lib/utils.nix 157 | symbols = language_server.request_document_symbols("default.nix") 158 | 159 | assert symbols is not None 160 | symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols 161 | 162 | # Check that makeGreeting exists (defined in default.nix) 163 | symbol_names = {sym.get("name") for sym in symbol_list if isinstance(sym, dict)} 164 | assert "makeGreeting" in symbol_names, "makeGreeting should be found in default.nix" 165 | 166 | # Verify lib/utils.nix has the expected structure 167 | utils_symbols = language_server.request_document_symbols("lib/utils.nix") 168 | assert utils_symbols is not None 169 | utils_list = utils_symbols[0] if isinstance(utils_symbols, tuple) else utils_symbols 170 | utils_names = {sym.get("name") for sym in utils_list if isinstance(sym, dict)} 171 | 172 | # Verify key functions exist in utils 173 | assert "math" in utils_names, "math should be found in lib/utils.nix" 174 | assert "strings" in utils_names, "strings should be found in lib/utils.nix" 175 | 176 | @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) 177 | def test_go_to_definition_cross_file(self, language_server: SolidLanguageServer) -> None: 178 | """Test go-to-definition from default.nix to lib/utils.nix.""" 179 | # Line 24 in default.nix: unique = utils.lists.unique; 180 | # Test go-to-definition for 'utils' 181 | definitions = language_server.request_definition("default.nix", 23, 14) # Position of 'utils' 182 | 183 | assert definitions is not None 184 | assert isinstance(definitions, list) 185 | 186 | if len(definitions) > 0: 187 | # Should point to the import statement or utils.nix 188 | assert any( 189 | "utils" in def_item.get("uri", "") or "default.nix" in def_item.get("uri", "") for def_item in definitions 190 | ), "Definition should relate to utils import or utils.nix file" 191 | 192 | @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) 193 | def test_definition_navigation_in_flake(self, language_server: SolidLanguageServer) -> None: 194 | """Test definition navigation in flake.nix.""" 195 | # Test that we can navigate to definitions within flake.nix 196 | # Line 69: default = hello-custom; 197 | definitions = language_server.request_definition("flake.nix", 68, 20) # Position of 'hello-custom' 198 | 199 | assert definitions is not None 200 | assert isinstance(definitions, list) 201 | # nixd should find the definition of hello-custom in the same file 202 | if len(definitions) > 0: 203 | assert any( 204 | "flake.nix" in def_item.get("uri", "") for def_item in definitions 205 | ), "Should find hello-custom definition in flake.nix" 206 | 207 | @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) 208 | def test_full_symbol_tree(self, language_server: SolidLanguageServer) -> None: 209 | """Test that full symbol tree is not empty.""" 210 | symbols = language_server.request_full_symbol_tree() 211 | 212 | assert symbols is not None 213 | assert len(symbols) > 0, "Symbol tree should not be empty" 214 | 215 | # The tree should have at least one root node 216 | root = symbols[0] 217 | assert isinstance(root, dict), "Root should be a dict" 218 | assert "name" in root, "Root should have a name" 219 | ``` -------------------------------------------------------------------------------- /test/serena/test_serena_agent.py: -------------------------------------------------------------------------------- ```python 1 | import json 2 | import logging 3 | import os 4 | import time 5 | 6 | import pytest 7 | 8 | import test.solidlsp.clojure as clj 9 | from serena.agent import SerenaAgent 10 | from serena.config.serena_config import ProjectConfig, RegisteredProject, SerenaConfig 11 | from serena.project import Project 12 | from serena.tools import FindReferencingSymbolsTool, FindSymbolTool 13 | from solidlsp.ls_config import Language 14 | from test.conftest import get_repo_path 15 | 16 | 17 | @pytest.fixture 18 | def serena_config(): 19 | """Create an in-memory configuration for tests with test repositories pre-registered.""" 20 | # Create test projects for all supported languages 21 | test_projects = [] 22 | for language in [ 23 | Language.PYTHON, 24 | Language.GO, 25 | Language.JAVA, 26 | Language.KOTLIN, 27 | Language.RUST, 28 | Language.TYPESCRIPT, 29 | Language.PHP, 30 | Language.CSHARP, 31 | Language.CLOJURE, 32 | ]: 33 | repo_path = get_repo_path(language) 34 | if repo_path.exists(): 35 | project_name = f"test_repo_{language}" 36 | project = Project( 37 | project_root=str(repo_path), 38 | project_config=ProjectConfig( 39 | project_name=project_name, 40 | language=language, 41 | ignored_paths=[], 42 | excluded_tools=set(), 43 | read_only=False, 44 | ignore_all_files_in_gitignore=True, 45 | initial_prompt="", 46 | encoding="utf-8", 47 | ), 48 | ) 49 | test_projects.append(RegisteredProject.from_project_instance(project)) 50 | 51 | config = SerenaConfig(gui_log_window_enabled=False, web_dashboard=False, log_level=logging.ERROR) 52 | config.projects = test_projects 53 | return config 54 | 55 | 56 | @pytest.fixture 57 | def serena_agent(request: pytest.FixtureRequest, serena_config): 58 | language = Language(request.param) 59 | project_name = f"test_repo_{language}" 60 | 61 | return SerenaAgent(project=project_name, serena_config=serena_config) 62 | 63 | 64 | class TestSerenaAgent: 65 | @pytest.mark.parametrize( 66 | "serena_agent,symbol_name,expected_kind,expected_file", 67 | [ 68 | pytest.param(Language.PYTHON, "User", "Class", "models.py", marks=pytest.mark.python), 69 | pytest.param(Language.GO, "Helper", "Function", "main.go", marks=pytest.mark.go), 70 | pytest.param(Language.JAVA, "Model", "Class", "Model.java", marks=pytest.mark.java), 71 | pytest.param(Language.KOTLIN, "Model", "Struct", "Model.kt", marks=pytest.mark.kotlin), 72 | pytest.param(Language.RUST, "add", "Function", "lib.rs", marks=pytest.mark.rust), 73 | pytest.param(Language.TYPESCRIPT, "DemoClass", "Class", "index.ts", marks=pytest.mark.typescript), 74 | pytest.param(Language.PHP, "helperFunction", "Function", "helper.php", marks=pytest.mark.php), 75 | pytest.param( 76 | Language.CLOJURE, 77 | "greet", 78 | "Function", 79 | clj.CORE_PATH, 80 | marks=[pytest.mark.clojure, pytest.mark.skipif(clj.CLI_FAIL, reason=f"Clojure CLI not available: {clj.CLI_FAIL}")], 81 | ), 82 | pytest.param(Language.CSHARP, "Calculator", "Class", "Program.cs", marks=pytest.mark.csharp), 83 | ], 84 | indirect=["serena_agent"], 85 | ) 86 | def test_find_symbol(self, serena_agent, symbol_name: str, expected_kind: str, expected_file: str): 87 | agent = serena_agent 88 | find_symbol_tool = agent.get_tool(FindSymbolTool) 89 | result = find_symbol_tool.apply_ex(name_path=symbol_name) 90 | 91 | symbols = json.loads(result) 92 | assert any( 93 | symbol_name in s["name_path"] and expected_kind.lower() in s["kind"].lower() and expected_file in s["relative_path"] 94 | for s in symbols 95 | ), f"Expected to find {symbol_name} ({expected_kind}) in {expected_file}" 96 | 97 | @pytest.mark.parametrize( 98 | "serena_agent,symbol_name,def_file,ref_file", 99 | [ 100 | pytest.param( 101 | Language.PYTHON, 102 | "User", 103 | os.path.join("test_repo", "models.py"), 104 | os.path.join("test_repo", "services.py"), 105 | marks=pytest.mark.python, 106 | ), 107 | pytest.param(Language.GO, "Helper", "main.go", "main.go", marks=pytest.mark.go), 108 | pytest.param( 109 | Language.JAVA, 110 | "Model", 111 | os.path.join("src", "main", "java", "test_repo", "Model.java"), 112 | os.path.join("src", "main", "java", "test_repo", "Main.java"), 113 | marks=pytest.mark.java, 114 | ), 115 | pytest.param( 116 | Language.KOTLIN, 117 | "Model", 118 | os.path.join("src", "main", "kotlin", "test_repo", "Model.kt"), 119 | os.path.join("src", "main", "kotlin", "test_repo", "Main.kt"), 120 | marks=pytest.mark.kotlin, 121 | ), 122 | pytest.param(Language.RUST, "add", os.path.join("src", "lib.rs"), os.path.join("src", "main.rs"), marks=pytest.mark.rust), 123 | pytest.param(Language.TYPESCRIPT, "helperFunction", "index.ts", "use_helper.ts", marks=pytest.mark.typescript), 124 | pytest.param(Language.PHP, "helperFunction", "helper.php", "index.php", marks=pytest.mark.php), 125 | pytest.param( 126 | Language.CLOJURE, 127 | "multiply", 128 | clj.CORE_PATH, 129 | clj.UTILS_PATH, 130 | marks=[pytest.mark.clojure, pytest.mark.skipif(clj.CLI_FAIL, reason=f"Clojure CLI not available: {clj.CLI_FAIL}")], 131 | ), 132 | pytest.param(Language.CSHARP, "Calculator", "Program.cs", "Program.cs", marks=pytest.mark.csharp), 133 | ], 134 | indirect=["serena_agent"], 135 | ) 136 | def test_find_symbol_references(self, serena_agent, symbol_name: str, def_file: str, ref_file: str) -> None: 137 | agent = serena_agent 138 | 139 | # Find the symbol location first 140 | find_symbol_tool = agent.get_tool(FindSymbolTool) 141 | result = find_symbol_tool.apply_ex(name_path=symbol_name, relative_path=def_file) 142 | 143 | time.sleep(1) 144 | symbols = json.loads(result) 145 | # Find the definition 146 | def_symbol = symbols[0] 147 | 148 | # Now find references 149 | find_refs_tool = agent.get_tool(FindReferencingSymbolsTool) 150 | result = find_refs_tool.apply_ex(name_path=def_symbol["name_path"], relative_path=def_symbol["relative_path"]) 151 | 152 | refs = json.loads(result) 153 | assert any( 154 | ref["relative_path"] == ref_file for ref in refs 155 | ), f"Expected to find reference to {symbol_name} in {ref_file}. refs={refs}" 156 | 157 | @pytest.mark.parametrize( 158 | "serena_agent,name_path,substring_matching,expected_symbol_name,expected_kind,expected_file", 159 | [ 160 | pytest.param( 161 | Language.PYTHON, 162 | "OuterClass/NestedClass", 163 | False, 164 | "NestedClass", 165 | "Class", 166 | os.path.join("test_repo", "nested.py"), 167 | id="exact_qualname_class", 168 | marks=pytest.mark.python, 169 | ), 170 | pytest.param( 171 | Language.PYTHON, 172 | "OuterClass/NestedClass/find_me", 173 | False, 174 | "find_me", 175 | "Method", 176 | os.path.join("test_repo", "nested.py"), 177 | id="exact_qualname_method", 178 | marks=pytest.mark.python, 179 | ), 180 | pytest.param( 181 | Language.PYTHON, 182 | "OuterClass/NestedCl", # Substring for NestedClass 183 | True, 184 | "NestedClass", 185 | "Class", 186 | os.path.join("test_repo", "nested.py"), 187 | id="substring_qualname_class", 188 | marks=pytest.mark.python, 189 | ), 190 | pytest.param( 191 | Language.PYTHON, 192 | "OuterClass/NestedClass/find_m", # Substring for find_me 193 | True, 194 | "find_me", 195 | "Method", 196 | os.path.join("test_repo", "nested.py"), 197 | id="substring_qualname_method", 198 | marks=pytest.mark.python, 199 | ), 200 | pytest.param( 201 | Language.PYTHON, 202 | "/OuterClass", # Absolute path 203 | False, 204 | "OuterClass", 205 | "Class", 206 | os.path.join("test_repo", "nested.py"), 207 | id="absolute_qualname_class", 208 | marks=pytest.mark.python, 209 | ), 210 | pytest.param( 211 | Language.PYTHON, 212 | "/OuterClass/NestedClass/find_m", # Absolute path with substring 213 | True, 214 | "find_me", 215 | "Method", 216 | os.path.join("test_repo", "nested.py"), 217 | id="absolute_substring_qualname_method", 218 | marks=pytest.mark.python, 219 | ), 220 | ], 221 | indirect=["serena_agent"], 222 | ) 223 | def test_find_symbol_name_path( 224 | self, 225 | serena_agent, 226 | name_path: str, 227 | substring_matching: bool, 228 | expected_symbol_name: str, 229 | expected_kind: str, 230 | expected_file: str, 231 | ): 232 | agent = serena_agent 233 | 234 | find_symbol_tool = agent.get_tool(FindSymbolTool) 235 | result = find_symbol_tool.apply_ex( 236 | name_path=name_path, 237 | depth=0, 238 | relative_path=None, 239 | include_body=False, 240 | include_kinds=None, 241 | exclude_kinds=None, 242 | substring_matching=substring_matching, 243 | ) 244 | 245 | symbols = json.loads(result) 246 | assert any( 247 | expected_symbol_name == s["name_path"].split("/")[-1] 248 | and expected_kind.lower() in s["kind"].lower() 249 | and expected_file in s["relative_path"] 250 | for s in symbols 251 | ), f"Expected to find {name_path} ({expected_kind}) in {expected_file} for {agent._active_project.language.name}. Symbols: {symbols}" 252 | 253 | @pytest.mark.parametrize( 254 | "serena_agent,name_path", 255 | [ 256 | pytest.param( 257 | Language.PYTHON, 258 | "/NestedClass", # Absolute path, NestedClass is not top-level 259 | id="absolute_path_non_top_level_no_match", 260 | marks=pytest.mark.python, 261 | ), 262 | pytest.param( 263 | Language.PYTHON, 264 | "/NoSuchParent/NestedClass", # Absolute path with non-existent parent 265 | id="absolute_path_non_existent_parent_no_match", 266 | marks=pytest.mark.python, 267 | ), 268 | ], 269 | indirect=["serena_agent"], 270 | ) 271 | def test_find_symbol_name_path_no_match( 272 | self, 273 | serena_agent, 274 | name_path: str, 275 | ): 276 | agent = serena_agent 277 | 278 | find_symbol_tool = agent.get_tool(FindSymbolTool) 279 | result = find_symbol_tool.apply_ex( 280 | name_path=name_path, 281 | depth=0, 282 | substring_matching=True, 283 | ) 284 | 285 | symbols = json.loads(result) 286 | assert not symbols, f"Expected to find no symbols for {name_path}. Symbols found: {symbols}" 287 | ``` -------------------------------------------------------------------------------- /test/solidlsp/clojure/test_clojure_basic.py: -------------------------------------------------------------------------------- ```python 1 | import pytest 2 | 3 | from serena.project import Project 4 | from solidlsp.ls import SolidLanguageServer 5 | from solidlsp.ls_config import Language 6 | from solidlsp.ls_types import UnifiedSymbolInformation 7 | 8 | from . import CLI_FAIL, CORE_PATH, UTILS_PATH 9 | 10 | 11 | @pytest.mark.clojure 12 | @pytest.mark.skipif(CLI_FAIL, reason=f"Clojure CLI not available: {CLI_FAIL}") 13 | class TestLanguageServerBasics: 14 | @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) 15 | def test_basic_definition(self, language_server: SolidLanguageServer): 16 | """ 17 | Test finding definition of 'greet' function call in core.clj 18 | """ 19 | result = language_server.request_definition(CORE_PATH, 20, 12) # Position of 'greet' in (greet "World") 20 | 21 | assert isinstance(result, list) 22 | assert len(result) >= 1 23 | 24 | definition = result[0] 25 | assert definition["relativePath"] == CORE_PATH 26 | assert definition["range"]["start"]["line"] == 2, "Should find the definition of greet function at line 2" 27 | 28 | @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) 29 | def test_cross_file_references(self, language_server: SolidLanguageServer): 30 | """ 31 | Test finding references to 'multiply' function from core.clj 32 | """ 33 | result = language_server.request_references(CORE_PATH, 12, 6) 34 | 35 | assert isinstance(result, list) and len(result) >= 2, "Should find definition + usage in utils.clj" 36 | 37 | usage_found = any( 38 | item["relativePath"] == UTILS_PATH and item["range"]["start"]["line"] == 6 # multiply usage in calculate-area 39 | for item in result 40 | ) 41 | assert usage_found, "Should find multiply usage in utils.clj" 42 | 43 | @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) 44 | def test_completions(self, language_server: SolidLanguageServer): 45 | with language_server.open_file(UTILS_PATH): 46 | # After "core/" in calculate-area 47 | result = language_server.request_completions(UTILS_PATH, 6, 8) 48 | 49 | assert isinstance(result, list) and len(result) > 0 50 | 51 | completion_texts = [item["completionText"] for item in result] 52 | assert any("multiply" in text for text in completion_texts), "Should find 'multiply' function in completions after 'core/'" 53 | 54 | @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) 55 | def test_document_symbols(self, language_server: SolidLanguageServer): 56 | symbols, _ = language_server.request_document_symbols(CORE_PATH) 57 | 58 | assert isinstance(symbols, list) and len(symbols) >= 4, "greet, add, multiply, -main functions" 59 | 60 | # Check that we find the expected function symbols 61 | symbol_names = [symbol["name"] for symbol in symbols] 62 | expected_functions = ["greet", "add", "multiply", "-main"] 63 | 64 | for func_name in expected_functions: 65 | assert func_name in symbol_names, f"Should find {func_name} function in symbols" 66 | 67 | @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) 68 | def test_hover(self, language_server: SolidLanguageServer): 69 | """Test hover on greet function""" 70 | result = language_server.request_hover(CORE_PATH, 2, 7) 71 | 72 | assert result is not None, "Hover should return information for greet function" 73 | assert "contents" in result 74 | # Should contain function signature or documentation 75 | contents = result["contents"] 76 | if isinstance(contents, str): 77 | assert "greet" in contents.lower() 78 | elif isinstance(contents, dict) and "value" in contents: 79 | assert "greet" in contents["value"].lower() 80 | else: 81 | assert False, f"Unexpected contents format: {type(contents)}" 82 | 83 | @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) 84 | def test_workspace_symbols(self, language_server: SolidLanguageServer): 85 | # Search for functions containing "add" 86 | result = language_server.request_workspace_symbol("add") 87 | 88 | assert isinstance(result, list) and len(result) > 0, "Should find at least one symbol containing 'add'" 89 | 90 | # Should find the 'add' function 91 | symbol_names = [symbol["name"] for symbol in result] 92 | assert any("add" in name.lower() for name in symbol_names), f"Should find 'add' function in symbols: {symbol_names}" 93 | 94 | @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) 95 | def test_namespace_functions(self, language_server: SolidLanguageServer): 96 | """Test definition lookup for core/greet usage in utils.clj""" 97 | # Position of 'greet' in core/greet call 98 | result = language_server.request_definition(UTILS_PATH, 11, 25) 99 | 100 | assert isinstance(result, list) 101 | assert len(result) >= 1 102 | 103 | definition = result[0] 104 | assert definition["relativePath"] == CORE_PATH, "Should find the definition of greet in core.clj" 105 | 106 | @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) 107 | def test_request_references_with_content(self, language_server: SolidLanguageServer): 108 | """Test references to multiply function with content""" 109 | references = language_server.request_references(CORE_PATH, 12, 6) 110 | result = [ 111 | language_server.retrieve_content_around_line(ref1["relativePath"], ref1["range"]["start"]["line"], 3, 0) for ref1 in references 112 | ] 113 | 114 | assert result is not None, "Should find references with content" 115 | assert isinstance(result, list) 116 | assert len(result) >= 2, "Should find definition + usage in utils.clj" 117 | 118 | for ref in result: 119 | assert ref.source_file_path is not None, "Each reference should have a source file path" 120 | content_str = ref.to_display_string() 121 | assert len(content_str) > 0, "Content should not be empty" 122 | 123 | # Verify we find the reference in utils.clj with context 124 | utils_refs = [ref for ref in result if ref.source_file_path and "utils.clj" in ref.source_file_path] 125 | assert len(utils_refs) > 0, "Should find reference in utils.clj" 126 | 127 | # The context should contain the calculate-area function 128 | utils_content = utils_refs[0].to_display_string() 129 | assert "calculate-area" in utils_content 130 | 131 | @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) 132 | def test_request_full_symbol_tree(self, language_server: SolidLanguageServer): 133 | """Test retrieving the full symbol tree for project overview 134 | We just check that we find some expected symbols. 135 | """ 136 | result = language_server.request_full_symbol_tree() 137 | 138 | assert result is not None, "Should return symbol tree" 139 | assert isinstance(result, list), "Symbol tree should be a list" 140 | assert len(result) > 0, "Should find symbols in the project" 141 | 142 | def traverse_symbols(symbols, indent=0): 143 | """Recursively traverse symbols to print their structure""" 144 | info = [] 145 | for s in symbols: 146 | name = getattr(s, "name", "NO_NAME") 147 | kind = getattr(s, "kind", "NO_KIND") 148 | info.append(f"{' ' * indent}Symbol: {name}, Kind: {kind}") 149 | if hasattr(s, "children") and s.children: 150 | info.append(" " * indent + "Children:") 151 | info.extend(traverse_symbols(s.children, indent + 2)) 152 | return info 153 | 154 | def list_all_symbols(symbols: list[UnifiedSymbolInformation]): 155 | found = [] 156 | for symbol in symbols: 157 | found.append(symbol["name"]) 158 | found.extend(list_all_symbols(symbol["children"])) 159 | return found 160 | 161 | all_symbol_names = list_all_symbols(result) 162 | 163 | expected_symbols = ["greet", "add", "multiply", "-main", "calculate-area", "format-greeting", "sum-list"] 164 | found_expected = [name for name in expected_symbols if any(name in symbol_name for symbol_name in all_symbol_names)] 165 | 166 | if len(found_expected) < 7: 167 | pytest.fail( 168 | f"Expected to find at least 3 symbols from {expected_symbols}, but found: {found_expected}.\n" 169 | f"All symbol names: {all_symbol_names}\n" 170 | f"Symbol tree structure:\n{traverse_symbols(result)}" 171 | ) 172 | 173 | @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) 174 | def test_request_referencing_symbols(self, language_server: SolidLanguageServer): 175 | """Test finding symbols that reference a given symbol 176 | Finds references to the 'multiply' function. 177 | """ 178 | result = language_server.request_referencing_symbols(CORE_PATH, 12, 6) 179 | assert isinstance(result, list) and len(result) > 0, "Should find at least one referencing symbol" 180 | found_relevant_references = False 181 | for ref in result: 182 | if hasattr(ref, "symbol") and "calculate-area" in ref.symbol["name"]: 183 | found_relevant_references = True 184 | break 185 | 186 | assert found_relevant_references, f"Should have found calculate-area referencing multiply, but got: {result}" 187 | 188 | 189 | class TestProjectBasics: 190 | @pytest.mark.parametrize("project", [Language.CLOJURE], indirect=True) 191 | def test_retrieve_content_around_line(self, project: Project): 192 | """Test retrieving content around specific lines""" 193 | # Test retrieving content around the greet function definition (line 2) 194 | result = project.retrieve_content_around_line(CORE_PATH, 2, 2) 195 | 196 | assert result is not None, "Should retrieve content around line 2" 197 | content_str = result.to_display_string() 198 | assert "greet" in content_str, "Should contain the greet function definition" 199 | assert "defn" in content_str, "Should contain defn keyword" 200 | 201 | # Test retrieving content around multiply function (around line 13) 202 | result = project.retrieve_content_around_line(CORE_PATH, 13, 1) 203 | 204 | assert result is not None, "Should retrieve content around line 13" 205 | content_str = result.to_display_string() 206 | assert "multiply" in content_str, "Should contain multiply function" 207 | 208 | @pytest.mark.parametrize("project", [Language.CLOJURE], indirect=True) 209 | def test_search_files_for_pattern(self, project: Project) -> None: 210 | result = project.search_source_files_for_pattern("defn.*greet") 211 | 212 | assert result is not None, "Pattern search should return results" 213 | assert len(result) > 0, "Should find at least one match for 'defn.*greet'" 214 | 215 | core_matches = [match for match in result if match.source_file_path and "core.clj" in match.source_file_path] 216 | assert len(core_matches) > 0, "Should find greet function in core.clj" 217 | 218 | result = project.search_source_files_for_pattern(":require") 219 | 220 | assert result is not None, "Should find require statements" 221 | utils_matches = [match for match in result if match.source_file_path and "utils.clj" in match.source_file_path] 222 | assert len(utils_matches) > 0, "Should find require statement in utils.clj" 223 | ``` -------------------------------------------------------------------------------- /src/solidlsp/ls_types.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Defines wrapper objects around the types returned by LSP to ensure decoupling between LSP versions and multilspy 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | from enum import Enum, IntEnum 8 | from typing import NotRequired, Union 9 | 10 | from typing_extensions import TypedDict 11 | 12 | URI = str 13 | DocumentUri = str 14 | Uint = int 15 | RegExp = str 16 | 17 | 18 | class Position(TypedDict): 19 | r"""Position in a text document expressed as zero-based line and character 20 | offset. Prior to 3.17 the offsets were always based on a UTF-16 string 21 | representation. So a string of the form `a𐐀b` the character offset of the 22 | character `a` is 0, the character offset of `𐐀` is 1 and the character 23 | offset of b is 3 since `𐐀` is represented using two code units in UTF-16. 24 | Since 3.17 clients and servers can agree on a different string encoding 25 | representation (e.g. UTF-8). The client announces it's supported encoding 26 | via the client capability [`general.positionEncodings`](#clientCapabilities). 27 | The value is an array of position encodings the client supports, with 28 | decreasing preference (e.g. the encoding at index `0` is the most preferred 29 | one). To stay backwards compatible the only mandatory encoding is UTF-16 30 | represented via the string `utf-16`. The server can pick one of the 31 | encodings offered by the client and signals that encoding back to the 32 | client via the initialize result's property 33 | [`capabilities.positionEncoding`](#serverCapabilities). If the string value 34 | `utf-16` is missing from the client's capability `general.positionEncodings` 35 | servers can safely assume that the client supports UTF-16. If the server 36 | omits the position encoding in its initialize result the encoding defaults 37 | to the string value `utf-16`. Implementation considerations: since the 38 | conversion from one encoding into another requires the content of the 39 | file / line the conversion is best done where the file is read which is 40 | usually on the server side. 41 | 42 | Positions are line end character agnostic. So you can not specify a position 43 | that denotes `\r|\n` or `\n|` where `|` represents the character offset. 44 | 45 | @since 3.17.0 - support for negotiated position encoding. 46 | """ 47 | 48 | line: Uint 49 | """ Line position in a document (zero-based). 50 | 51 | If a line number is greater than the number of lines in a document, it defaults back to the number of lines in the document. 52 | If a line number is negative, it defaults to 0. """ 53 | character: Uint 54 | """ Character offset on a line in a document (zero-based). 55 | 56 | The meaning of this offset is determined by the negotiated 57 | `PositionEncodingKind`. 58 | 59 | If the character value is greater than the line length it defaults back to the 60 | line length. """ 61 | 62 | 63 | class Range(TypedDict): 64 | """A range in a text document expressed as (zero-based) start and end positions. 65 | 66 | If you want to specify a range that contains a line including the line ending 67 | character(s) then use an end position denoting the start of the next line. 68 | For example: 69 | ```ts 70 | { 71 | start: { line: 5, character: 23 } 72 | end : { line 6, character : 0 } 73 | } 74 | ``` 75 | """ 76 | 77 | start: Position 78 | """ The range's start position. """ 79 | end: Position 80 | """ The range's end position. """ 81 | 82 | 83 | class Location(TypedDict): 84 | """Represents a location inside a resource, such as a line 85 | inside a text file. 86 | """ 87 | 88 | uri: DocumentUri 89 | range: Range 90 | absolutePath: str 91 | relativePath: str | None 92 | 93 | 94 | class CompletionItemKind(IntEnum): 95 | """The kind of a completion entry.""" 96 | 97 | Text = 1 98 | Method = 2 99 | Function = 3 100 | Constructor = 4 101 | Field = 5 102 | Variable = 6 103 | Class = 7 104 | Interface = 8 105 | Module = 9 106 | Property = 10 107 | Unit = 11 108 | Value = 12 109 | Enum = 13 110 | Keyword = 14 111 | Snippet = 15 112 | Color = 16 113 | File = 17 114 | Reference = 18 115 | Folder = 19 116 | EnumMember = 20 117 | Constant = 21 118 | Struct = 22 119 | Event = 23 120 | Operator = 24 121 | TypeParameter = 25 122 | 123 | 124 | class CompletionItem(TypedDict): 125 | """A completion item represents a text snippet that is 126 | proposed to complete text that is being typed. 127 | """ 128 | 129 | completionText: str 130 | """ The completionText of this completion item. 131 | 132 | The completionText property is also by default the text that 133 | is inserted when selecting this completion.""" 134 | 135 | kind: CompletionItemKind 136 | """ The kind of this completion item. Based of the kind 137 | an icon is chosen by the editor. """ 138 | 139 | detail: NotRequired[str] 140 | """ A human-readable string with additional information 141 | about this item, like type or symbol information. """ 142 | 143 | 144 | class SymbolKind(IntEnum): 145 | """A symbol kind.""" 146 | 147 | File = 1 148 | Module = 2 149 | Namespace = 3 150 | Package = 4 151 | Class = 5 152 | Method = 6 153 | Property = 7 154 | Field = 8 155 | Constructor = 9 156 | Enum = 10 157 | Interface = 11 158 | Function = 12 159 | Variable = 13 160 | Constant = 14 161 | String = 15 162 | Number = 16 163 | Boolean = 17 164 | Array = 18 165 | Object = 19 166 | Key = 20 167 | Null = 21 168 | EnumMember = 22 169 | Struct = 23 170 | Event = 24 171 | Operator = 25 172 | TypeParameter = 26 173 | 174 | 175 | class SymbolTag(IntEnum): 176 | """Symbol tags are extra annotations that tweak the rendering of a symbol. 177 | 178 | @since 3.16 179 | """ 180 | 181 | Deprecated = 1 182 | """ Render a symbol as obsolete, usually using a strike-out. """ 183 | 184 | 185 | class UnifiedSymbolInformation(TypedDict): 186 | """Represents information about programming constructs like variables, classes, 187 | interfaces etc. 188 | """ 189 | 190 | deprecated: NotRequired[bool] 191 | """ Indicates if this symbol is deprecated. 192 | 193 | @deprecated Use tags instead """ 194 | location: NotRequired[Location] 195 | """ The location of this symbol. The location's range is used by a tool 196 | to reveal the location in the editor. If the symbol is selected in the 197 | tool the range's start information is used to position the cursor. So 198 | the range usually spans more than the actual symbol's name and does 199 | normally include things like visibility modifiers. 200 | 201 | The range doesn't have to denote a node range in the sense of an abstract 202 | syntax tree. It can therefore not be used to re-construct a hierarchy of 203 | the symbols. """ 204 | name: str 205 | """ The name of this symbol. """ 206 | kind: SymbolKind 207 | """ The kind of this symbol. """ 208 | tags: NotRequired[list[SymbolTag]] 209 | """ Tags for this symbol. 210 | 211 | @since 3.16.0 """ 212 | containerName: NotRequired[str] 213 | """ The name of the symbol containing this symbol. This information is for 214 | user interface purposes (e.g. to render a qualifier in the user interface 215 | if necessary). It can't be used to re-infer a hierarchy for the document 216 | symbols. 217 | 218 | Note: within Serena, the parent attribute was added and should be used instead. 219 | Most LS don't provide containerName. 220 | """ 221 | 222 | detail: NotRequired[str] 223 | """ More detail for this symbol, e.g the signature of a function. """ 224 | 225 | range: NotRequired[Range] 226 | """ The range enclosing this symbol not including leading/trailing whitespace but everything else 227 | like comments. This information is typically used to determine if the clients cursor is 228 | inside the symbol to reveal in the symbol in the UI. """ 229 | selectionRange: NotRequired[Range] 230 | """ The range that should be selected and revealed when this symbol is being picked, e.g the name of a function. 231 | Must be contained by the `range`. """ 232 | 233 | body: NotRequired[str] 234 | """ The body of the symbol. """ 235 | 236 | children: list[UnifiedSymbolInformation] 237 | """ The children of the symbol. 238 | Added to be compatible with `lsp_types.DocumentSymbol`, 239 | since it is sometimes useful to have the children of the symbol as a user-facing feature.""" 240 | 241 | parent: NotRequired[UnifiedSymbolInformation | None] 242 | """The parent of the symbol, if there is any. Added with Serena, not part of the LSP. 243 | All symbols except the root packages will have a parent. 244 | """ 245 | 246 | 247 | class MarkupKind(Enum): 248 | """Describes the content type that a client supports in various 249 | result literals like `Hover`, `ParameterInfo` or `CompletionItem`. 250 | 251 | Please note that `MarkupKinds` must not start with a `$`. This kinds 252 | are reserved for internal usage. 253 | """ 254 | 255 | PlainText = "plaintext" 256 | """ Plain text is supported as a content format """ 257 | Markdown = "markdown" 258 | """ Markdown is supported as a content format """ 259 | 260 | 261 | class __MarkedString_Type_1(TypedDict): 262 | language: str 263 | value: str 264 | 265 | 266 | MarkedString = Union[str, "__MarkedString_Type_1"] 267 | """ MarkedString can be used to render human readable text. It is either a markdown string 268 | or a code-block that provides a language and a code snippet. The language identifier 269 | is semantically equal to the optional language identifier in fenced code blocks in GitHub 270 | issues. See https://help.github.com/articles/creating-and-highlighting-code-blocks/#syntax-highlighting 271 | 272 | The pair of a language and a value is an equivalent to markdown: 273 | ```${language} 274 | ${value} 275 | ``` 276 | 277 | Note that markdown strings will be sanitized - that means html will be escaped. 278 | @deprecated use MarkupContent instead. """ 279 | 280 | 281 | class MarkupContent(TypedDict): 282 | r"""A `MarkupContent` literal represents a string value which content is interpreted base on its 283 | kind flag. Currently the protocol supports `plaintext` and `markdown` as markup kinds. 284 | 285 | If the kind is `markdown` then the value can contain fenced code blocks like in GitHub issues. 286 | See https://help.github.com/articles/creating-and-highlighting-code-blocks/#syntax-highlighting 287 | 288 | Here is an example how such a string can be constructed using JavaScript / TypeScript: 289 | ```ts 290 | let markdown: MarkdownContent = { 291 | kind: MarkupKind.Markdown, 292 | value: [ 293 | '# Header', 294 | 'Some text', 295 | '```typescript', 296 | 'someCode();', 297 | '```' 298 | ].join('\n') 299 | }; 300 | ``` 301 | 302 | *Please Note* that clients might sanitize the return markdown. A client could decide to 303 | remove HTML from the markdown to avoid script execution. 304 | """ 305 | 306 | kind: MarkupKind 307 | """ The type of the Markup """ 308 | value: str 309 | """ The content itself """ 310 | 311 | 312 | class Hover(TypedDict): 313 | """The result of a hover request.""" 314 | 315 | contents: MarkupContent | MarkedString | list[MarkedString] 316 | """ The hover's content """ 317 | range: NotRequired[Range] 318 | """ An optional range inside the text document that is used to 319 | visualize the hover, e.g. by changing the background color. """ 320 | 321 | 322 | class DiagnosticsSeverity(IntEnum): 323 | ERROR = 1 324 | WARNING = 2 325 | INFORMATION = 3 326 | HINT = 4 327 | 328 | 329 | class Diagnostic(TypedDict): 330 | """Diagnostic information for a text document.""" 331 | 332 | uri: DocumentUri 333 | """ The URI of the text document to which the diagnostics apply. """ 334 | range: Range 335 | """ The range of the text document to which the diagnostics apply. """ 336 | severity: NotRequired[DiagnosticsSeverity] 337 | """ The severity of the diagnostic. """ 338 | message: str 339 | """ The diagnostic message. """ 340 | code: str 341 | """ The code of the diagnostic. """ 342 | source: NotRequired[str] 343 | """ The source of the diagnostic, e.g. the name of the tool that produced it. """ 344 | ``` -------------------------------------------------------------------------------- /src/solidlsp/ls_config.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Configuration objects for language servers 3 | """ 4 | 5 | import fnmatch 6 | from collections.abc import Iterable 7 | from dataclasses import dataclass, field 8 | from enum import Enum 9 | from typing import TYPE_CHECKING, Self 10 | 11 | if TYPE_CHECKING: 12 | from solidlsp import SolidLanguageServer 13 | 14 | 15 | class FilenameMatcher: 16 | def __init__(self, *patterns: str) -> None: 17 | """ 18 | :param patterns: fnmatch-compatible patterns 19 | """ 20 | self.patterns = patterns 21 | 22 | def is_relevant_filename(self, fn: str) -> bool: 23 | for pattern in self.patterns: 24 | if fnmatch.fnmatch(fn, pattern): 25 | return True 26 | return False 27 | 28 | 29 | class Language(str, Enum): 30 | """ 31 | Possible languages with Multilspy. 32 | """ 33 | 34 | CSHARP = "csharp" 35 | PYTHON = "python" 36 | RUST = "rust" 37 | JAVA = "java" 38 | KOTLIN = "kotlin" 39 | TYPESCRIPT = "typescript" 40 | GO = "go" 41 | RUBY = "ruby" 42 | DART = "dart" 43 | CPP = "cpp" 44 | PHP = "php" 45 | R = "r" 46 | PERL = "perl" 47 | CLOJURE = "clojure" 48 | ELIXIR = "elixir" 49 | ELM = "elm" 50 | TERRAFORM = "terraform" 51 | SWIFT = "swift" 52 | BASH = "bash" 53 | ZIG = "zig" 54 | LUA = "lua" 55 | NIX = "nix" 56 | ERLANG = "erlang" 57 | AL = "al" 58 | # Experimental or deprecated Language Servers 59 | TYPESCRIPT_VTS = "typescript_vts" 60 | """Use the typescript language server through the natively bundled vscode extension via https://github.com/yioneko/vtsls""" 61 | PYTHON_JEDI = "python_jedi" 62 | """Jedi language server for Python (instead of pyright, which is the default)""" 63 | CSHARP_OMNISHARP = "csharp_omnisharp" 64 | """OmniSharp language server for C# (instead of the default csharp-ls by microsoft). 65 | Currently has problems with finding references, and generally seems less stable and performant. 66 | """ 67 | RUBY_SOLARGRAPH = "ruby_solargraph" 68 | """Solargraph language server for Ruby (legacy, experimental). 69 | Use Language.RUBY (ruby-lsp) for better performance and modern LSP features. 70 | """ 71 | MARKDOWN = "markdown" 72 | """Marksman language server for Markdown (experimental). 73 | Must be explicitly specified as the main language, not auto-detected. 74 | This is an edge case primarily useful when working on documentation-heavy projects. 75 | """ 76 | 77 | @classmethod 78 | def iter_all(cls, include_experimental: bool = False) -> Iterable[Self]: 79 | for lang in cls: 80 | if include_experimental or not lang.is_experimental(): 81 | yield lang 82 | 83 | def is_experimental(self) -> bool: 84 | """ 85 | Check if the language server is experimental or deprecated. 86 | """ 87 | return self in {self.TYPESCRIPT_VTS, self.PYTHON_JEDI, self.CSHARP_OMNISHARP, self.RUBY_SOLARGRAPH, self.MARKDOWN} 88 | 89 | def __str__(self) -> str: 90 | return self.value 91 | 92 | def get_source_fn_matcher(self) -> FilenameMatcher: 93 | match self: 94 | case self.PYTHON | self.PYTHON_JEDI: 95 | return FilenameMatcher("*.py", "*.pyi") 96 | case self.JAVA: 97 | return FilenameMatcher("*.java") 98 | case self.TYPESCRIPT | self.TYPESCRIPT_VTS: 99 | # see https://github.com/oraios/serena/issues/204 100 | path_patterns = [] 101 | for prefix in ["c", "m", ""]: 102 | for postfix in ["x", ""]: 103 | for base_pattern in ["ts", "js"]: 104 | path_patterns.append(f"*.{prefix}{base_pattern}{postfix}") 105 | return FilenameMatcher(*path_patterns) 106 | case self.CSHARP | self.CSHARP_OMNISHARP: 107 | return FilenameMatcher("*.cs") 108 | case self.RUST: 109 | return FilenameMatcher("*.rs") 110 | case self.GO: 111 | return FilenameMatcher("*.go") 112 | case self.RUBY: 113 | return FilenameMatcher("*.rb", "*.erb") 114 | case self.RUBY_SOLARGRAPH: 115 | return FilenameMatcher("*.rb") 116 | case self.CPP: 117 | return FilenameMatcher("*.cpp", "*.h", "*.hpp", "*.c", "*.hxx", "*.cc", "*.cxx") 118 | case self.KOTLIN: 119 | return FilenameMatcher("*.kt", "*.kts") 120 | case self.DART: 121 | return FilenameMatcher("*.dart") 122 | case self.PHP: 123 | return FilenameMatcher("*.php") 124 | case self.R: 125 | return FilenameMatcher("*.R", "*.r", "*.Rmd", "*.Rnw") 126 | case self.PERL: 127 | return FilenameMatcher("*.pl", "*.pm", "*.t") 128 | case self.CLOJURE: 129 | return FilenameMatcher("*.clj", "*.cljs", "*.cljc", "*.edn") # codespell:ignore edn 130 | case self.ELIXIR: 131 | return FilenameMatcher("*.ex", "*.exs") 132 | case self.ELM: 133 | return FilenameMatcher("*.elm") 134 | case self.TERRAFORM: 135 | return FilenameMatcher("*.tf", "*.tfvars", "*.tfstate") 136 | case self.SWIFT: 137 | return FilenameMatcher("*.swift") 138 | case self.BASH: 139 | return FilenameMatcher("*.sh", "*.bash") 140 | case self.ZIG: 141 | return FilenameMatcher("*.zig", "*.zon") 142 | case self.LUA: 143 | return FilenameMatcher("*.lua") 144 | case self.NIX: 145 | return FilenameMatcher("*.nix") 146 | case self.ERLANG: 147 | return FilenameMatcher("*.erl", "*.hrl", "*.escript", "*.config", "*.app", "*.app.src") 148 | case self.AL: 149 | return FilenameMatcher("*.al", "*.dal") 150 | case self.MARKDOWN: 151 | return FilenameMatcher("*.md", "*.markdown") 152 | case _: 153 | raise ValueError(f"Unhandled language: {self}") 154 | 155 | def get_ls_class(self) -> type["SolidLanguageServer"]: 156 | match self: 157 | case self.PYTHON: 158 | from solidlsp.language_servers.pyright_server import PyrightServer 159 | 160 | return PyrightServer 161 | case self.PYTHON_JEDI: 162 | from solidlsp.language_servers.jedi_server import JediServer 163 | 164 | return JediServer 165 | case self.JAVA: 166 | from solidlsp.language_servers.eclipse_jdtls import EclipseJDTLS 167 | 168 | return EclipseJDTLS 169 | case self.KOTLIN: 170 | from solidlsp.language_servers.kotlin_language_server import KotlinLanguageServer 171 | 172 | return KotlinLanguageServer 173 | case self.RUST: 174 | from solidlsp.language_servers.rust_analyzer import RustAnalyzer 175 | 176 | return RustAnalyzer 177 | case self.CSHARP: 178 | from solidlsp.language_servers.csharp_language_server import CSharpLanguageServer 179 | 180 | return CSharpLanguageServer 181 | case self.CSHARP_OMNISHARP: 182 | from solidlsp.language_servers.omnisharp import OmniSharp 183 | 184 | return OmniSharp 185 | case self.TYPESCRIPT: 186 | from solidlsp.language_servers.typescript_language_server import TypeScriptLanguageServer 187 | 188 | return TypeScriptLanguageServer 189 | case self.TYPESCRIPT_VTS: 190 | from solidlsp.language_servers.vts_language_server import VtsLanguageServer 191 | 192 | return VtsLanguageServer 193 | case self.GO: 194 | from solidlsp.language_servers.gopls import Gopls 195 | 196 | return Gopls 197 | case self.RUBY: 198 | from solidlsp.language_servers.ruby_lsp import RubyLsp 199 | 200 | return RubyLsp 201 | case self.RUBY_SOLARGRAPH: 202 | from solidlsp.language_servers.solargraph import Solargraph 203 | 204 | return Solargraph 205 | case self.DART: 206 | from solidlsp.language_servers.dart_language_server import DartLanguageServer 207 | 208 | return DartLanguageServer 209 | case self.CPP: 210 | from solidlsp.language_servers.clangd_language_server import ClangdLanguageServer 211 | 212 | return ClangdLanguageServer 213 | case self.PHP: 214 | from solidlsp.language_servers.intelephense import Intelephense 215 | 216 | return Intelephense 217 | case self.PERL: 218 | from solidlsp.language_servers.perl_language_server import PerlLanguageServer 219 | 220 | return PerlLanguageServer 221 | case self.CLOJURE: 222 | from solidlsp.language_servers.clojure_lsp import ClojureLSP 223 | 224 | return ClojureLSP 225 | case self.ELIXIR: 226 | from solidlsp.language_servers.elixir_tools.elixir_tools import ElixirTools 227 | 228 | return ElixirTools 229 | case self.ELM: 230 | from solidlsp.language_servers.elm_language_server import ElmLanguageServer 231 | 232 | return ElmLanguageServer 233 | case self.TERRAFORM: 234 | from solidlsp.language_servers.terraform_ls import TerraformLS 235 | 236 | return TerraformLS 237 | case self.SWIFT: 238 | from solidlsp.language_servers.sourcekit_lsp import SourceKitLSP 239 | 240 | return SourceKitLSP 241 | case self.BASH: 242 | from solidlsp.language_servers.bash_language_server import BashLanguageServer 243 | 244 | return BashLanguageServer 245 | case self.ZIG: 246 | from solidlsp.language_servers.zls import ZigLanguageServer 247 | 248 | return ZigLanguageServer 249 | case self.NIX: 250 | from solidlsp.language_servers.nixd_ls import NixLanguageServer 251 | 252 | return NixLanguageServer 253 | case self.LUA: 254 | from solidlsp.language_servers.lua_ls import LuaLanguageServer 255 | 256 | return LuaLanguageServer 257 | case self.ERLANG: 258 | from solidlsp.language_servers.erlang_language_server import ErlangLanguageServer 259 | 260 | return ErlangLanguageServer 261 | case self.AL: 262 | from solidlsp.language_servers.al_language_server import ALLanguageServer 263 | 264 | return ALLanguageServer 265 | case self.MARKDOWN: 266 | from solidlsp.language_servers.marksman import Marksman 267 | 268 | return Marksman 269 | case self.R: 270 | from solidlsp.language_servers.r_language_server import RLanguageServer 271 | 272 | return RLanguageServer 273 | case _: 274 | raise ValueError(f"Unhandled language: {self}") 275 | 276 | @classmethod 277 | def from_ls_class(cls, ls_class: type["SolidLanguageServer"]) -> Self: 278 | """ 279 | Get the Language enum value from a SolidLanguageServer class. 280 | 281 | :param ls_class: The SolidLanguageServer class to find the corresponding Language for 282 | :return: The Language enum value 283 | :raises ValueError: If the language server class is not supported 284 | """ 285 | for enum_instance in cls: 286 | if enum_instance.get_ls_class() == ls_class: 287 | return enum_instance 288 | raise ValueError(f"Unhandled language server class: {ls_class}") 289 | 290 | 291 | @dataclass 292 | class LanguageServerConfig: 293 | """ 294 | Configuration parameters 295 | """ 296 | 297 | code_language: Language 298 | trace_lsp_communication: bool = False 299 | start_independent_lsp_process: bool = True 300 | ignored_paths: list[str] = field(default_factory=list) 301 | """Paths, dirs or glob-like patterns. The matching will follow the same logic as for .gitignore entries""" 302 | 303 | @classmethod 304 | def from_dict(cls, env: dict): 305 | """ 306 | Create a MultilspyConfig instance from a dictionary 307 | """ 308 | import inspect 309 | 310 | return cls(**{k: v for k, v in env.items() if k in inspect.signature(cls).parameters}) 311 | ``` -------------------------------------------------------------------------------- /src/solidlsp/language_servers/lua_ls.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Provides Lua specific instantiation of the LanguageServer class using lua-language-server. 3 | """ 4 | 5 | import logging 6 | import os 7 | import pathlib 8 | import platform 9 | import shutil 10 | import tarfile 11 | import threading 12 | import zipfile 13 | from pathlib import Path 14 | 15 | import requests 16 | from overrides import override 17 | 18 | from solidlsp.ls import SolidLanguageServer 19 | from solidlsp.ls_config import LanguageServerConfig 20 | from solidlsp.ls_logger import LanguageServerLogger 21 | from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams 22 | from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo 23 | from solidlsp.settings import SolidLSPSettings 24 | 25 | 26 | class LuaLanguageServer(SolidLanguageServer): 27 | """ 28 | Provides Lua specific instantiation of the LanguageServer class using lua-language-server. 29 | """ 30 | 31 | @override 32 | def is_ignored_dirname(self, dirname: str) -> bool: 33 | # For Lua projects, we should ignore: 34 | # - .luarocks: package manager cache 35 | # - lua_modules: local dependencies 36 | # - node_modules: if the project has JavaScript components 37 | return super().is_ignored_dirname(dirname) or dirname in [".luarocks", "lua_modules", "node_modules", "build", "dist", ".cache"] 38 | 39 | @staticmethod 40 | def _get_lua_ls_path(): 41 | """Get the path to lua-language-server executable.""" 42 | # First check if it's in PATH 43 | lua_ls = shutil.which("lua-language-server") 44 | if lua_ls: 45 | return lua_ls 46 | 47 | # Check common installation locations 48 | home = Path.home() 49 | possible_paths = [ 50 | home / ".local" / "bin" / "lua-language-server", 51 | home / ".serena" / "language_servers" / "lua" / "bin" / "lua-language-server", 52 | Path("/usr/local/bin/lua-language-server"), 53 | Path("/opt/lua-language-server/bin/lua-language-server"), 54 | ] 55 | 56 | # Add Windows-specific paths 57 | if platform.system() == "Windows": 58 | possible_paths.extend( 59 | [ 60 | home / "AppData" / "Local" / "lua-language-server" / "bin" / "lua-language-server.exe", 61 | home / ".serena" / "language_servers" / "lua" / "bin" / "lua-language-server.exe", 62 | ] 63 | ) 64 | 65 | for path in possible_paths: 66 | if path.exists(): 67 | return str(path) 68 | 69 | return None 70 | 71 | @staticmethod 72 | def _download_lua_ls(): 73 | """Download and install lua-language-server if not present.""" 74 | system = platform.system() 75 | machine = platform.machine().lower() 76 | lua_ls_version = "3.15.0" 77 | 78 | # Map platform and architecture to download URL 79 | if system == "Linux": 80 | if machine in ["x86_64", "amd64"]: 81 | download_name = f"lua-language-server-{lua_ls_version}-linux-x64.tar.gz" 82 | elif machine in ["aarch64", "arm64"]: 83 | download_name = f"lua-language-server-{lua_ls_version}-linux-arm64.tar.gz" 84 | else: 85 | raise RuntimeError(f"Unsupported Linux architecture: {machine}") 86 | elif system == "Darwin": 87 | if machine in ["x86_64", "amd64"]: 88 | download_name = f"lua-language-server-{lua_ls_version}-darwin-x64.tar.gz" 89 | elif machine in ["arm64", "aarch64"]: 90 | download_name = f"lua-language-server-{lua_ls_version}-darwin-arm64.tar.gz" 91 | else: 92 | raise RuntimeError(f"Unsupported macOS architecture: {machine}") 93 | elif system == "Windows": 94 | if machine in ["amd64", "x86_64"]: 95 | download_name = f"lua-language-server-{lua_ls_version}-win32-x64.zip" 96 | else: 97 | raise RuntimeError(f"Unsupported Windows architecture: {machine}") 98 | else: 99 | raise RuntimeError(f"Unsupported operating system: {system}") 100 | 101 | download_url = f"https://github.com/LuaLS/lua-language-server/releases/download/{lua_ls_version}/{download_name}" 102 | 103 | # Create installation directory 104 | install_dir = Path.home() / ".serena" / "language_servers" / "lua" 105 | install_dir.mkdir(parents=True, exist_ok=True) 106 | 107 | # Download the file 108 | print(f"Downloading lua-language-server from {download_url}...") 109 | response = requests.get(download_url, stream=True) 110 | response.raise_for_status() 111 | 112 | # Save and extract 113 | download_path = install_dir / download_name 114 | with open(download_path, "wb") as f: 115 | for chunk in response.iter_content(chunk_size=8192): 116 | f.write(chunk) 117 | 118 | print(f"Extracting lua-language-server to {install_dir}...") 119 | if download_name.endswith(".tar.gz"): 120 | with tarfile.open(download_path, "r:gz") as tar: 121 | tar.extractall(install_dir) 122 | elif download_name.endswith(".zip"): 123 | with zipfile.ZipFile(download_path, "r") as zip_ref: 124 | zip_ref.extractall(install_dir) 125 | 126 | # Clean up download file 127 | download_path.unlink() 128 | 129 | # Make executable on Unix systems 130 | if system != "Windows": 131 | lua_ls_path = install_dir / "bin" / "lua-language-server" 132 | if lua_ls_path.exists(): 133 | lua_ls_path.chmod(0o755) 134 | return str(lua_ls_path) 135 | else: 136 | lua_ls_path = install_dir / "bin" / "lua-language-server.exe" 137 | if lua_ls_path.exists(): 138 | return str(lua_ls_path) 139 | 140 | raise RuntimeError("Failed to find lua-language-server executable after extraction") 141 | 142 | @staticmethod 143 | def _setup_runtime_dependency(): 144 | """ 145 | Check if required Lua runtime dependencies are available. 146 | Downloads lua-language-server if not present. 147 | """ 148 | lua_ls_path = LuaLanguageServer._get_lua_ls_path() 149 | 150 | if not lua_ls_path: 151 | print("lua-language-server not found. Downloading...") 152 | lua_ls_path = LuaLanguageServer._download_lua_ls() 153 | print(f"lua-language-server installed at: {lua_ls_path}") 154 | 155 | return lua_ls_path 156 | 157 | def __init__( 158 | self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings 159 | ): 160 | lua_ls_path = self._setup_runtime_dependency() 161 | 162 | super().__init__( 163 | config, 164 | logger, 165 | repository_root_path, 166 | ProcessLaunchInfo(cmd=lua_ls_path, cwd=repository_root_path), 167 | "lua", 168 | solidlsp_settings, 169 | ) 170 | self.server_ready = threading.Event() 171 | self.request_id = 0 172 | 173 | @staticmethod 174 | def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: 175 | """ 176 | Returns the initialize params for the Lua Language Server. 177 | """ 178 | root_uri = pathlib.Path(repository_absolute_path).as_uri() 179 | initialize_params = { 180 | "locale": "en", 181 | "capabilities": { 182 | "textDocument": { 183 | "synchronization": {"didSave": True, "dynamicRegistration": True}, 184 | "definition": {"dynamicRegistration": True}, 185 | "references": {"dynamicRegistration": True}, 186 | "documentSymbol": { 187 | "dynamicRegistration": True, 188 | "hierarchicalDocumentSymbolSupport": True, 189 | "symbolKind": {"valueSet": list(range(1, 27))}, 190 | }, 191 | "completion": { 192 | "dynamicRegistration": True, 193 | "completionItem": { 194 | "snippetSupport": True, 195 | "commitCharactersSupport": True, 196 | "documentationFormat": ["markdown", "plaintext"], 197 | "deprecatedSupport": True, 198 | "preselectSupport": True, 199 | }, 200 | }, 201 | "hover": { 202 | "dynamicRegistration": True, 203 | "contentFormat": ["markdown", "plaintext"], 204 | }, 205 | "signatureHelp": { 206 | "dynamicRegistration": True, 207 | "signatureInformation": { 208 | "documentationFormat": ["markdown", "plaintext"], 209 | "parameterInformation": {"labelOffsetSupport": True}, 210 | }, 211 | }, 212 | }, 213 | "workspace": { 214 | "workspaceFolders": True, 215 | "didChangeConfiguration": {"dynamicRegistration": True}, 216 | "configuration": True, 217 | "symbol": { 218 | "dynamicRegistration": True, 219 | "symbolKind": {"valueSet": list(range(1, 27))}, 220 | }, 221 | }, 222 | }, 223 | "processId": os.getpid(), 224 | "rootPath": repository_absolute_path, 225 | "rootUri": root_uri, 226 | "workspaceFolders": [ 227 | { 228 | "uri": root_uri, 229 | "name": os.path.basename(repository_absolute_path), 230 | } 231 | ], 232 | "initializationOptions": { 233 | # Lua Language Server specific options 234 | "runtime": { 235 | "version": "Lua 5.4", 236 | "path": ["?.lua", "?/init.lua"], 237 | }, 238 | "diagnostics": { 239 | "enable": True, 240 | "globals": ["vim", "describe", "it", "before_each", "after_each"], # Common globals 241 | }, 242 | "workspace": { 243 | "library": [], # Can be extended with project-specific libraries 244 | "checkThirdParty": False, 245 | "userThirdParty": [], 246 | }, 247 | "telemetry": { 248 | "enable": False, 249 | }, 250 | "completion": { 251 | "enable": True, 252 | "callSnippet": "Both", 253 | "keywordSnippet": "Both", 254 | }, 255 | }, 256 | } 257 | return initialize_params 258 | 259 | def _start_server(self): 260 | """Start Lua Language Server process""" 261 | 262 | def register_capability_handler(params): 263 | return 264 | 265 | def window_log_message(msg): 266 | self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) 267 | 268 | def do_nothing(params): 269 | return 270 | 271 | self.server.on_request("client/registerCapability", register_capability_handler) 272 | self.server.on_notification("window/logMessage", window_log_message) 273 | self.server.on_notification("$/progress", do_nothing) 274 | self.server.on_notification("textDocument/publishDiagnostics", do_nothing) 275 | 276 | self.logger.log("Starting Lua Language Server process", logging.INFO) 277 | self.server.start() 278 | initialize_params = self._get_initialize_params(self.repository_root_path) 279 | 280 | self.logger.log( 281 | "Sending initialize request from LSP client to LSP server and awaiting response", 282 | logging.INFO, 283 | ) 284 | init_response = self.server.send.initialize(initialize_params) 285 | 286 | # Verify server capabilities 287 | assert "textDocumentSync" in init_response["capabilities"] 288 | assert "definitionProvider" in init_response["capabilities"] 289 | assert "documentSymbolProvider" in init_response["capabilities"] 290 | assert "referencesProvider" in init_response["capabilities"] 291 | 292 | self.server.notify.initialized({}) 293 | self.completions_available.set() 294 | 295 | # Lua Language Server is typically ready immediately after initialization 296 | self.server_ready.set() 297 | self.server_ready.wait() 298 | ``` -------------------------------------------------------------------------------- /test/solidlsp/erlang/test_erlang_ignored_dirs.py: -------------------------------------------------------------------------------- ```python 1 | from collections.abc import Generator 2 | from pathlib import Path 3 | 4 | import pytest 5 | 6 | from solidlsp import SolidLanguageServer 7 | from solidlsp.ls_config import Language 8 | from test.conftest import create_ls 9 | 10 | from . import ERLANG_LS_UNAVAILABLE, ERLANG_LS_UNAVAILABLE_REASON 11 | 12 | # These marks will be applied to all tests in this module 13 | pytestmark = [ 14 | pytest.mark.erlang, 15 | pytest.mark.skipif(ERLANG_LS_UNAVAILABLE, reason=f"Erlang LS not available: {ERLANG_LS_UNAVAILABLE_REASON}"), 16 | ] 17 | 18 | 19 | @pytest.fixture(scope="module") 20 | def ls_with_ignored_dirs() -> Generator[SolidLanguageServer, None, None]: 21 | """Fixture to set up an LS for the erlang test repo with the 'ignored_dir' directory ignored.""" 22 | ignored_paths = ["_build", "ignored_dir"] 23 | ls = create_ls(ignored_paths=ignored_paths, language=Language.ERLANG) 24 | ls.start() 25 | try: 26 | yield ls 27 | finally: 28 | try: 29 | ls.stop(shutdown_timeout=1.0) # Shorter timeout for CI 30 | except Exception as e: 31 | print(f"Warning: Error stopping language server: {e}") 32 | # Force cleanup if needed 33 | if hasattr(ls, "server") and hasattr(ls.server, "process"): 34 | try: 35 | ls.server.process.terminate() 36 | except: 37 | pass 38 | 39 | 40 | @pytest.mark.timeout(60) # Add 60 second timeout 41 | @pytest.mark.xfail(reason="Known timeout issue on Ubuntu CI with Erlang LS server startup", strict=False) 42 | @pytest.mark.parametrize("ls_with_ignored_dirs", [Language.ERLANG], indirect=True) 43 | def test_symbol_tree_ignores_dir(ls_with_ignored_dirs: SolidLanguageServer): 44 | """Tests that request_full_symbol_tree ignores the configured directory.""" 45 | root = ls_with_ignored_dirs.request_full_symbol_tree()[0] 46 | root_children = root["children"] 47 | children_names = {child["name"] for child in root_children} 48 | 49 | # Should have src, include, and test directories, but not _build or ignored_dir 50 | expected_dirs = {"src", "include", "test"} 51 | found_expected = expected_dirs.intersection(children_names) 52 | assert len(found_expected) > 0, f"Expected some dirs from {expected_dirs} to be in {children_names}" 53 | assert "_build" not in children_names, f"_build should not be in {children_names}" 54 | assert "ignored_dir" not in children_names, f"ignored_dir should not be in {children_names}" 55 | 56 | 57 | @pytest.mark.timeout(60) # Add 60 second timeout 58 | @pytest.mark.xfail(reason="Known timeout issue on Ubuntu CI with Erlang LS server startup", strict=False) 59 | @pytest.mark.parametrize("ls_with_ignored_dirs", [Language.ERLANG], indirect=True) 60 | def test_find_references_ignores_dir(ls_with_ignored_dirs: SolidLanguageServer): 61 | """Tests that find_references ignores the configured directory.""" 62 | # Location of user record, which might be referenced in ignored_dir 63 | definition_file = "include/records.hrl" 64 | 65 | # Find the user record definition 66 | symbols = ls_with_ignored_dirs.request_document_symbols(definition_file) 67 | user_symbol = None 68 | for symbol_group in symbols: 69 | user_symbol = next((s for s in symbol_group if "user" in s.get("name", "").lower()), None) 70 | if user_symbol: 71 | break 72 | 73 | if not user_symbol or "selectionRange" not in user_symbol: 74 | pytest.skip("User record symbol not found for reference testing") 75 | 76 | sel_start = user_symbol["selectionRange"]["start"] 77 | references = ls_with_ignored_dirs.request_references(definition_file, sel_start["line"], sel_start["character"]) 78 | 79 | # Assert that _build and ignored_dir do not appear in the references 80 | assert not any("_build" in ref["relativePath"] for ref in references), "_build should be ignored" 81 | assert not any("ignored_dir" in ref["relativePath"] for ref in references), "ignored_dir should be ignored" 82 | 83 | 84 | @pytest.mark.timeout(90) # Longer timeout for this complex test 85 | @pytest.mark.xfail(reason="Known timeout issue on Ubuntu CI with Erlang LS server startup", strict=False) 86 | @pytest.mark.parametrize("repo_path", [Language.ERLANG], indirect=True) 87 | def test_refs_and_symbols_with_glob_patterns(repo_path: Path) -> None: 88 | """Tests that refs and symbols with glob patterns are ignored.""" 89 | ignored_paths = ["_build*", "ignored_*", "*.tmp"] 90 | ls = create_ls(ignored_paths=ignored_paths, repo_path=str(repo_path), language=Language.ERLANG) 91 | ls.start() 92 | 93 | try: 94 | # Same as in the above tests 95 | root = ls.request_full_symbol_tree()[0] 96 | root_children = root["children"] 97 | children_names = {child["name"] for child in root_children} 98 | 99 | # Should have src, include, and test directories, but not _build or ignored_dir 100 | expected_dirs = {"src", "include", "test"} 101 | found_expected = expected_dirs.intersection(children_names) 102 | assert len(found_expected) > 0, f"Expected some dirs from {expected_dirs} to be in {children_names}" 103 | assert "_build" not in children_names, f"_build should not be in {children_names} (glob pattern)" 104 | assert "ignored_dir" not in children_names, f"ignored_dir should not be in {children_names} (glob pattern)" 105 | 106 | # Test that the refs and symbols with glob patterns are ignored 107 | definition_file = "include/records.hrl" 108 | 109 | # Find the user record definition 110 | symbols = ls.request_document_symbols(definition_file) 111 | user_symbol = None 112 | for symbol_group in symbols: 113 | user_symbol = next((s for s in symbol_group if "user" in s.get("name", "").lower()), None) 114 | if user_symbol: 115 | break 116 | 117 | if user_symbol and "selectionRange" in user_symbol: 118 | sel_start = user_symbol["selectionRange"]["start"] 119 | references = ls.request_references(definition_file, sel_start["line"], sel_start["character"]) 120 | 121 | # Assert that _build and ignored_dir do not appear in references 122 | assert not any("_build" in ref["relativePath"] for ref in references), "_build should be ignored (glob)" 123 | assert not any("ignored_dir" in ref["relativePath"] for ref in references), "ignored_dir should be ignored (glob)" 124 | finally: 125 | try: 126 | ls.stop(shutdown_timeout=1.0) # Shorter timeout for CI 127 | except Exception as e: 128 | print(f"Warning: Error stopping glob pattern test LS: {e}") 129 | # Force cleanup if needed 130 | if hasattr(ls, "server") and hasattr(ls.server, "process"): 131 | try: 132 | ls.server.process.terminate() 133 | except: 134 | pass 135 | 136 | 137 | @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) 138 | def test_default_ignored_directories(language_server: SolidLanguageServer): 139 | """Test that default Erlang directories are ignored.""" 140 | # Test that Erlang-specific directories are ignored by default 141 | assert language_server.is_ignored_dirname("_build"), "_build should be ignored" 142 | assert language_server.is_ignored_dirname("ebin"), "ebin should be ignored" 143 | assert language_server.is_ignored_dirname("deps"), "deps should be ignored" 144 | assert language_server.is_ignored_dirname(".rebar3"), ".rebar3 should be ignored" 145 | assert language_server.is_ignored_dirname("_checkouts"), "_checkouts should be ignored" 146 | assert language_server.is_ignored_dirname("node_modules"), "node_modules should be ignored" 147 | 148 | # Test that important directories are not ignored 149 | assert not language_server.is_ignored_dirname("src"), "src should not be ignored" 150 | assert not language_server.is_ignored_dirname("include"), "include should not be ignored" 151 | assert not language_server.is_ignored_dirname("test"), "test should not be ignored" 152 | assert not language_server.is_ignored_dirname("priv"), "priv should not be ignored" 153 | 154 | 155 | @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) 156 | def test_symbol_tree_excludes_build_dirs(language_server: SolidLanguageServer): 157 | """Test that symbol tree excludes build and dependency directories.""" 158 | symbol_tree = language_server.request_full_symbol_tree() 159 | 160 | if symbol_tree: 161 | root = symbol_tree[0] 162 | children_names = {child["name"] for child in root.get("children", [])} 163 | 164 | # Build and dependency directories should not appear 165 | ignored_dirs = {"_build", "ebin", "deps", ".rebar3", "_checkouts", "node_modules"} 166 | found_ignored = ignored_dirs.intersection(children_names) 167 | assert len(found_ignored) == 0, f"Found ignored directories in symbol tree: {found_ignored}" 168 | 169 | # Important directories should appear 170 | important_dirs = {"src", "include", "test"} 171 | found_important = important_dirs.intersection(children_names) 172 | assert len(found_important) > 0, f"Expected to find important directories: {important_dirs}, got: {children_names}" 173 | 174 | 175 | @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) 176 | def test_ignore_compiled_files(language_server: SolidLanguageServer): 177 | """Test that compiled Erlang files are ignored.""" 178 | # Test that beam files are ignored 179 | assert language_server.is_ignored_filename("module.beam"), "BEAM files should be ignored" 180 | assert language_server.is_ignored_filename("app.beam"), "BEAM files should be ignored" 181 | 182 | # Test that source files are not ignored 183 | assert not language_server.is_ignored_filename("module.erl"), "Erlang source files should not be ignored" 184 | assert not language_server.is_ignored_filename("records.hrl"), "Header files should not be ignored" 185 | 186 | 187 | @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) 188 | def test_rebar_directories_ignored(language_server: SolidLanguageServer): 189 | """Test that rebar-specific directories are ignored.""" 190 | # Test rebar3-specific directories 191 | assert language_server.is_ignored_dirname("_build"), "rebar3 _build should be ignored" 192 | assert language_server.is_ignored_dirname("_checkouts"), "rebar3 _checkouts should be ignored" 193 | assert language_server.is_ignored_dirname(".rebar3"), "rebar3 cache should be ignored" 194 | 195 | # Test that rebar.lock and rebar.config are not ignored (they are configuration files) 196 | assert not language_server.is_ignored_filename("rebar.config"), "rebar.config should not be ignored" 197 | assert not language_server.is_ignored_filename("rebar.lock"), "rebar.lock should not be ignored" 198 | 199 | 200 | @pytest.mark.parametrize("ls_with_ignored_dirs", [Language.ERLANG], indirect=True) 201 | def test_document_symbols_ignores_dirs(ls_with_ignored_dirs: SolidLanguageServer): 202 | """Test that document symbols from ignored directories are not included.""" 203 | # Try to get symbols from a file in ignored directory (should not find it) 204 | try: 205 | ignored_file = "ignored_dir/ignored_module.erl" 206 | symbols = ls_with_ignored_dirs.request_document_symbols(ignored_file) 207 | # If we get here, the file was found - symbols should be empty or None 208 | if symbols: 209 | assert len(symbols) == 0, "Should not find symbols in ignored directory" 210 | except Exception: 211 | # This is expected - the file should not be accessible 212 | pass 213 | 214 | 215 | @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) 216 | def test_erlang_specific_ignore_patterns(language_server: SolidLanguageServer): 217 | """Test Erlang-specific ignore patterns work correctly.""" 218 | erlang_ignored_dirs = ["_build", "ebin", ".rebar3", "_checkouts", "cover"] 219 | 220 | # These should be ignored 221 | for dirname in erlang_ignored_dirs: 222 | assert language_server.is_ignored_dirname(dirname), f"{dirname} should be ignored" 223 | 224 | # These should not be ignored 225 | erlang_important_dirs = ["src", "include", "test", "priv"] 226 | for dirname in erlang_important_dirs: 227 | assert not language_server.is_ignored_dirname(dirname), f"{dirname} should not be ignored" 228 | ``` -------------------------------------------------------------------------------- /test/solidlsp/lua/test_lua_basic.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Tests for the Lua language server implementation. 3 | 4 | These tests validate symbol finding and cross-file reference capabilities 5 | for Lua modules and functions. 6 | """ 7 | 8 | import pytest 9 | 10 | from solidlsp import SolidLanguageServer 11 | from solidlsp.ls_config import Language 12 | from solidlsp.ls_types import SymbolKind 13 | 14 | 15 | @pytest.mark.lua 16 | class TestLuaLanguageServer: 17 | """Test Lua language server symbol finding and cross-file references.""" 18 | 19 | @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) 20 | def test_find_symbols_in_calculator(self, language_server: SolidLanguageServer) -> None: 21 | """Test finding specific functions in calculator.lua.""" 22 | symbols = language_server.request_document_symbols("src/calculator.lua") 23 | 24 | assert symbols is not None 25 | assert len(symbols) > 0 26 | 27 | # Extract function names from the returned structure 28 | symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols 29 | function_names = set() 30 | for symbol in symbol_list: 31 | if isinstance(symbol, dict): 32 | name = symbol.get("name", "") 33 | # Handle both plain names and module-prefixed names 34 | if "." in name: 35 | name = name.split(".")[-1] 36 | if symbol.get("kind") == SymbolKind.Function: 37 | function_names.add(name) 38 | 39 | # Verify exact calculator functions exist 40 | expected_functions = {"add", "subtract", "multiply", "divide", "factorial"} 41 | found_functions = function_names & expected_functions 42 | assert found_functions == expected_functions, f"Expected exactly {expected_functions}, found {found_functions}" 43 | 44 | # Verify specific functions 45 | assert "add" in function_names, "add function not found" 46 | assert "multiply" in function_names, "multiply function not found" 47 | assert "factorial" in function_names, "factorial function not found" 48 | 49 | @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) 50 | def test_find_symbols_in_utils(self, language_server: SolidLanguageServer) -> None: 51 | """Test finding specific functions in utils.lua.""" 52 | symbols = language_server.request_document_symbols("src/utils.lua") 53 | 54 | assert symbols is not None 55 | assert len(symbols) > 0 56 | 57 | symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols 58 | function_names = set() 59 | all_symbols = set() 60 | 61 | for symbol in symbol_list: 62 | if isinstance(symbol, dict): 63 | name = symbol.get("name", "") 64 | all_symbols.add(name) 65 | # Handle both plain names and module-prefixed names 66 | if "." in name: 67 | name = name.split(".")[-1] 68 | if symbol.get("kind") == SymbolKind.Function: 69 | function_names.add(name) 70 | 71 | # Verify exact string utility functions 72 | expected_utils = {"trim", "split", "starts_with", "ends_with"} 73 | found_utils = function_names & expected_utils 74 | assert found_utils == expected_utils, f"Expected exactly {expected_utils}, found {found_utils}" 75 | 76 | # Verify exact table utility functions 77 | table_utils = {"deep_copy", "table_contains", "table_merge"} 78 | found_table_utils = function_names & table_utils 79 | assert found_table_utils == table_utils, f"Expected exactly {table_utils}, found {found_table_utils}" 80 | 81 | # Check for Logger class/table 82 | assert "Logger" in all_symbols or any("Logger" in s for s in all_symbols), "Logger not found in symbols" 83 | 84 | @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) 85 | def test_find_symbols_in_main(self, language_server: SolidLanguageServer) -> None: 86 | """Test finding functions in main.lua.""" 87 | symbols = language_server.request_document_symbols("main.lua") 88 | 89 | assert symbols is not None 90 | assert len(symbols) > 0 91 | 92 | symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols 93 | function_names = set() 94 | 95 | for symbol in symbol_list: 96 | if isinstance(symbol, dict) and symbol.get("kind") == SymbolKind.Function: 97 | function_names.add(symbol.get("name", "")) 98 | 99 | # Verify exact main functions exist 100 | expected_funcs = {"print_banner", "test_calculator", "test_utils"} 101 | found_funcs = function_names & expected_funcs 102 | assert found_funcs == expected_funcs, f"Expected exactly {expected_funcs}, found {found_funcs}" 103 | 104 | assert "test_calculator" in function_names, "test_calculator function not found" 105 | assert "test_utils" in function_names, "test_utils function not found" 106 | 107 | @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) 108 | def test_cross_file_references_calculator_add(self, language_server: SolidLanguageServer) -> None: 109 | """Test finding cross-file references to calculator.add function.""" 110 | symbols = language_server.request_document_symbols("src/calculator.lua") 111 | 112 | assert symbols is not None 113 | symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols 114 | 115 | # Find the add function 116 | add_symbol = None 117 | for sym in symbol_list: 118 | if isinstance(sym, dict): 119 | name = sym.get("name", "") 120 | if "add" in name or name == "add": 121 | add_symbol = sym 122 | break 123 | 124 | assert add_symbol is not None, "add function not found in calculator.lua" 125 | 126 | # Get references to the add function 127 | range_info = add_symbol.get("selectionRange", add_symbol.get("range")) 128 | assert range_info is not None, "add function has no range information" 129 | 130 | range_start = range_info["start"] 131 | refs = language_server.request_references("src/calculator.lua", range_start["line"], range_start["character"]) 132 | 133 | assert refs is not None 134 | assert isinstance(refs, list) 135 | # add function appears in: main.lua (lines 16, 71), test_calculator.lua (lines 22, 23, 24) 136 | # Note: The declaration itself may or may not be included as a reference 137 | assert len(refs) >= 5, f"Should find at least 5 references to calculator.add, found {len(refs)}" 138 | 139 | # Verify exact reference locations 140 | ref_files = {} 141 | for ref in refs: 142 | filename = ref.get("uri", "").split("/")[-1] 143 | if filename not in ref_files: 144 | ref_files[filename] = [] 145 | ref_files[filename].append(ref["range"]["start"]["line"]) 146 | 147 | # The declaration may or may not be included 148 | if "calculator.lua" in ref_files: 149 | assert ( 150 | 5 in ref_files["calculator.lua"] 151 | ), f"If declaration is included, it should be at line 6 (0-indexed: 5), found at {ref_files['calculator.lua']}" 152 | 153 | # Check main.lua has usages 154 | assert "main.lua" in ref_files, "Should find add usages in main.lua" 155 | assert ( 156 | 15 in ref_files["main.lua"] or 70 in ref_files["main.lua"] 157 | ), f"Should find add usage in main.lua, found at lines {ref_files.get('main.lua', [])}" 158 | 159 | # Check for cross-file references from main.lua 160 | main_refs = [ref for ref in refs if "main.lua" in ref.get("uri", "")] 161 | assert len(main_refs) > 0, "calculator.add should be called in main.lua" 162 | 163 | @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) 164 | def test_cross_file_references_utils_trim(self, language_server: SolidLanguageServer) -> None: 165 | """Test finding cross-file references to utils.trim function.""" 166 | symbols = language_server.request_document_symbols("src/utils.lua") 167 | 168 | assert symbols is not None 169 | symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols 170 | 171 | # Find the trim function 172 | trim_symbol = None 173 | for sym in symbol_list: 174 | if isinstance(sym, dict): 175 | name = sym.get("name", "") 176 | if "trim" in name or name == "trim": 177 | trim_symbol = sym 178 | break 179 | 180 | assert trim_symbol is not None, "trim function not found in utils.lua" 181 | 182 | # Get references to the trim function 183 | range_info = trim_symbol.get("selectionRange", trim_symbol.get("range")) 184 | assert range_info is not None, "trim function has no range information" 185 | 186 | range_start = range_info["start"] 187 | refs = language_server.request_references("src/utils.lua", range_start["line"], range_start["character"]) 188 | 189 | assert refs is not None 190 | assert isinstance(refs, list) 191 | # trim function appears in: usage (line 32 in main.lua) 192 | # Note: The declaration itself may or may not be included as a reference 193 | assert len(refs) >= 1, f"Should find at least 1 reference to utils.trim, found {len(refs)}" 194 | 195 | # Verify exact reference locations 196 | ref_files = {} 197 | for ref in refs: 198 | filename = ref.get("uri", "").split("/")[-1] 199 | if filename not in ref_files: 200 | ref_files[filename] = [] 201 | ref_files[filename].append(ref["range"]["start"]["line"]) 202 | 203 | # The declaration may or may not be included 204 | if "utils.lua" in ref_files: 205 | assert ( 206 | 5 in ref_files["utils.lua"] 207 | ), f"If declaration is included, it should be at line 6 (0-indexed: 5), found at {ref_files['utils.lua']}" 208 | 209 | # Check main.lua has usage 210 | assert "main.lua" in ref_files, "Should find trim usage in main.lua" 211 | assert ( 212 | 31 in ref_files["main.lua"] 213 | ), f"Should find trim usage at line 32 (0-indexed: 31) in main.lua, found at lines {ref_files.get('main.lua', [])}" 214 | 215 | # Check for cross-file references from main.lua 216 | main_refs = [ref for ref in refs if "main.lua" in ref.get("uri", "")] 217 | assert len(main_refs) > 0, "utils.trim should be called in main.lua" 218 | 219 | @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) 220 | def test_hover_information(self, language_server: SolidLanguageServer) -> None: 221 | """Test hover information for symbols.""" 222 | # Get hover info for a function 223 | hover_info = language_server.request_hover("src/calculator.lua", 5, 10) # Position near add function 224 | 225 | assert hover_info is not None, "Should provide hover information" 226 | 227 | # Hover info could be a dict with 'contents' or a string 228 | if isinstance(hover_info, dict): 229 | assert "contents" in hover_info or "value" in hover_info, "Hover should have contents" 230 | 231 | @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) 232 | def test_full_symbol_tree(self, language_server: SolidLanguageServer) -> None: 233 | """Test that full symbol tree is not empty.""" 234 | symbols = language_server.request_full_symbol_tree() 235 | 236 | assert symbols is not None 237 | assert len(symbols) > 0, "Symbol tree should not be empty" 238 | 239 | # The tree should have at least one root node 240 | root = symbols[0] 241 | assert isinstance(root, dict), "Root should be a dict" 242 | assert "name" in root, "Root should have a name" 243 | 244 | @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) 245 | def test_references_between_test_and_source(self, language_server: SolidLanguageServer) -> None: 246 | """Test finding references from test files to source files.""" 247 | # Check if test_calculator.lua references calculator module 248 | test_symbols = language_server.request_document_symbols("tests/test_calculator.lua") 249 | 250 | assert test_symbols is not None 251 | assert len(test_symbols) > 0 252 | 253 | # The test file should have some content that references calculator 254 | symbol_list = test_symbols[0] if isinstance(test_symbols, tuple) else test_symbols 255 | assert len(symbol_list) > 0, "test_calculator.lua should have symbols" 256 | ```