From 01dc6f87345276da8fde0d7abfc67b2ef9a257f8 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 31 Jan 2026 17:22:02 +0100 Subject: [PATCH 01/35] Add note making clear the LS-specific settings need not be adjusted by most users --- docs/02-usage/050_configuration.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index f06df078..69d36fa4 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -173,6 +173,11 @@ You can change this location by setting the `SERENA_HOME` environment variable t (ls-specific-settings)= ### Language Server-Specific Settings +:::note +**Advanced Users Only**: The settings described in this section are intended for advanced users who need to fine-tune language server behavior. +Most users will not need to adjust these settings. +::: + Under the key `ls_specific_settings` in `serena_config.yml`, you can you pass per-language, language server-specific configuration. From 71a6f1ff82d69c77b9082c68b2d2ff236dc9919c Mon Sep 17 00:00:00 2001 From: Azraf Nahian <69325302+turboslapper@users.noreply.github.com> Date: Sun, 1 Feb 2026 08:01:51 -0500 Subject: [PATCH 02/35] CCLS Support added (#975) Also added a test suite for both clangd and ccls --- .github/workflows/pytest.yml | 16 ++ CHANGELOG.md | 1 + README.md | 2 + docs/01-about/020_programming-languages.md | 3 +- pyproject.toml | 1 + .../language_servers/ccls_language_server.py | 160 ++++++++++++++++++ src/solidlsp/ls_config.py | 8 +- test/conftest.py | 30 +++- test/resources/repos/cpp/test_repo/a.cpp | 6 + test/resources/repos/cpp/test_repo/b.cpp | 5 + test/resources/repos/cpp/test_repo/b.hpp | 3 + .../repos/cpp/test_repo/compile_commands.json | 12 ++ test/serena/test_serena_agent.py | 3 + test/solidlsp/cpp/__init__.py | 0 test/solidlsp/cpp/test_cpp_basic.py | 92 ++++++++++ 15 files changed, 337 insertions(+), 5 deletions(-) create mode 100644 src/solidlsp/language_servers/ccls_language_server.py create mode 100644 test/resources/repos/cpp/test_repo/a.cpp create mode 100644 test/resources/repos/cpp/test_repo/b.cpp create mode 100644 test/resources/repos/cpp/test_repo/b.hpp create mode 100644 test/resources/repos/cpp/test_repo/compile_commands.json create mode 100644 test/solidlsp/cpp/__init__.py create mode 100644 test/solidlsp/cpp/test_cpp_basic.py diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index e292d5d7..8b962404 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -110,6 +110,22 @@ jobs: uses: DeLaGuardo/setup-clojure@13.4 with: cli: latest + - name: Install ccls (C/C++ Language Server) + shell: bash + run: | + if [[ "${{ runner.os }}" == "Linux" ]]; then + sudo apt-get update + sudo apt-get install -y ccls + elif [[ "${{ runner.os }}" == "macOS" ]]; then + brew install ccls + fi + # Windows: ccls requires building from source with MSYS2/LLVM, skipped in CI + # Verify installation + if command -v ccls &> /dev/null; then + echo "ccls installed: $(ccls --version 2>&1 | head -1)" + else + echo "ccls not available on this platform (expected on Windows)" + fi - name: Setup Java (for JVM based languages) uses: actions/setup-java@v4 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a2fe6dc..c5f065e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,7 @@ Status of the `main` branch. Changes prior to the next official version change w * **Add support for PowerShell** via PowerShell Editor Services (PSES). Requires `pwsh` (PowerShell Core) to be installed and available in PATH. Supports symbol navigation, go-to-definition, and within-file references for .ps1 files. * **Add support for MATLAB** via the official MathWorks MATLAB Language Server. Requires MATLAB R2021b or later and Node.js. Set `MATLAB_PATH` environment variable or configure `matlab_path` in `ls_specific_settings`. Supports .m, .mlx, and .mlapp files with code completion, diagnostics, go-to-definition, find references, document symbols, formatting, and rename. * **Add support for Pascal** via the official Pascal Language Server. + * **C/C++ alternate LS (ccls)**: Add experimental, opt-in support for ccls as an alternative backend to clangd. Enable via `cpp_ccls` in project configuration. Requires `ccls` installed and ideally a `compile_commands.json` at repo root. # 0.1.4 diff --git a/README.md b/README.md index b03b07c1..16c6b47b 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,8 @@ The underlying language servers are typically open-source projects (like Serena) With Serena's LSP library, we provide **support for over 30 programming languages**, including AL, Bash, C#, C/C++, Clojure, Dart, Elixir, Elm, Erlang, Fortran, Go, Groovy (partial support), Haskell, Java, Javascript, Julia, Kotlin, Lua, Markdown, MATLAB, Nix, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Swift, TOML, TypeScript, YAML, and Zig. +Note: For **C/C++**, Serena uses **clangd** by default and also supports **ccls** as an experimental, opt-in alternative (`cpp_ccls`). For best results, provide a `compile_commands.json` at the repository root. + > [!IMPORTANT] > Some language servers require additional dependencies to be installed; see the [Language Support](https://oraios.github.io/serena/01-about/020_programming-languages.html) page for details. diff --git a/docs/01-about/020_programming-languages.md b/docs/01-about/020_programming-languages.md index aa4a4834..c1880c7d 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -31,7 +31,8 @@ Some languages require additional installations or setup steps, as noted. * **Bash** * **C#** * **C/C++** - (you may experience issues with finding references, we are working on it) + Default: clangd. Optional alternate: ccls (experimental, opt-in). + For best results, provide a `compile_commands.json` at the repository root. * **Clojure** * **Dart** * **Elixir** diff --git a/pyproject.toml b/pyproject.toml index d6c4b373..be99a021 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -313,6 +313,7 @@ markers = [ "yaml: language server running for YAML", "powershell: language server running for PowerShell", "pascal: language server running for Pascal (Free Pascal/Lazarus)", + "cpp: language server running for C/C++", "slow: tests that require additional Expert instances and have long startup times (~60-90s each)", "toml: language server running for TOML", "matlab: language server running for MATLAB (requires MATLAB R2021b+)", diff --git a/src/solidlsp/language_servers/ccls_language_server.py b/src/solidlsp/language_servers/ccls_language_server.py new file mode 100644 index 00000000..a6975254 --- /dev/null +++ b/src/solidlsp/language_servers/ccls_language_server.py @@ -0,0 +1,160 @@ +""" +Provides C/C++ specific instantiation of the LanguageServer class using ccls. + +This is an alternative to clangd for large C++ codebases where ccls may perform +better for indexing and navigation. Requires ccls to be installed and available +on PATH, or configured via ls_specific_settings with key "ls_path". + +For best results, ensure a compile_commands.json exists at the repository root. + +Installation +------------ +ccls must be installed manually as there are no prebuilt binaries available for +direct download. Install using your system package manager: + +**Linux:** +- Ubuntu/Debian (22.04+): ``sudo apt-get install ccls`` +- Fedora/RHEL: ``sudo dnf install ccls`` +- Arch Linux: ``sudo pacman -S ccls`` +- openSUSE Tumbleweed: ``sudo zypper install ccls`` +- Gentoo: ``sudo emerge dev-util/ccls`` + +**macOS:** +- Homebrew: ``brew install ccls`` + +**Windows:** +- MSYS2 (MinGW): Build from source using MSYS2 toolchain +- No native prebuilt binaries available; must build from source + +For build-from-source instructions (required on Windows), see: +https://github.com/MaskRay/ccls/wiki/Build + +Official documentation: +https://github.com/MaskRay/ccls +""" + +import logging +import os +import pathlib +import threading +from typing import Any, cast + +from solidlsp.ls import ( + LanguageServerDependencyProvider, + LanguageServerDependencyProviderSinglePath, + SolidLanguageServer, +) +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + + +class CclsLanguageServer(SolidLanguageServer): + """ + C/C++ language server implementation using ccls. + + Notes: + - ccls should be installed and on PATH (or specify ls_path in settings) + - compile_commands.json at repo root is recommended for accurate indexing + + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates a CclsLanguageServer instance. This class is not meant to be instantiated directly. + Use LanguageServer.create() instead. + """ + super().__init__(config, repository_root_path, None, "cpp", solidlsp_settings) + self.server_ready = threading.Event() + + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) + + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + def _get_or_install_core_dependency(self) -> str: + """ + Resolve ccls path from system or raise helpful error if missing. + Allows override via ls_specific_settings[language].ls_path. + """ + import shutil + + ccls_path = shutil.which("ccls") + if not ccls_path: + raise FileNotFoundError( + "ccls is not installed on your system.\n" + "Please install ccls using your system package manager:\n" + " Linux (Ubuntu/Debian): sudo apt-get install ccls\n" + " Linux (Fedora/RHEL): sudo dnf install ccls\n" + " Linux (Arch): sudo pacman -S ccls\n" + " macOS (Homebrew): brew install ccls\n" + " Windows: Build from source (see wiki)\n\n" + "For build instructions and more details, see:\n" + " https://github.com/MaskRay/ccls/wiki/Build" + ) + log.info(f"Using system-installed ccls at {ccls_path}") + return ccls_path + + def _create_launch_command(self, core_path: str) -> list[str] | str: + return [core_path] + + @staticmethod + def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for the ccls Language Server. + """ + root_uri = pathlib.Path(repository_absolute_path).as_uri() + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": {"dynamicRegistration": True}, + }, + "workspace": {"workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}}, + }, + "processId": os.getpid(), + "rootPath": repository_absolute_path, + "rootUri": root_uri, + "workspaceFolders": [ + { + "uri": root_uri, + "name": "$name", + } + ], + # ccls supports initializationOptions but none are required for basic functionality + } + return cast(InitializeParams, initialize_params) + + def _start_server(self) -> None: + """ + Starts the ccls language server and initializes the LSP connection. + """ + + def do_nothing(params: Any) -> None: + pass + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + # Register minimal handlers + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting ccls server process") + self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + log.info("Sending initialize request from LSP client to ccls and awaiting response") + self.server.send.initialize(initialize_params) + # Do not assert clangd-specific capability shapes; ccls differs + self.server.notify.initialized({}) + + # Basic readiness + self.completions_available.set() + self.server_ready.set() diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 04627009..938d6ebc 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -41,6 +41,7 @@ class Language(str, Enum): RUBY = "ruby" DART = "dart" CPP = "cpp" + CPP_CCLS = "cpp_ccls" PHP = "php" R = "r" PERL = "perl" @@ -124,6 +125,7 @@ class Language(str, Enum): self.YAML, self.TOML, self.GROOVY, + self.CPP_CCLS, } def __str__(self) -> str: @@ -170,7 +172,7 @@ class Language(str, Enum): return FilenameMatcher("*.rb", "*.erb") case self.RUBY_SOLARGRAPH: return FilenameMatcher("*.rb") - case self.CPP: + case self.CPP | self.CPP_CCLS: return FilenameMatcher("*.cpp", "*.h", "*.hpp", "*.c", "*.hxx", "*.cc", "*.cxx") case self.KOTLIN: return FilenameMatcher("*.kt", "*.kts") @@ -304,6 +306,10 @@ class Language(str, Enum): from solidlsp.language_servers.clangd_language_server import ClangdLanguageServer return ClangdLanguageServer + case self.CPP_CCLS: + from solidlsp.language_servers.ccls_language_server import CclsLanguageServer + + return CclsLanguageServer case self.PHP: from solidlsp.language_servers.intelephense import Intelephense diff --git a/test/conftest.py b/test/conftest.py index e02b428c..59bdc836 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,12 +1,20 @@ import logging import os +import shutil as _sh from collections.abc import Iterator from contextlib import contextmanager from pathlib import Path from typing import Any import pytest -from sensai.util.logging import configure + +try: + from sensai.util.logging import configure +except Exception: + # Fallback if 'sensai' is not installed: basic logging configuration + def configure(level: int = logging.INFO) -> None: + logging.basicConfig(level=level) + from serena.config.serena_config import SerenaPaths from serena.constants import SERENA_MANAGED_DIR_NAME @@ -35,7 +43,9 @@ class LanguageParamRequest: def get_repo_path(language: Language) -> Path: - return Path(__file__).parent / "resources" / "repos" / language / "test_repo" + # Map both CPP variants to the shared cpp test repo + repo_dir = "cpp" if language in (Language.CPP, Language.CPP_CCLS) else str(language) + return Path(__file__).parent / "resources" / "repos" / repo_dir / "test_repo" def _create_ls( @@ -52,7 +62,11 @@ def _create_ls( gitignore_parser = GitignoreParser(str(repo_path)) for spec in gitignore_parser.get_ignore_specs(): ignored_paths.extend(spec.patterns) - config = LanguageServerConfig(code_language=language, ignored_paths=ignored_paths, trace_lsp_communication=trace_lsp_communication) + config = LanguageServerConfig( + code_language=language, + ignored_paths=ignored_paths, + trace_lsp_communication=trace_lsp_communication, + ) effective_solidlsp_dir = solidlsp_dir if solidlsp_dir is not None else SerenaPaths().serena_user_home_dir return SolidLanguageServer.create( config, @@ -212,6 +226,16 @@ def _determine_disabled_languages() -> list[Language]: if not clojure_tests_enabled: result.append(Language.CLOJURE) + # Disable CPP_CCLS tests if ccls is not available + ccls_tests_enabled = _sh.which("ccls") is not None + if not ccls_tests_enabled: + result.append(Language.CPP_CCLS) + + # Disable CPP (clangd) tests if clangd is not available + clangd_tests_enabled = _sh.which("clangd") is not None + if not clangd_tests_enabled: + result.append(Language.CPP) + al_tests_enabled = True if not al_tests_enabled: result.append(Language.AL) diff --git a/test/resources/repos/cpp/test_repo/a.cpp b/test/resources/repos/cpp/test_repo/a.cpp new file mode 100644 index 00000000..7e5aebb1 --- /dev/null +++ b/test/resources/repos/cpp/test_repo/a.cpp @@ -0,0 +1,6 @@ +#include "b.hpp" + +int main() { + int x = add(3, 4); + return x; +} diff --git a/test/resources/repos/cpp/test_repo/b.cpp b/test/resources/repos/cpp/test_repo/b.cpp new file mode 100644 index 00000000..27b93733 --- /dev/null +++ b/test/resources/repos/cpp/test_repo/b.cpp @@ -0,0 +1,5 @@ +#include "b.hpp" + +int add(int a, int b) { + return a + b; +} diff --git a/test/resources/repos/cpp/test_repo/b.hpp b/test/resources/repos/cpp/test_repo/b.hpp new file mode 100644 index 00000000..e404974d --- /dev/null +++ b/test/resources/repos/cpp/test_repo/b.hpp @@ -0,0 +1,3 @@ +#pragma once + +int add(int a, int b); diff --git a/test/resources/repos/cpp/test_repo/compile_commands.json b/test/resources/repos/cpp/test_repo/compile_commands.json new file mode 100644 index 00000000..c385fb3a --- /dev/null +++ b/test/resources/repos/cpp/test_repo/compile_commands.json @@ -0,0 +1,12 @@ +[ + { + "directory": ".", + "command": "g++ -I . -c a.cpp", + "file": "a.cpp" + }, + { + "directory": ".", + "command": "g++ -I . -c b.cpp", + "file": "b.cpp" + } +] diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index 6e7a0792..b43be9e8 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -36,6 +36,7 @@ def serena_config(): Language.CLOJURE, Language.FSHARP, Language.POWERSHELL, + Language.CPP_CCLS, ]: repo_path = get_repo_path(language) if repo_path.exists(): @@ -118,6 +119,7 @@ class TestSerenaAgent: pytest.param(Language.CSHARP, "Calculator", "Class", "Program.cs", marks=pytest.mark.csharp), pytest.param(Language.FSHARP, "Calculator", "Module", "Calculator.fs", marks=pytest.mark.fsharp), pytest.param(Language.POWERSHELL, "function Greet-User ()", "Function", "main.ps1", marks=pytest.mark.powershell), + pytest.param(Language.CPP_CCLS, "add", "Function", "b.cpp", marks=pytest.mark.cpp), ], indirect=["serena_agent"], ) @@ -186,6 +188,7 @@ class TestSerenaAgent: pytest.param(Language.CSHARP, "Calculator", "Program.cs", "Program.cs", marks=pytest.mark.csharp), pytest.param(Language.FSHARP, "add", "Calculator.fs", "Program.fs", marks=pytest.mark.fsharp), pytest.param(Language.POWERSHELL, "function Greet-User ()", "main.ps1", "main.ps1", marks=pytest.mark.powershell), + pytest.param(Language.CPP_CCLS, "add", "b.cpp", "a.cpp", marks=pytest.mark.cpp), ], indirect=["serena_agent"], ) diff --git a/test/solidlsp/cpp/__init__.py b/test/solidlsp/cpp/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/solidlsp/cpp/test_cpp_basic.py b/test/solidlsp/cpp/test_cpp_basic.py new file mode 100644 index 00000000..84d0eaa8 --- /dev/null +++ b/test/solidlsp/cpp/test_cpp_basic.py @@ -0,0 +1,92 @@ +""" +Basic tests for C/C++ language server integration (clangd and ccls). + +This module tests both Language.CPP (clangd) and Language.CPP_CCLS (ccls) +using the same test repository. Tests are skipped if the respective language +server is not available. +""" + +import os +import shutil +from typing import cast + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_utils import SymbolUtils + + +def _clangd_available() -> bool: + return shutil.which("clangd") is not None + + +def _ccls_available() -> bool: + return shutil.which("ccls") is not None + + +# Build parametrize list based on availability +_cpp_servers: list[Language] = [] +if _clangd_available(): + _cpp_servers.append(Language.CPP) +if _ccls_available(): + _cpp_servers.append(Language.CPP_CCLS) + + +@pytest.mark.cpp +@pytest.mark.skipif(not _cpp_servers, reason="No C++ language server (clangd or ccls) available") +class TestCppLanguageServer: + """Tests for C/C++ language servers (clangd and ccls).""" + + @pytest.mark.parametrize("language_server", _cpp_servers, indirect=True) + def test_find_symbol(self, language_server: SolidLanguageServer) -> None: + """Test that symbol tree contains expected functions.""" + symbols = language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "add"), "Function 'add' not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "main"), "Function 'main' not found in symbol tree" + + @pytest.mark.parametrize("language_server", _cpp_servers, indirect=True) + def test_get_document_symbols(self, language_server: SolidLanguageServer) -> None: + """Test document symbols for a.cpp.""" + file_path = os.path.join("a.cpp") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + # Flatten nested structure if needed + symbol_list = symbols[0] if symbols and isinstance(symbols[0], list) else symbols + names = [s.get("name") for s in symbol_list] + assert "main" in names, f"Expected 'main' in document symbols, got: {names}" + + @pytest.mark.parametrize("language_server", _cpp_servers, indirect=True) + def test_find_referencing_symbols_across_files(self, language_server: SolidLanguageServer) -> None: + """Test finding references to 'add' function across files.""" + # Locate 'add' in b.cpp + file_path = os.path.join("b.cpp") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + symbol_list = symbols[0] if symbols and isinstance(symbols[0], list) else symbols + add_symbol = None + for sym in symbol_list: + if sym.get("name") == "add": + add_symbol = sym + break + assert add_symbol is not None, "Could not find 'add' function symbol in b.cpp" + + sel_start = add_symbol["selectionRange"]["start"] + refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"] + 1) + ref_files = cast(list[str], [ref.get("relativePath", "") for ref in refs]) + assert any("a.cpp" in ref_file for ref_file in ref_files), "Should find reference in a.cpp" + + # Verify second call returns same results (stability check) + def _ref_key(ref: dict) -> tuple: + rp = ref.get("relativePath", "") + rng = ref.get("range") or {} + s = rng.get("start") or {} + e = rng.get("end") or {} + return ( + rp, + s.get("line", -1), + s.get("character", -1), + e.get("line", -1), + e.get("character", -1), + ) + + refs2 = language_server.request_references(file_path, sel_start["line"], sel_start["character"] + 1) + assert sorted(map(_ref_key, refs2)) == sorted(map(_ref_key, refs)), "Reference results should be stable across calls" From 1d0b3afdc9e046a65cafdbd36b15217d8b6ee6f1 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Sun, 1 Feb 2026 20:09:37 +0700 Subject: [PATCH 03/35] Removed ccls mention from readme --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 16c6b47b..b03b07c1 100644 --- a/README.md +++ b/README.md @@ -84,8 +84,6 @@ The underlying language servers are typically open-source projects (like Serena) With Serena's LSP library, we provide **support for over 30 programming languages**, including AL, Bash, C#, C/C++, Clojure, Dart, Elixir, Elm, Erlang, Fortran, Go, Groovy (partial support), Haskell, Java, Javascript, Julia, Kotlin, Lua, Markdown, MATLAB, Nix, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Swift, TOML, TypeScript, YAML, and Zig. -Note: For **C/C++**, Serena uses **clangd** by default and also supports **ccls** as an experimental, opt-in alternative (`cpp_ccls`). For best results, provide a `compile_commands.json` at the repository root. - > [!IMPORTANT] > Some language servers require additional dependencies to be installed; see the [Language Support](https://oraios.github.io/serena/01-about/020_programming-languages.html) page for details. From fb3a0f5b1731e23c5db4493718467c58dd63c868 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Sun, 1 Feb 2026 20:18:47 +0700 Subject: [PATCH 04/35] Removed try-except around sensai import [ci skip] --- test/conftest.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/test/conftest.py b/test/conftest.py index 59bdc836..d774a328 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -7,14 +7,7 @@ from pathlib import Path from typing import Any import pytest - -try: - from sensai.util.logging import configure -except Exception: - # Fallback if 'sensai' is not installed: basic logging configuration - def configure(level: int = logging.INFO) -> None: - logging.basicConfig(level=level) - +from sensai.util.logging import configure from serena.config.serena_config import SerenaPaths from serena.constants import SERENA_MANAGED_DIR_NAME From 6b87ffd7585e9f826183e6e5978bf23ace44cffd Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sun, 1 Feb 2026 13:13:22 +0100 Subject: [PATCH 05/35] Convert RustAnalyzer to use LanguageServerDependencyProviderSinglePath --- docs/02-usage/050_configuration.md | 4 +- .../language_servers/rust_analyzer.py | 230 +++++++++--------- .../rust/test_rust_analyzer_detection.py | 68 +++--- 3 files changed, 154 insertions(+), 148 deletions(-) diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index 69d36fa4..50372801 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -173,7 +173,7 @@ You can change this location by setting the `SERENA_HOME` environment variable t (ls-specific-settings)= ### Language Server-Specific Settings -:::note +:::{note} **Advanced Users Only**: The settings described in this section are intended for advanced users who need to fine-tune language server behavior. Most users will not need to adjust these settings. ::: @@ -209,7 +209,7 @@ ls_specific_settings: ``` This is supported by all language servers deriving their dependency provider from `LanguageServerDependencyProviderSinglePath`. -Currently, this includes the following languages: `clojure`, `cpp`, `php`, `python`, `typescript`. +Currently, this includes the following languages: `clojure`, `cpp`, `php`, `python`, `rust`, `typescript`. We will add support for more languages over time. #### Go (`gopls`) diff --git a/src/solidlsp/language_servers/rust_analyzer.py b/src/solidlsp/language_servers/rust_analyzer.py index f345380a..a778c017 100644 --- a/src/solidlsp/language_servers/rust_analyzer.py +++ b/src/solidlsp/language_servers/rust_analyzer.py @@ -13,10 +13,9 @@ from typing import cast from overrides import override -from solidlsp.ls import SolidLanguageServer +from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams -from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -43,136 +42,140 @@ class RustAnalyzer(SolidLanguageServer): return SolidLanguageServer._determine_log_level(line) - @staticmethod - def _get_rustup_version() -> str | None: - """Get installed rustup version or None if not found.""" - try: - result = subprocess.run(["rustup", "--version"], capture_output=True, text=True, check=False) - if result.returncode == 0: - return result.stdout.strip() - except FileNotFoundError: + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + @staticmethod + def _get_rustup_version() -> str | None: + """Get installed rustup version or None if not found.""" + try: + result = subprocess.run(["rustup", "--version"], capture_output=True, text=True, check=False) + if result.returncode == 0: + return result.stdout.strip() + except FileNotFoundError: + return None return None - return None - @staticmethod - def _get_rust_analyzer_via_rustup() -> str | None: - """Get rust-analyzer path via rustup. Returns None if not found.""" - try: - result = subprocess.run(["rustup", "which", "rust-analyzer"], capture_output=True, text=True, check=False) - if result.returncode == 0: - return result.stdout.strip() - except FileNotFoundError: - pass - return None + @staticmethod + def _get_rust_analyzer_via_rustup() -> str | None: + """Get rust-analyzer path via rustup. Returns None if not found.""" + try: + result = subprocess.run(["rustup", "which", "rust-analyzer"], capture_output=True, text=True, check=False) + if result.returncode == 0: + return result.stdout.strip() + except FileNotFoundError: + pass + return None - @staticmethod - def _ensure_rust_analyzer_installed() -> str: - """ - Ensure rust-analyzer is available. + @staticmethod + def _ensure_rust_analyzer_installed() -> str: + """ + Ensure rust-analyzer is available. - Priority order: - 1. Rustup existing installation (preferred - matches toolchain version) - 2. Rustup auto-install if rustup is available (ensures correct version) - 3. Common installation locations as fallback (only if rustup not available) - 4. System PATH last (can pick up incompatible versions) + Priority order: + 1. Rustup existing installation (preferred - matches toolchain version) + 2. Rustup auto-install if rustup is available (ensures correct version) + 3. Common installation locations as fallback (only if rustup not available) + 4. System PATH last (can pick up incompatible versions) - :return: path to rust-analyzer executable - """ - # Try rustup FIRST (preferred - avoids picking up incompatible versions from PATH) - rustup_path = RustAnalyzer._get_rust_analyzer_via_rustup() - if rustup_path: - return rustup_path + :return: path to rust-analyzer executable + """ + # Try rustup FIRST (preferred - avoids picking up incompatible versions from PATH) + rustup_path = RustAnalyzer.DependencyProvider._get_rust_analyzer_via_rustup() + if rustup_path: + return rustup_path - # If rustup is available but rust-analyzer not installed, auto-install it BEFORE - # checking common paths. This ensures we get the correct version matching the toolchain. - if RustAnalyzer._get_rustup_version(): - result = subprocess.run(["rustup", "component", "add", "rust-analyzer"], check=False, capture_output=True, text=True) - if result.returncode == 0: - # Verify installation worked - rustup_path = RustAnalyzer._get_rust_analyzer_via_rustup() - if rustup_path: - return rustup_path - # If auto-install failed, fall through to common paths as last resort + # If rustup is available but rust-analyzer not installed, auto-install it BEFORE + # checking common paths. This ensures we get the correct version matching the toolchain. + if RustAnalyzer.DependencyProvider._get_rustup_version(): + result = subprocess.run(["rustup", "component", "add", "rust-analyzer"], check=False, capture_output=True, text=True) + if result.returncode == 0: + # Verify installation worked + rustup_path = RustAnalyzer.DependencyProvider._get_rust_analyzer_via_rustup() + if rustup_path: + return rustup_path + # If auto-install failed, fall through to common paths as last resort - # Determine platform-specific binary name and paths - is_windows = platform.system() == "Windows" - binary_name = "rust-analyzer.exe" if is_windows else "rust-analyzer" + # Determine platform-specific binary name and paths + is_windows = platform.system() == "Windows" + binary_name = "rust-analyzer.exe" if is_windows else "rust-analyzer" - # Fallback to common installation locations (only used if rustup not available) - common_paths: list[str | None] = [] + # Fallback to common installation locations (only used if rustup not available) + common_paths: list[str | None] = [] - if is_windows: - # Windows-specific paths - home = pathlib.Path.home() - common_paths.extend( - [ - str(home / ".cargo" / "bin" / binary_name), # cargo install / rustup - str(home / "scoop" / "shims" / binary_name), # Scoop package manager - str(home / "scoop" / "apps" / "rust-analyzer" / "current" / binary_name), # Scoop direct - str( - pathlib.Path(os.environ.get("LOCALAPPDATA", "")) / "Programs" / "rust-analyzer" / binary_name - ), # Standalone install - ] - ) - else: - # Unix-like paths (macOS, Linux) - common_paths.extend( - [ - "/opt/homebrew/bin/rust-analyzer", # macOS Homebrew (Apple Silicon) - "/usr/local/bin/rust-analyzer", # macOS Homebrew (Intel) / Linux system - os.path.expanduser("~/.cargo/bin/rust-analyzer"), # cargo install - os.path.expanduser("~/.local/bin/rust-analyzer"), # User local bin - ] + if is_windows: + # Windows-specific paths + home = pathlib.Path.home() + common_paths.extend( + [ + str(home / ".cargo" / "bin" / binary_name), # cargo install / rustup + str(home / "scoop" / "shims" / binary_name), # Scoop package manager + str(home / "scoop" / "apps" / "rust-analyzer" / "current" / binary_name), # Scoop direct + str( + pathlib.Path(os.environ.get("LOCALAPPDATA", "")) / "Programs" / "rust-analyzer" / binary_name + ), # Standalone install + ] + ) + else: + # Unix-like paths (macOS, Linux) + common_paths.extend( + [ + "/opt/homebrew/bin/rust-analyzer", # macOS Homebrew (Apple Silicon) + "/usr/local/bin/rust-analyzer", # macOS Homebrew (Intel) / Linux system + os.path.expanduser("~/.cargo/bin/rust-analyzer"), # cargo install + os.path.expanduser("~/.local/bin/rust-analyzer"), # User local bin + ] + ) + + for path in common_paths: + if path and os.path.isfile(path) and os.access(path, os.X_OK): + return path + + # Last resort: check system PATH (can pick up incorrect aliases, hence checked last) + path_result = shutil.which("rust-analyzer") + if path_result and os.path.isfile(path_result) and os.access(path_result, os.X_OK): + return path_result + + # Provide helpful error message with all searched locations + searched = [p for p in common_paths if p] + install_instructions = [ + " - Rustup: rustup component add rust-analyzer", + " - Cargo: cargo install rust-analyzer", + ] + if is_windows: + install_instructions.extend( + [ + " - Scoop: scoop install rust-analyzer", + " - Chocolatey: choco install rust-analyzer", + " - Standalone: Download from https://github.com/rust-lang/rust-analyzer/releases", + ] + ) + else: + install_instructions.extend( + [ + " - Homebrew (macOS): brew install rust-analyzer", + " - System package manager (Linux): apt/dnf/pacman install rust-analyzer", + ] + ) + + raise RuntimeError( + "rust-analyzer is not installed or not in PATH.\n" + "Searched locations:\n" + "\n".join(f" - {p}" for p in searched) + "\n" + "Please install rust-analyzer via:\n" + "\n".join(install_instructions) ) - for path in common_paths: - if path and os.path.isfile(path) and os.access(path, os.X_OK): - return path + def _get_or_install_core_dependency(self) -> str: + return self._ensure_rust_analyzer_installed() - # Last resort: check system PATH (can pick up incorrect aliases, hence checked last) - path_result = shutil.which("rust-analyzer") - if path_result and os.path.isfile(path_result) and os.access(path_result, os.X_OK): - return path_result - - # Provide helpful error message with all searched locations - searched = [p for p in common_paths if p] - install_instructions = [ - " - Rustup: rustup component add rust-analyzer", - " - Cargo: cargo install rust-analyzer", - ] - if is_windows: - install_instructions.extend( - [ - " - Scoop: scoop install rust-analyzer", - " - Chocolatey: choco install rust-analyzer", - " - Standalone: Download from https://github.com/rust-lang/rust-analyzer/releases", - ] - ) - else: - install_instructions.extend( - [ - " - Homebrew (macOS): brew install rust-analyzer", - " - System package manager (Linux): apt/dnf/pacman install rust-analyzer", - ] - ) - - raise RuntimeError( - "rust-analyzer is not installed or not in PATH.\n" - "Searched locations:\n" + "\n".join(f" - {p}" for p in searched) + "\n" - "Please install rust-analyzer via:\n" + "\n".join(install_instructions) - ) + def _create_launch_command(self, core_path: str) -> list[str] | str: + return [core_path] def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a RustAnalyzer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ - rustanalyzer_executable_path = self._ensure_rust_analyzer_installed() - log.info(f"Using rust-analyzer at: {rustanalyzer_executable_path}") - super().__init__( config, repository_root_path, - ProcessLaunchInfo(cmd=rustanalyzer_executable_path, cwd=repository_root_path), + None, "rust", solidlsp_settings, ) @@ -181,6 +184,9 @@ class RustAnalyzer(SolidLanguageServer): self.initialize_searcher_command_available = threading.Event() self.resolve_main_method_available = threading.Event() + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) + @override def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in ["target"] diff --git a/test/solidlsp/rust/test_rust_analyzer_detection.py b/test/solidlsp/rust/test_rust_analyzer_detection.py index 31b0254a..85a978c3 100644 --- a/test/solidlsp/rust/test_rust_analyzer_detection.py +++ b/test/solidlsp/rust/test_rust_analyzer_detection.py @@ -48,7 +48,7 @@ class TestRustAnalyzerDetection: from solidlsp.language_servers.rust_analyzer import RustAnalyzer # Mock rustup to be unavailable - with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None): + with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): # Mock common locations to NOT exist with patch("os.path.isfile", return_value=False): # Mock PATH to have rust-analyzer @@ -60,7 +60,7 @@ class TestRustAnalyzerDetection: return path == "/custom/bin/rust-analyzer" with patch("os.path.isfile", side_effect=selective_isfile): - result = RustAnalyzer._ensure_rust_analyzer_installed() + result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() assert result == "/custom/bin/rust-analyzer" mock_which.assert_called_with("rust-analyzer") @@ -86,11 +86,11 @@ class TestRustAnalyzerDetection: def mock_access(path, mode): return path == "/opt/homebrew/bin/rust-analyzer" - with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None): + with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): with patch("shutil.which", return_value=None): with patch("os.path.isfile", side_effect=mock_isfile): with patch("os.access", side_effect=mock_access): - result = RustAnalyzer._ensure_rust_analyzer_installed() + result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() assert result == "/opt/homebrew/bin/rust-analyzer" @@ -115,11 +115,11 @@ class TestRustAnalyzerDetection: def mock_access(path, mode): return path == "/usr/local/bin/rust-analyzer" - with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None): + with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): with patch("shutil.which", return_value=None): with patch("os.path.isfile", side_effect=mock_isfile): with patch("os.access", side_effect=mock_access): - result = RustAnalyzer._ensure_rust_analyzer_installed() + result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() assert result == "/usr/local/bin/rust-analyzer" @@ -146,11 +146,11 @@ class TestRustAnalyzerDetection: def mock_access(path, mode): return path == cargo_path - with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None): + with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): with patch("shutil.which", return_value=None): with patch("os.path.isfile", side_effect=mock_isfile): with patch("os.access", side_effect=mock_access): - result = RustAnalyzer._ensure_rust_analyzer_installed() + result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() assert result == cargo_path @@ -169,11 +169,11 @@ class TestRustAnalyzerDetection: with patch("shutil.which", return_value=None): with patch("os.path.isfile", return_value=False): with patch.object( - RustAnalyzer, + RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value="/home/user/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin/rust-analyzer", ): - result = RustAnalyzer._ensure_rust_analyzer_installed() + result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() assert "rustup" in result or ".rustup" in result @@ -193,10 +193,10 @@ class TestRustAnalyzerDetection: with patch("shutil.which", return_value=None): with patch("os.path.isfile", return_value=False): - with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None): - with patch.object(RustAnalyzer, "_get_rustup_version", return_value=None): + with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): + with patch.object(RustAnalyzer.DependencyProvider, "_get_rustup_version", return_value=None): with pytest.raises(RuntimeError) as exc_info: - RustAnalyzer._ensure_rust_analyzer_installed() + RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() error_message = str(exc_info.value) # Error should list the locations that were searched (Unix paths) @@ -222,11 +222,11 @@ class TestRustAnalyzerDetection: rustup_path = "/home/user/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin/rust-analyzer" # Rustup has rust-analyzer, PATH also has it, common locations also exist - with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=rustup_path): + with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=rustup_path): with patch("shutil.which", return_value="/custom/path/rust-analyzer"): with patch("os.path.isfile", return_value=True): with patch("os.access", return_value=True): - result = RustAnalyzer._ensure_rust_analyzer_installed() + result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() # Should use rustup version, NOT PATH or common locations assert result == rustup_path @@ -260,11 +260,11 @@ class TestRustAnalyzerDetection: def mock_isfile_for_cargo(path): return path in ["/opt/homebrew/bin/rust-analyzer", os.path.expanduser("~/.cargo/bin/rust-analyzer")] - with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None): + with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): with patch("shutil.which", return_value=None): with patch("os.path.isfile", side_effect=mock_isfile_for_cargo): with patch("os.access", side_effect=mock_access): - result = RustAnalyzer._ensure_rust_analyzer_installed() + result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() # Should skip non-executable Homebrew and use cargo assert result == os.path.expanduser("~/.cargo/bin/rust-analyzer") @@ -292,12 +292,12 @@ class TestRustAnalyzerDetection: def mock_access(path, mode): return path == scoop_path - with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None): + with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): with patch("platform.system", return_value="Windows"): with patch("shutil.which", return_value=None): with patch("os.path.isfile", side_effect=mock_isfile): with patch("os.access", side_effect=mock_access): - result = RustAnalyzer._ensure_rust_analyzer_installed() + result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() assert result == scoop_path @@ -324,12 +324,12 @@ class TestRustAnalyzerDetection: def mock_access(path, mode): return path == cargo_path - with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None): + with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): with patch("platform.system", return_value="Windows"): with patch("shutil.which", return_value=None): with patch("os.path.isfile", side_effect=mock_isfile): with patch("os.access", side_effect=mock_access): - result = RustAnalyzer._ensure_rust_analyzer_installed() + result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() assert result == cargo_path @@ -349,10 +349,10 @@ class TestRustAnalyzerDetection: with patch("platform.system", return_value="Windows"): with patch("shutil.which", return_value=None): with patch("os.path.isfile", return_value=False): - with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None): - with patch.object(RustAnalyzer, "_get_rustup_version", return_value=None): + with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): + with patch.object(RustAnalyzer.DependencyProvider, "_get_rustup_version", return_value=None): with pytest.raises(RuntimeError) as exc_info: - RustAnalyzer._ensure_rust_analyzer_installed() + RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() error_message = str(exc_info.value) # Error should suggest Windows-specific package managers @@ -377,13 +377,13 @@ class TestRustAnalyzerDetection: with patch("shutil.which", return_value=None): with patch("os.path.isfile", return_value=False): - with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup") as mock_rustup_path: + with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup") as mock_rustup_path: # First call returns None (not installed), second returns path (after install) mock_rustup_path.side_effect = [None, "/home/user/.rustup/toolchains/stable/bin/rust-analyzer"] - with patch.object(RustAnalyzer, "_get_rustup_version", return_value="1.70.0"): + with patch.object(RustAnalyzer.DependencyProvider, "_get_rustup_version", return_value="1.70.0"): with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") - result = RustAnalyzer._ensure_rust_analyzer_installed() + result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() assert result == "/home/user/.rustup/toolchains/stable/bin/rust-analyzer" mock_run.assert_called_once() @@ -407,14 +407,14 @@ class TestRustAnalyzerDetection: with patch("shutil.which", return_value=None): with patch("os.path.isfile", return_value=False): - with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None): - with patch.object(RustAnalyzer, "_get_rustup_version", return_value="1.70.0"): + with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): + with patch.object(RustAnalyzer.DependencyProvider, "_get_rustup_version", return_value="1.70.0"): with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock( returncode=1, stdout="", stderr="error: component 'rust-analyzer' is not available" ) with pytest.raises(RuntimeError) as exc_info: - RustAnalyzer._ensure_rust_analyzer_installed() + RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() error_message = str(exc_info.value) # Error should provide helpful installation instructions @@ -439,12 +439,12 @@ class TestRustAnalyzerDetection: with patch("shutil.which", return_value=None): with patch("os.path.isfile", return_value=False): - with patch.object(RustAnalyzer, "_get_rust_analyzer_via_rustup", return_value=None): - with patch.object(RustAnalyzer, "_get_rustup_version", return_value="1.70.0"): + with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): + with patch.object(RustAnalyzer.DependencyProvider, "_get_rustup_version", return_value="1.70.0"): with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") with pytest.raises(RuntimeError) as exc_info: - RustAnalyzer._ensure_rust_analyzer_installed() + RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() error_message = str(exc_info.value) # Error should indicate rust-analyzer is not available and provide install instructions @@ -482,7 +482,7 @@ class TestRustAnalyzerDetectionIntegration: if not any(os.path.isfile(p) and os.access(p, os.X_OK) for p in common_paths): pytest.skip("rust-analyzer not installed on this system") - result = RustAnalyzer._ensure_rust_analyzer_installed() + result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() assert result is not None assert os.path.isfile(result) From 0f30a850c4e4d6187541ba522a862a4da71eac4f Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sun, 1 Feb 2026 20:04:26 +0100 Subject: [PATCH 06/35] Project: Initialise ignorespec and list of patterns asynchronously Resolves #980 --- src/serena/project.py | 75 ++++++++++++++++++++++++++++++------------- 1 file changed, 52 insertions(+), 23 deletions(-) diff --git a/src/serena/project.py b/src/serena/project.py index 06f39954..ea3674ad 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -1,10 +1,12 @@ import json import logging import os +import threading from pathlib import Path from typing import Any import pathspec +from sensai.util.logging import LogTime from sensai.util.string import ToStringMixin from serena.config.serena_config import DEFAULT_TOOL_TIMEOUT, ProjectConfig, get_serena_managed_in_project_dir @@ -69,27 +71,38 @@ class Project(ToStringMixin): with open(serena_data_gitignore_path, "w", encoding="utf-8") as f: f.write(f"/{SolidLanguageServer.CACHE_FOLDER_NAME}\n") - # gather ignored paths from the project configuration and gitignore files - ignored_patterns = list(project_config.ignored_paths) - if len(ignored_patterns) > 0: - log.info(f"Using {len(ignored_patterns)} ignored paths from the explicit project configuration.") - log.debug(f"Ignored paths: {ignored_patterns}") - if project_config.ignore_all_files_in_gitignore: - gitignore_parser = GitignoreParser(self.project_root) - for spec in gitignore_parser.get_ignore_specs(): - log.debug(f"Adding {len(spec.patterns)} patterns from {spec.file_path} to the ignored paths.") - ignored_patterns.extend(spec.patterns) - self._ignored_patterns = ignored_patterns + # prepare ignore spec asynchronously, ensuring immediate project activation. + self.__ignored_patterns: list[str] + self.__ignore_spec: pathspec.PathSpec + self._ignore_spec_available = threading.Event() + threading.Thread(name=f"gather-ignorespec[{self.project_config.project_name}]", target=self._gather_ignorespec, daemon=True).start() - # Set up the pathspec matcher for the ignored paths - # for all absolute paths in ignored_paths, convert them to relative paths - processed_patterns = [] - for pattern in set(ignored_patterns): - # Normalize separators (pathspec expects forward slashes) - pattern = pattern.replace(os.path.sep, "/") - processed_patterns.append(pattern) - log.debug(f"Processing {len(processed_patterns)} ignored paths") - self._ignore_spec = pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, processed_patterns) + def _gather_ignorespec(self) -> None: + with LogTime(f"Gathering ignore spec for project {self.project_config.project_name}", logger=log): + + # gather ignored paths from the project configuration and gitignore files + ignored_patterns = list(self.project_config.ignored_paths) + if len(ignored_patterns) > 0: + log.info(f"Using {len(ignored_patterns)} ignored paths from the explicit project configuration.") + log.debug(f"Ignored paths: {ignored_patterns}") + if self.project_config.ignore_all_files_in_gitignore: + gitignore_parser = GitignoreParser(self.project_root) + for spec in gitignore_parser.get_ignore_specs(): + log.debug(f"Adding {len(spec.patterns)} patterns from {spec.file_path} to the ignored paths.") + ignored_patterns.extend(spec.patterns) + self.__ignored_patterns = ignored_patterns + + # Set up the pathspec matcher for the ignored paths + # for all absolute paths in ignored_paths, convert them to relative paths + processed_patterns = [] + for pattern in set(ignored_patterns): + # Normalize separators (pathspec expects forward slashes) + pattern = pattern.replace(os.path.sep, "/") + processed_patterns.append(pattern) + log.debug(f"Processing {len(processed_patterns)} ignored paths") + self.__ignore_spec = pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, processed_patterns) + + self._ignore_spec_available.set() def _tostring_includes(self) -> list[str]: return [] @@ -151,12 +164,28 @@ class Project(ToStringMixin): abs_path = Path(self.project_root) / relative_path return FileUtils.read_file(str(abs_path), self.project_config.encoding) - def get_ignore_spec(self) -> pathspec.PathSpec: + @property + def _ignore_spec(self) -> pathspec.PathSpec: """ :return: the pathspec matcher for the paths that were configured to be ignored, either explicitly or implicitly through .gitignore files. """ - return self._ignore_spec + if not self._ignore_spec_available.is_set(): + log.info("Waiting for ignore spec to become available ...") + self._ignore_spec_available.wait() + log.info("Ignore spec is now available for project; proceeding") + return self.__ignore_spec + + @property + def _ignored_patterns(self) -> list[str]: + """ + :return: the list of ignored path patterns + """ + if not self._ignore_spec_available.is_set(): + log.info("Waiting for ignored patterns to become available ...") + self._ignore_spec_available.wait() + log.info("Ignore patterns are now available for project; proceeding") + return self.__ignored_patterns def _is_ignored_relative_path(self, relative_path: str | Path, ignore_non_source_files: bool = True) -> bool: """ @@ -198,7 +227,7 @@ class Project(ToStringMixin): if len(rel_path.parts) > 0 and rel_path.parts[0] == ".git": return True - return match_path(str(relative_path), self.get_ignore_spec(), root_path=self.project_root) + return match_path(str(relative_path), self._ignore_spec, root_path=self.project_root) def is_ignored_path(self, path: str | Path, ignore_non_source_files: bool = False) -> bool: """ From 9d9e83770e29b78929113fb66106465cf47b3758 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sun, 1 Feb 2026 21:02:21 +0100 Subject: [PATCH 07/35] Remove event completions_available and other superfluous events in LS implementations --- src/solidlsp/language_servers/bash_language_server.py | 2 -- src/solidlsp/language_servers/ccls_language_server.py | 1 - .../language_servers/clangd_language_server.py | 4 +++- src/solidlsp/language_servers/clojure_lsp.py | 1 - .../language_servers/csharp_language_server.py | 11 ++++------- src/solidlsp/language_servers/eclipse_jdtls.py | 1 - .../language_servers/elixir_tools/elixir_tools.py | 1 - src/solidlsp/language_servers/elm_language_server.py | 3 --- .../language_servers/erlang_language_server.py | 1 - .../language_servers/fortran_language_server.py | 4 ---- src/solidlsp/language_servers/gopls.py | 6 +----- .../language_servers/groovy_language_server.py | 1 - .../language_servers/haskell_language_server.py | 1 - src/solidlsp/language_servers/intelephense.py | 1 - src/solidlsp/language_servers/jedi_server.py | 4 +++- src/solidlsp/language_servers/julia_server.py | 1 - .../language_servers/kotlin_language_server.py | 1 - src/solidlsp/language_servers/lua_ls.py | 6 +----- src/solidlsp/language_servers/marksman.py | 4 ---- .../language_servers/matlab_language_server.py | 2 -- src/solidlsp/language_servers/nixd_ls.py | 5 ----- src/solidlsp/language_servers/omnisharp.py | 1 + src/solidlsp/language_servers/pascal_server.py | 3 --- src/solidlsp/language_servers/perl_language_server.py | 2 -- .../language_servers/powershell_language_server.py | 2 -- src/solidlsp/language_servers/pyright_server.py | 3 --- src/solidlsp/language_servers/r_language_server.py | 4 ---- src/solidlsp/language_servers/regal_server.py | 5 ----- src/solidlsp/language_servers/ruby_lsp.py | 4 ---- src/solidlsp/language_servers/rust_analyzer.py | 1 - src/solidlsp/language_servers/solargraph.py | 2 -- src/solidlsp/language_servers/sourcekit_lsp.py | 6 ------ src/solidlsp/language_servers/taplo_server.py | 4 ---- src/solidlsp/language_servers/terraform_ls.py | 5 ----- .../language_servers/typescript_language_server.py | 2 -- src/solidlsp/language_servers/vts_language_server.py | 2 -- src/solidlsp/language_servers/vue_language_server.py | 2 -- src/solidlsp/language_servers/yaml_language_server.py | 4 ---- src/solidlsp/language_servers/zls.py | 6 +----- src/solidlsp/ls.py | 2 -- 40 files changed, 14 insertions(+), 107 deletions(-) diff --git a/src/solidlsp/language_servers/bash_language_server.py b/src/solidlsp/language_servers/bash_language_server.py index 870e08b6..7b231830 100644 --- a/src/solidlsp/language_servers/bash_language_server.py +++ b/src/solidlsp/language_servers/bash_language_server.py @@ -148,7 +148,6 @@ class BashLanguageServer(SolidLanguageServer): if "Analyzing" in message_text or "analysis complete" in message_text.lower(): log.info("Bash language server analysis signals detected") self.server_ready.set() - self.completions_available.set() self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_notification("window/logMessage", window_log_message) @@ -183,7 +182,6 @@ class BashLanguageServer(SolidLanguageServer): # This is common. bash-language-server doesn't always send explicit ready signals. Log as info log.info("Timeout waiting for bash server ready signal, proceeding anyway") self.server_ready.set() - self.completions_available.set() else: log.info("Bash server initialization complete") diff --git a/src/solidlsp/language_servers/ccls_language_server.py b/src/solidlsp/language_servers/ccls_language_server.py index a6975254..366a6f7d 100644 --- a/src/solidlsp/language_servers/ccls_language_server.py +++ b/src/solidlsp/language_servers/ccls_language_server.py @@ -156,5 +156,4 @@ class CclsLanguageServer(SolidLanguageServer): self.server.notify.initialized({}) # Basic readiness - self.completions_available.set() self.server_ready.set() diff --git a/src/solidlsp/language_servers/clangd_language_server.py b/src/solidlsp/language_servers/clangd_language_server.py index 192ef199..c99a2ae2 100644 --- a/src/solidlsp/language_servers/clangd_language_server.py +++ b/src/solidlsp/language_servers/clangd_language_server.py @@ -214,7 +214,9 @@ class ClangdLanguageServer(SolidLanguageServer): self.server.notify.initialized({}) - self.completions_available.set() # set ready flag + # TODO This defeats the purpose of the event; we should wait for the server to actually be ready self.server_ready.set() + + # wait for server to be ready self.server_ready.wait() diff --git a/src/solidlsp/language_servers/clojure_lsp.py b/src/solidlsp/language_servers/clojure_lsp.py index e8d14b96..5ed597c0 100644 --- a/src/solidlsp/language_servers/clojure_lsp.py +++ b/src/solidlsp/language_servers/clojure_lsp.py @@ -214,4 +214,3 @@ class ClojureLSP(SolidLanguageServer): self.server.notify.initialized({}) # after initialize, Clojure-lsp is ready to serve self.server_ready.set() - self.completions_available.set() diff --git a/src/solidlsp/language_servers/csharp_language_server.py b/src/solidlsp/language_servers/csharp_language_server.py index d0025f16..d0ab9e36 100644 --- a/src/solidlsp/language_servers/csharp_language_server.py +++ b/src/solidlsp/language_servers/csharp_language_server.py @@ -213,8 +213,6 @@ class CSharpLanguageServer(SolidLanguageServer): """ super().__init__(config, repository_root_path, None, "csharp", solidlsp_settings) - self.initialization_complete = threading.Event() - def _create_dependency_provider(self) -> LanguageServerDependencyProvider: return self.DependencyProvider(self._custom_settings, self._ls_resources_dir, self._solidlsp_settings, self.repository_root_path) @@ -538,6 +536,8 @@ class CSharpLanguageServer(SolidLanguageServer): ) def _start_server(self) -> None: + indexing_complete = threading.Event() + def do_nothing(params: dict) -> None: return @@ -645,7 +645,7 @@ class CSharpLanguageServer(SolidLanguageServer): return def handle_workspace_indexing_complete(params: dict) -> None: - self.completions_available.set() + indexing_complete.set() # Set up notification handlers self.server.on_notification("window/logMessage", window_log_message) @@ -699,19 +699,16 @@ class CSharpLanguageServer(SolidLanguageServer): # Open solution and project files self._open_solution_and_projects() - self.initialization_complete.set() - log.info( "Microsoft.CodeAnalysis.LanguageServer initialized and ready\n" "Waiting for language server to index project files...\n" "This may take a while for large projects" ) - if self.completions_available.wait(30): # Wait up to 30 seconds for indexing + if indexing_complete.wait(30): # Wait up to 30 seconds for indexing log.info("Indexing complete") else: log.warning("Timeout waiting for indexing to complete, proceeding anyway") - self.completions_available.set() def _force_pull_diagnostics(self, init_response: dict | InitializeResult) -> None: """ diff --git a/src/solidlsp/language_servers/eclipse_jdtls.py b/src/solidlsp/language_servers/eclipse_jdtls.py index da34052b..880a5adb 100644 --- a/src/solidlsp/language_servers/eclipse_jdtls.py +++ b/src/solidlsp/language_servers/eclipse_jdtls.py @@ -739,7 +739,6 @@ class EclipseJDTLS(SolidLanguageServer): "*", " ", ] - self.completions_available.set() if registration["method"] == "workspace/executeCommand": if "java.intellicode.enable" in registration["registerOptions"]["commands"]: self._intellicode_enable_command_available.set() diff --git a/src/solidlsp/language_servers/elixir_tools/elixir_tools.py b/src/solidlsp/language_servers/elixir_tools/elixir_tools.py index de13725e..60f5157d 100644 --- a/src/solidlsp/language_servers/elixir_tools/elixir_tools.py +++ b/src/solidlsp/language_servers/elixir_tools/elixir_tools.py @@ -328,7 +328,6 @@ class ElixirTools(SolidLanguageServer): assert "textDocumentSync" in init_response["capabilities"], f"Missing textDocumentSync in {init_response['capabilities']}" self.server.notify.initialized({}) - self.completions_available.set() # Expert needs time to compile the project and build indexes on first run. # This can take 2-3+ minutes for mid-sized codebases. diff --git a/src/solidlsp/language_servers/elm_language_server.py b/src/solidlsp/language_servers/elm_language_server.py index e8b86938..e4598942 100644 --- a/src/solidlsp/language_servers/elm_language_server.py +++ b/src/solidlsp/language_servers/elm_language_server.py @@ -50,7 +50,6 @@ class ElmLanguageServer(SolidLanguageServer): "elm", solidlsp_settings, ) - self.server_ready = threading.Event() @override def is_ignored_dirname(self, dirname: str) -> bool: @@ -196,8 +195,6 @@ class ElmLanguageServer(SolidLanguageServer): else: log.warning("Timeout waiting for Elm workspace scan, proceeding anyway") - self.server_ready.set() - self.completions_available.set() log.info("Elm server ready") @override diff --git a/src/solidlsp/language_servers/erlang_language_server.py b/src/solidlsp/language_servers/erlang_language_server.py index 065f96bf..a36cb799 100644 --- a/src/solidlsp/language_servers/erlang_language_server.py +++ b/src/solidlsp/language_servers/erlang_language_server.py @@ -155,7 +155,6 @@ class ErlangLanguageServer(SolidLanguageServer): log.info(f"Erlang LS capabilities: {list(init_response['capabilities'].keys())}") self.server.notify.initialized({}) - self.completions_available.set() # Wait for Erlang LS to be ready - adjust timeout based on environment is_ci = os.getenv("CI") == "true" or os.getenv("GITHUB_ACTIONS") == "true" diff --git a/src/solidlsp/language_servers/fortran_language_server.py b/src/solidlsp/language_servers/fortran_language_server.py index 406ab182..2cd6b099 100644 --- a/src/solidlsp/language_servers/fortran_language_server.py +++ b/src/solidlsp/language_servers/fortran_language_server.py @@ -7,7 +7,6 @@ import os import pathlib import re import shutil -import threading from overrides import override @@ -190,7 +189,6 @@ class FortranLanguageServer(SolidLanguageServer): super().__init__( config, repository_root_path, ProcessLaunchInfo(cmd=fortls_cmd, cwd=repository_root_path), "fortran", solidlsp_settings ) - self.server_ready = threading.Event() @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: @@ -283,8 +281,6 @@ class FortranLanguageServer(SolidLanguageServer): log.info("Fortran LSP document symbol provider available") self.server.notify.initialized({}) - self.completions_available.set() # Fortran Language Server is ready after initialization - self.server_ready.set() log.info("Fortran Language Server initialization complete") diff --git a/src/solidlsp/language_servers/gopls.py b/src/solidlsp/language_servers/gopls.py index 419109ef..3575a00b 100644 --- a/src/solidlsp/language_servers/gopls.py +++ b/src/solidlsp/language_servers/gopls.py @@ -2,7 +2,6 @@ import logging import os import pathlib import subprocess -import threading from typing import Any, cast from overrides import override @@ -95,7 +94,6 @@ class Gopls(SolidLanguageServer): self._setup_runtime_dependency() super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd="gopls", cwd=repository_root_path), "go", solidlsp_settings) - self.server_ready = threading.Event() self.request_id = 0 def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: @@ -234,8 +232,6 @@ class Gopls(SolidLanguageServer): assert "definitionProvider" in init_response["capabilities"] self.server.notify.initialized({}) - self.completions_available.set() # gopls server is typically ready immediately after initialization - self.server_ready.set() - self.server_ready.wait() + # (no need to wait for events) diff --git a/src/solidlsp/language_servers/groovy_language_server.py b/src/solidlsp/language_servers/groovy_language_server.py index 1c302de7..6a2dca09 100644 --- a/src/solidlsp/language_servers/groovy_language_server.py +++ b/src/solidlsp/language_servers/groovy_language_server.py @@ -271,4 +271,3 @@ class GroovyLanguageServer(SolidLanguageServer): assert "workspaceSymbolProvider" in capabilities, "Server must support workspace symbols" self.server.notify.initialized({}) - self.completions_available.set() diff --git a/src/solidlsp/language_servers/haskell_language_server.py b/src/solidlsp/language_servers/haskell_language_server.py index e4e13bba..be3ed0db 100644 --- a/src/solidlsp/language_servers/haskell_language_server.py +++ b/src/solidlsp/language_servers/haskell_language_server.py @@ -374,7 +374,6 @@ class HaskellLanguageServer(SolidLanguageServer): log.info(f"HLS capabilities: {list(capabilities.keys())}") self.server.notify.initialized({}) - self.completions_available.set() # Give HLS time to index the project # HLS can be slow to index, especially on first run diff --git a/src/solidlsp/language_servers/intelephense.py b/src/solidlsp/language_servers/intelephense.py index d984bf16..e51c9da8 100644 --- a/src/solidlsp/language_servers/intelephense.py +++ b/src/solidlsp/language_servers/intelephense.py @@ -172,7 +172,6 @@ class Intelephense(SolidLanguageServer): assert "definitionProvider" in init_response["capabilities"] self.server.notify.initialized({}) - self.completions_available.set() # Intelephense server is typically ready immediately after initialization # TODO: This is probably incorrect; the server does send an initialized notification, which we could wait for! diff --git a/src/solidlsp/language_servers/jedi_server.py b/src/solidlsp/language_servers/jedi_server.py index 1352cb37..49ca91a0 100644 --- a/src/solidlsp/language_servers/jedi_server.py +++ b/src/solidlsp/language_servers/jedi_server.py @@ -5,6 +5,7 @@ Provides Python specific instantiation of the LanguageServer class. Contains var import logging import os import pathlib +import threading from typing import cast from overrides import override @@ -155,6 +156,7 @@ class JediServer(SolidLanguageServer): """ Starts the JEDI Language Server """ + completions_available = threading.Event() def execute_client_command_handler(params: dict) -> list: return [] @@ -164,7 +166,7 @@ class JediServer(SolidLanguageServer): def check_experimental_status(params: dict) -> None: if params["quiescent"] == True: - self.completions_available.set() + completions_available.set() def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") diff --git a/src/solidlsp/language_servers/julia_server.py b/src/solidlsp/language_servers/julia_server.py index f255a539..41d23c02 100644 --- a/src/solidlsp/language_servers/julia_server.py +++ b/src/solidlsp/language_servers/julia_server.py @@ -166,5 +166,4 @@ class JuliaLanguageServer(SolidLanguageServer): assert "documentSymbolProvider" in init_response["capabilities"] self.server.notify.initialized({}) - self.completions_available.set() log.info("Julia Language Server is initialized and ready.") diff --git a/src/solidlsp/language_servers/kotlin_language_server.py b/src/solidlsp/language_servers/kotlin_language_server.py index c3f637fb..2392974e 100644 --- a/src/solidlsp/language_servers/kotlin_language_server.py +++ b/src/solidlsp/language_servers/kotlin_language_server.py @@ -485,4 +485,3 @@ class KotlinLanguageServer(SolidLanguageServer): assert "semanticTokensProvider" in capabilities, "Server must support semantic tokens" self.server.notify.initialized({}) - self.completions_available.set() diff --git a/src/solidlsp/language_servers/lua_ls.py b/src/solidlsp/language_servers/lua_ls.py index 8802ee87..8f6731d4 100644 --- a/src/solidlsp/language_servers/lua_ls.py +++ b/src/solidlsp/language_servers/lua_ls.py @@ -8,7 +8,6 @@ import pathlib import platform import shutil import tarfile -import threading import zipfile from pathlib import Path @@ -161,7 +160,6 @@ class LuaLanguageServer(SolidLanguageServer): super().__init__( config, repository_root_path, ProcessLaunchInfo(cmd=lua_ls_path, cwd=repository_root_path), "lua", solidlsp_settings ) - self.server_ready = threading.Event() self.request_id = 0 @staticmethod @@ -281,8 +279,6 @@ class LuaLanguageServer(SolidLanguageServer): assert "referencesProvider" in init_response["capabilities"] self.server.notify.initialized({}) - self.completions_available.set() # Lua Language Server is typically ready immediately after initialization - self.server_ready.set() - self.server_ready.wait() + # (no need to wait for events) diff --git a/src/solidlsp/language_servers/marksman.py b/src/solidlsp/language_servers/marksman.py index d799abb2..97c840e2 100644 --- a/src/solidlsp/language_servers/marksman.py +++ b/src/solidlsp/language_servers/marksman.py @@ -6,7 +6,6 @@ Contains various configurations and settings specific to Markdown. import logging import os import pathlib -import threading from overrides import override @@ -99,7 +98,6 @@ class Marksman(SolidLanguageServer): "markdown", solidlsp_settings, ) - self.server_ready = threading.Event() @override def is_ignored_dirname(self, dirname: str) -> bool: @@ -181,5 +179,3 @@ class Marksman(SolidLanguageServer): # marksman is typically ready immediately after initialization log.info("Marksman server initialization complete") - self.server_ready.set() - self.completions_available.set() diff --git a/src/solidlsp/language_servers/matlab_language_server.py b/src/solidlsp/language_servers/matlab_language_server.py index bf23a425..bd16fc03 100644 --- a/src/solidlsp/language_servers/matlab_language_server.py +++ b/src/solidlsp/language_servers/matlab_language_server.py @@ -463,7 +463,6 @@ class MatlabLanguageServer(SolidLanguageServer): if "mvm attach success" in message_text.lower() or "adding workspace folder" in message_text.lower(): log.info("MATLAB language server ready signal detected (MVM attached)") self.server_ready.set() - self.completions_available.set() self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_notification("window/logMessage", window_log_message) @@ -508,7 +507,6 @@ class MatlabLanguageServer(SolidLanguageServer): # Fallback: assume server is ready after timeout log.info("Timeout waiting for MATLAB server ready signal, proceeding anyway") self.server_ready.set() - self.completions_available.set() else: log.info("MATLAB server initialization complete") diff --git a/src/solidlsp/language_servers/nixd_ls.py b/src/solidlsp/language_servers/nixd_ls.py index d3e3e96a..b697292c 100644 --- a/src/solidlsp/language_servers/nixd_ls.py +++ b/src/solidlsp/language_servers/nixd_ls.py @@ -11,7 +11,6 @@ import pathlib import platform import shutil import subprocess -import threading from pathlib import Path from overrides import override @@ -249,7 +248,6 @@ class NixLanguageServer(SolidLanguageServer): nixd_path = self._setup_runtime_dependency() super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd=nixd_path, cwd=repository_root_path), "nix", solidlsp_settings) - self.server_ready = threading.Event() self.request_id = 0 @staticmethod @@ -374,8 +372,5 @@ class NixLanguageServer(SolidLanguageServer): assert "referencesProvider" in init_response["capabilities"] self.server.notify.initialized({}) - self.completions_available.set() # nixd server is typically ready immediately after initialization - self.server_ready.set() - self.server_ready.wait() diff --git a/src/solidlsp/language_servers/omnisharp.py b/src/solidlsp/language_servers/omnisharp.py index 83bfae10..c1d60834 100644 --- a/src/solidlsp/language_servers/omnisharp.py +++ b/src/solidlsp/language_servers/omnisharp.py @@ -108,6 +108,7 @@ class OmniSharp(SolidLanguageServer): self.server_ready = threading.Event() self.definition_available = threading.Event() self.references_available = threading.Event() + self.completions_available = threading.Event() @override def is_ignored_dirname(self, dirname: str) -> bool: diff --git a/src/solidlsp/language_servers/pascal_server.py b/src/solidlsp/language_servers/pascal_server.py index 1d868367..d7b9c91f 100644 --- a/src/solidlsp/language_servers/pascal_server.py +++ b/src/solidlsp/language_servers/pascal_server.py @@ -141,7 +141,6 @@ class PascalLanguageServer(SolidLanguageServer): solidlsp_settings, ) self.server_ready = threading.Event() - self.completions_available_event = threading.Event() # ============== Metadata Directory Management ============== @@ -859,7 +858,6 @@ class PascalLanguageServer(SolidLanguageServer): if "initialized" in message_text.lower() or "ready" in message_text.lower(): log.info("Pascal language server ready signal detected") self.server_ready.set() - self.completions_available.set() def publish_diagnostics(params: dict) -> None: log.debug(f"Diagnostics: {params}") @@ -904,7 +902,6 @@ class PascalLanguageServer(SolidLanguageServer): # pasls may not send explicit ready signals, so we proceed after timeout log.info("Timeout waiting for Pascal server ready signal, assuming server is ready") self.server_ready.set() - self.completions_available.set() else: log.info("Pascal server initialization complete") diff --git a/src/solidlsp/language_servers/perl_language_server.py b/src/solidlsp/language_servers/perl_language_server.py index b7ab42a0..3edb9bca 100644 --- a/src/solidlsp/language_servers/perl_language_server.py +++ b/src/solidlsp/language_servers/perl_language_server.py @@ -206,8 +206,6 @@ class PerlLanguageServer(SolidLanguageServer): log.info(f"Sending workspace/didChangeConfiguration notification with config: {perl_config}") self.server.notify.workspace_did_change_configuration(perl_config) - self.completions_available.set() - # Perl::LanguageServer needs time to index files and resolve cross-file references # Without this delay, requests for definitions/references may return empty results settling_time = 0.5 diff --git a/src/solidlsp/language_servers/powershell_language_server.py b/src/solidlsp/language_servers/powershell_language_server.py index 129ab383..ee095ff3 100644 --- a/src/solidlsp/language_servers/powershell_language_server.py +++ b/src/solidlsp/language_servers/powershell_language_server.py @@ -287,7 +287,6 @@ class PowerShellLanguageServer(SolidLanguageServer): if "started" in message_text.lower() or "ready" in message_text.lower(): log.info("PowerShell Editor Services ready signal detected") self.server_ready.set() - self.completions_available.set() def do_nothing(params: dict) -> None: return @@ -320,6 +319,5 @@ class PowerShellLanguageServer(SolidLanguageServer): # Fallback: assume server is ready after timeout log.info("Timeout waiting for PSES ready signal, proceeding anyway") self.server_ready.set() - self.completions_available.set() else: log.info("PowerShell Editor Services initialization complete") diff --git a/src/solidlsp/language_servers/pyright_server.py b/src/solidlsp/language_servers/pyright_server.py index 515616a0..efeefdaa 100644 --- a/src/solidlsp/language_servers/pyright_server.py +++ b/src/solidlsp/language_servers/pyright_server.py @@ -154,7 +154,6 @@ class PyrightServer(SolidLanguageServer): log.info("Pyright workspace scanning complete") self.found_source_files = True self.analysis_complete.set() - self.completions_available.set() def check_experimental_status(params: dict) -> None: """ @@ -164,7 +163,6 @@ class PyrightServer(SolidLanguageServer): log.info("Received experimental/serverStatus with quiescent=true") if not self.found_source_files: self.analysis_complete.set() - self.completions_available.set() # Set up notification handlers self.server.on_request("client/registerCapability", do_nothing) @@ -203,4 +201,3 @@ class PyrightServer(SolidLanguageServer): log.warning("Timeout waiting for Pyright analysis completion, proceeding anyway") # Fallback: assume analysis is complete after timeout self.analysis_complete.set() - self.completions_available.set() diff --git a/src/solidlsp/language_servers/r_language_server.py b/src/solidlsp/language_servers/r_language_server.py index 9802381d..087eccca 100644 --- a/src/solidlsp/language_servers/r_language_server.py +++ b/src/solidlsp/language_servers/r_language_server.py @@ -2,7 +2,6 @@ import logging import os import pathlib import subprocess -import threading from typing import Any from overrides import override @@ -68,7 +67,6 @@ class RLanguageServer(SolidLanguageServer): r_cmd = 'R --vanilla --quiet --slave -e "options(languageserver.debug_mode = FALSE); languageserver::run()"' super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd=r_cmd, cwd=repository_root_path), "r", solidlsp_settings) - self.server_ready = threading.Event() @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: @@ -158,7 +156,5 @@ class RLanguageServer(SolidLanguageServer): log.info("R LSP definition provider available") self.server.notify.initialized({}) - self.completions_available.set() # R Language Server is ready after initialization - self.server_ready.set() diff --git a/src/solidlsp/language_servers/regal_server.py b/src/solidlsp/language_servers/regal_server.py index 35c7bee1..72eb19db 100644 --- a/src/solidlsp/language_servers/regal_server.py +++ b/src/solidlsp/language_servers/regal_server.py @@ -3,7 +3,6 @@ import logging import os import shutil -import threading from overrides import override @@ -53,7 +52,6 @@ class RegalLanguageServer(SolidLanguageServer): "rego", solidlsp_settings, ) - self.server_ready = threading.Event() @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: @@ -129,8 +127,5 @@ class RegalLanguageServer(SolidLanguageServer): assert "textDocumentSync" in init_response["capabilities"] self.server.notify.initialized({}) - self.completions_available.set() # Regal server is ready immediately after initialization - self.server_ready.set() - self.server_ready.wait() diff --git a/src/solidlsp/language_servers/ruby_lsp.py b/src/solidlsp/language_servers/ruby_lsp.py index 744213fe..ee39d250 100644 --- a/src/solidlsp/language_servers/ruby_lsp.py +++ b/src/solidlsp/language_servers/ruby_lsp.py @@ -350,7 +350,6 @@ class RubyLsp(SolidLanguageServer): if params.get("type") == "ready": log.info("ruby-lsp service is ready.") self.analysis_complete.set() - self.completions_available.set() def execute_client_command_handler(params: dict) -> list: return [] @@ -370,7 +369,6 @@ class RubyLsp(SolidLanguageServer): if value.get("kind") == "end": log.info("ruby-lsp indexing complete ($/progress end)") self.analysis_complete.set() - self.completions_available.set() elif value.get("kind") == "begin": log.info("ruby-lsp indexing started ($/progress begin)") elif "percentage" in value: @@ -384,7 +382,6 @@ class RubyLsp(SolidLanguageServer): if value.get("kind") == "end" or value.get("percentage") == 100: log.info("ruby-lsp indexing complete (token progress)") self.analysis_complete.set() - self.completions_available.set() def window_work_done_progress_create(params: dict) -> dict: """Handle workDoneProgress/create requests from ruby-lsp""" @@ -429,7 +426,6 @@ class RubyLsp(SolidLanguageServer): log.warning("Timeout waiting for ruby-lsp indexing completion, proceeding anyway") # Fallback: assume indexing is complete after timeout self.analysis_complete.set() - self.completions_available.set() def _handle_initialization_response(self, init_response: InitializeResult) -> None: """ diff --git a/src/solidlsp/language_servers/rust_analyzer.py b/src/solidlsp/language_servers/rust_analyzer.py index a778c017..259acb85 100644 --- a/src/solidlsp/language_servers/rust_analyzer.py +++ b/src/solidlsp/language_servers/rust_analyzer.py @@ -721,6 +721,5 @@ class RustAnalyzer(SolidLanguageServer): "completionItem": {"labelDetailsSupport": True}, } self.server.notify.initialized({}) - self.completions_available.set() self.server_ready.wait() diff --git a/src/solidlsp/language_servers/solargraph.py b/src/solidlsp/language_servers/solargraph.py index 4915f730..62a77749 100644 --- a/src/solidlsp/language_servers/solargraph.py +++ b/src/solidlsp/language_servers/solargraph.py @@ -320,7 +320,6 @@ class Solargraph(SolidLanguageServer): if params.get("type") == "ServiceReady" and params.get("message") == "Service is ready.": log.info("Solargraph service is ready.") self.analysis_complete.set() - self.completions_available.set() def execute_client_command_handler(params: dict) -> list: return [] @@ -364,4 +363,3 @@ class Solargraph(SolidLanguageServer): log.warning("Timeout waiting for Solargraph analysis completion, proceeding anyway") # Fallback: assume analysis is complete after timeout self.analysis_complete.set() - self.completions_available.set() diff --git a/src/solidlsp/language_servers/sourcekit_lsp.py b/src/solidlsp/language_servers/sourcekit_lsp.py index ad004d5d..ba55e35b 100644 --- a/src/solidlsp/language_servers/sourcekit_lsp.py +++ b/src/solidlsp/language_servers/sourcekit_lsp.py @@ -2,7 +2,6 @@ import logging import os import pathlib import subprocess -import threading import time from overrides import override @@ -53,7 +52,6 @@ class SourceKitLSP(SolidLanguageServer): super().__init__( config, repository_root_path, ProcessLaunchInfo(cmd="sourcekit-lsp", cwd=repository_root_path), "swift", solidlsp_settings ) - self.server_ready = threading.Event() self.request_id = 0 self._did_sleep_before_requesting_references = False self._initialization_timestamp: float | None = None @@ -332,10 +330,6 @@ class SourceKitLSP(SolidLanguageServer): assert "definitionProvider" in capabilities, "definitionProvider capability missing" self.server.notify.initialized({}) - self.completions_available.set() - - self.server_ready.set() - self.server_ready.wait() # Mark initialization timestamp for smarter delay calculation self._initialization_timestamp = time.time() diff --git a/src/solidlsp/language_servers/taplo_server.py b/src/solidlsp/language_servers/taplo_server.py index dcc418ec..f44e2d8f 100644 --- a/src/solidlsp/language_servers/taplo_server.py +++ b/src/solidlsp/language_servers/taplo_server.py @@ -11,7 +11,6 @@ import platform import shutil import socket import stat -import threading import urllib.request from typing import Any @@ -128,7 +127,6 @@ class TaploServer(SolidLanguageServer): "toml", solidlsp_settings, ) - self.server_ready = threading.Event() @classmethod def _setup_runtime_dependencies(cls, solidlsp_settings: SolidLSPSettings) -> str: @@ -308,8 +306,6 @@ class TaploServer(SolidLanguageServer): self.server.notify.initialized({}) log.info("Taplo server initialization complete") - self.server_ready.set() - self.completions_available.set() def is_ignored_dirname(self, dirname: str) -> bool: """Define TOML-specific directories to ignore.""" diff --git a/src/solidlsp/language_servers/terraform_ls.py b/src/solidlsp/language_servers/terraform_ls.py index 2b54c0fa..333c1c70 100644 --- a/src/solidlsp/language_servers/terraform_ls.py +++ b/src/solidlsp/language_servers/terraform_ls.py @@ -1,7 +1,6 @@ import logging import os import shutil -import threading from typing import cast from overrides import override @@ -166,7 +165,6 @@ class TerraformLS(SolidLanguageServer): "terraform", solidlsp_settings, ) - self.server_ready = threading.Event() self.request_id = 0 @staticmethod @@ -232,8 +230,5 @@ class TerraformLS(SolidLanguageServer): assert "definitionProvider" in init_response["capabilities"] self.server.notify.initialized({}) - self.completions_available.set() # terraform-ls server is typically ready immediately after initialization - self.server_ready.set() - self.server_ready.wait() diff --git a/src/solidlsp/language_servers/typescript_language_server.py b/src/solidlsp/language_servers/typescript_language_server.py index 779ed4e5..61812520 100644 --- a/src/solidlsp/language_servers/typescript_language_server.py +++ b/src/solidlsp/language_servers/typescript_language_server.py @@ -269,7 +269,6 @@ class TypeScriptLanguageServer(SolidLanguageServer): """ if params.get("quiescent") == True: self.server_ready.set() - self.completions_available.set() self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_notification("window/logMessage", window_log_message) @@ -302,7 +301,6 @@ class TypeScriptLanguageServer(SolidLanguageServer): log.info("Timeout waiting for TypeScript server to become ready, proceeding anyway") # Fallback: assume server is ready after timeout self.server_ready.set() - self.completions_available.set() @override def _get_wait_time_for_cross_file_referencing(self) -> float: diff --git a/src/solidlsp/language_servers/vts_language_server.py b/src/solidlsp/language_servers/vts_language_server.py index 21320374..e60eda1a 100644 --- a/src/solidlsp/language_servers/vts_language_server.py +++ b/src/solidlsp/language_servers/vts_language_server.py @@ -186,7 +186,6 @@ class VtsLanguageServer(SolidLanguageServer): """ if params.get("quiescent") is True: self.server_ready.set() - self.completions_available.set() self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_notification("window/logMessage", window_log_message) @@ -222,7 +221,6 @@ class VtsLanguageServer(SolidLanguageServer): log.info("Timeout waiting for VTS server to become ready, proceeding anyway") # Fallback: assume server is ready after timeout self.server_ready.set() - self.completions_available.set() @override def _get_wait_time_for_cross_file_referencing(self) -> float: diff --git a/src/solidlsp/language_servers/vue_language_server.py b/src/solidlsp/language_servers/vue_language_server.py index 42aad243..61c3ec34 100644 --- a/src/solidlsp/language_servers/vue_language_server.py +++ b/src/solidlsp/language_servers/vue_language_server.py @@ -633,7 +633,6 @@ class VueLanguageServer(SolidLanguageServer): if "initialized" in message_text.lower() or "ready" in message_text.lower(): log.info("Vue language server ready signal detected") self.server_ready.set() - self.completions_available.set() def tsserver_request_notification_handler(params: list) -> None: try: @@ -683,7 +682,6 @@ class VueLanguageServer(SolidLanguageServer): if not self.server_ready.wait(timeout=self.VUE_SERVER_READY_TIMEOUT): log.info("Timeout waiting for Vue server ready signal, proceeding anyway") self.server_ready.set() - self.completions_available.set() else: log.info("Vue server initialization complete") diff --git a/src/solidlsp/language_servers/yaml_language_server.py b/src/solidlsp/language_servers/yaml_language_server.py index 6ec52501..bb813a2a 100644 --- a/src/solidlsp/language_servers/yaml_language_server.py +++ b/src/solidlsp/language_servers/yaml_language_server.py @@ -7,7 +7,6 @@ import logging import os import pathlib import shutil -import threading from typing import Any from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection @@ -55,7 +54,6 @@ class YamlLanguageServer(SolidLanguageServer): "yaml", solidlsp_settings, ) - self.server_ready = threading.Event() @classmethod def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> str: @@ -184,5 +182,3 @@ class YamlLanguageServer(SolidLanguageServer): # YAML language server is ready immediately after initialization log.info("YAML server initialization complete") - self.server_ready.set() - self.completions_available.set() diff --git a/src/solidlsp/language_servers/zls.py b/src/solidlsp/language_servers/zls.py index f8019a4e..8f8a86e8 100644 --- a/src/solidlsp/language_servers/zls.py +++ b/src/solidlsp/language_servers/zls.py @@ -8,7 +8,6 @@ import pathlib import platform import shutil import subprocess -import threading from overrides import override @@ -100,7 +99,6 @@ class ZigLanguageServer(SolidLanguageServer): self._setup_runtime_dependency() super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd="zls", cwd=repository_root_path), "zig", solidlsp_settings) - self.server_ready = threading.Event() self.request_id = 0 @staticmethod @@ -213,11 +211,9 @@ class ZigLanguageServer(SolidLanguageServer): assert "referencesProvider" in init_response["capabilities"] self.server.notify.initialized({}) - self.completions_available.set() # ZLS server is ready after initialization - self.server_ready.set() - self.server_ready.wait() + # (no need to wait for an event) # Open build.zig if it exists to help ZLS understand project structure build_zig_path = os.path.join(self.repository_root_path, "build.zig") diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 116f31fc..91abc660 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -409,7 +409,6 @@ class SolidLanguageServer(ABC): self._load_document_symbols_cache() self.server_started = False - self.completions_available = threading.Event() if config.trace_lsp_communication: def logging_fn(source: str, target: str, msg: StringDict | str) -> None: @@ -1005,7 +1004,6 @@ class SolidLanguageServer(ABC): num_retries = 0 while response is None or (response["isIncomplete"] and num_retries < 30): # type: ignore - self.completions_available.wait() response = self.server.send.completion(completion_params) if isinstance(response, list): response = {"items": response, "isIncomplete": False} From b238e1d0ce54c7589cab2d40ceabc0abbeb7ba38 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 2 Feb 2026 10:21:43 +0000 Subject: [PATCH 08/35] Switch C# language server from Azure NuGet feed to official NuGet.org Replace Microsoft.CodeAnalysis.LanguageServer packages from Azure DevOps feed with official roslyn-language-server packages from NuGet.org. Changes: - Update package names to roslyn-language-server.* (from Microsoft.CodeAnalysis.LanguageServer.*) - Update version from 5.0.0-1.25329.6 to 5.5.0-2.26078.4 - Add direct URLs pointing to NuGet.org for each platform variant - Update extract paths from content/LanguageServer/ to tools/net10.0/ - Simplify download logic by removing Azure feed service index lookup - Replace _download_nuget_package_direct with simpler _download_nuget_package - Add tests to verify NuGet.org download behavior Benefits: - More reliable package source (official NuGet.org vs Azure DevOps feed) - Newer version with latest Roslyn improvements - Simpler codebase (removed ~60 lines of Azure-specific logic) - Better error messages with explicit URLs --- .../csharp_language_server.py | 110 +++++++--------- .../csharp/test_csharp_nuget_download.py | 118 ++++++++++++++++++ 2 files changed, 166 insertions(+), 62 deletions(-) create mode 100644 test/solidlsp/csharp/test_csharp_nuget_download.py diff --git a/src/solidlsp/language_servers/csharp_language_server.py b/src/solidlsp/language_servers/csharp_language_server.py index d0ab9e36..2a86a492 100644 --- a/src/solidlsp/language_servers/csharp_language_server.py +++ b/src/solidlsp/language_servers/csharp_language_server.py @@ -1,8 +1,7 @@ """ -CSharp Language Server using Microsoft.CodeAnalysis.LanguageServer (Official Roslyn-based LSP server) +CSharp Language Server using Roslyn Language Server (Official Roslyn-based LSP server from NuGet.org) """ -import json import logging import os import platform @@ -33,63 +32,69 @@ log = logging.getLogger(__name__) _RUNTIME_DEPENDENCIES = [ RuntimeDependency( id="CSharpLanguageServer", - description="Microsoft.CodeAnalysis.LanguageServer for Windows (x64)", - package_name="Microsoft.CodeAnalysis.LanguageServer.win-x64", - package_version="5.0.0-1.25329.6", + description="Roslyn Language Server for Windows (x64)", + package_name="roslyn-language-server.win-x64", + package_version="5.5.0-2.26078.4", + url="https://www.nuget.org/api/v2/package/roslyn-language-server.win-x64/5.5.0-2.26078.4", platform_id="win-x64", archive_type="nupkg", binary_name="Microsoft.CodeAnalysis.LanguageServer.dll", - extract_path="content/LanguageServer/win-x64", + extract_path="tools/net10.0/win-x64", ), RuntimeDependency( id="CSharpLanguageServer", - description="Microsoft.CodeAnalysis.LanguageServer for Windows (ARM64)", - package_name="Microsoft.CodeAnalysis.LanguageServer.win-arm64", - package_version="5.0.0-1.25329.6", + description="Roslyn Language Server for Windows (ARM64)", + package_name="roslyn-language-server.win-arm64", + package_version="5.5.0-2.26078.4", + url="https://www.nuget.org/api/v2/package/roslyn-language-server.win-arm64/5.5.0-2.26078.4", platform_id="win-arm64", archive_type="nupkg", binary_name="Microsoft.CodeAnalysis.LanguageServer.dll", - extract_path="content/LanguageServer/win-arm64", + extract_path="tools/net10.0/win-arm64", ), RuntimeDependency( id="CSharpLanguageServer", - description="Microsoft.CodeAnalysis.LanguageServer for macOS (x64)", - package_name="Microsoft.CodeAnalysis.LanguageServer.osx-x64", - package_version="5.0.0-1.25329.6", + description="Roslyn Language Server for macOS (x64)", + package_name="roslyn-language-server.osx-x64", + package_version="5.5.0-2.26078.4", + url="https://www.nuget.org/api/v2/package/roslyn-language-server.osx-x64/5.5.0-2.26078.4", platform_id="osx-x64", archive_type="nupkg", binary_name="Microsoft.CodeAnalysis.LanguageServer.dll", - extract_path="content/LanguageServer/osx-x64", + extract_path="tools/net10.0/osx-x64", ), RuntimeDependency( id="CSharpLanguageServer", - description="Microsoft.CodeAnalysis.LanguageServer for macOS (ARM64)", - package_name="Microsoft.CodeAnalysis.LanguageServer.osx-arm64", - package_version="5.0.0-1.25329.6", + description="Roslyn Language Server for macOS (ARM64)", + package_name="roslyn-language-server.osx-arm64", + package_version="5.5.0-2.26078.4", + url="https://www.nuget.org/api/v2/package/roslyn-language-server.osx-arm64/5.5.0-2.26078.4", platform_id="osx-arm64", archive_type="nupkg", binary_name="Microsoft.CodeAnalysis.LanguageServer.dll", - extract_path="content/LanguageServer/osx-arm64", + extract_path="tools/net10.0/osx-arm64", ), RuntimeDependency( id="CSharpLanguageServer", - description="Microsoft.CodeAnalysis.LanguageServer for Linux (x64)", - package_name="Microsoft.CodeAnalysis.LanguageServer.linux-x64", - package_version="5.0.0-1.25329.6", + description="Roslyn Language Server for Linux (x64)", + package_name="roslyn-language-server.linux-x64", + package_version="5.5.0-2.26078.4", + url="https://www.nuget.org/api/v2/package/roslyn-language-server.linux-x64/5.5.0-2.26078.4", platform_id="linux-x64", archive_type="nupkg", binary_name="Microsoft.CodeAnalysis.LanguageServer.dll", - extract_path="content/LanguageServer/linux-x64", + extract_path="tools/net10.0/linux-x64", ), RuntimeDependency( id="CSharpLanguageServer", - description="Microsoft.CodeAnalysis.LanguageServer for Linux (ARM64)", - package_name="Microsoft.CodeAnalysis.LanguageServer.linux-arm64", - package_version="5.0.0-1.25329.6", + description="Roslyn Language Server for Linux (ARM64)", + package_name="roslyn-language-server.linux-arm64", + package_version="5.5.0-2.26078.4", + url="https://www.nuget.org/api/v2/package/roslyn-language-server.linux-arm64/5.5.0-2.26078.4", platform_id="linux-arm64", archive_type="nupkg", binary_name="Microsoft.CodeAnalysis.LanguageServer.dll", - extract_path="content/LanguageServer/linux-arm64", + extract_path="tools/net10.0/linux-arm64", ), RuntimeDependency( id="DotNetRuntime", @@ -309,26 +314,24 @@ class CSharpLanguageServer(SolidLanguageServer): server_dll = server_dir / lang_server_dep.binary_name if server_dll.exists(): - log.info(f"Using cached Microsoft.CodeAnalysis.LanguageServer from {server_dll}") + log.info(f"Using cached Roslyn Language Server from {server_dll}") return str(server_dll) # Download and install the language server - log.info(f"Downloading {package_name} version {package_version}...") - assert package_version is not None - assert package_name is not None - package_path = self._download_nuget_package_direct(package_name, package_version) + log.info(f"Downloading {package_name} version {package_version} from NuGet.org...") + package_path = self._download_nuget_package(lang_server_dep) # Extract and install self._extract_language_server(lang_server_dep, package_path, server_dir) if not server_dll.exists(): - raise SolidLSPException("Microsoft.CodeAnalysis.LanguageServer.dll not found after extraction") + raise SolidLSPException("Roslyn Language Server DLL not found after extraction") # Make executable on Unix systems if platform.system().lower() != "windows": server_dll.chmod(0o755) - log.info(f"Successfully installed Microsoft.CodeAnalysis.LanguageServer to {server_dll}") + log.info(f"Successfully installed Roslyn Language Server to {server_dll}") return str(server_dll) @staticmethod @@ -354,43 +357,28 @@ class CSharpLanguageServer(SolidLanguageServer): server_dir.mkdir(parents=True, exist_ok=True) shutil.copytree(source_dir, server_dir, dirs_exist_ok=True) - def _download_nuget_package_direct(self, package_name: str, package_version: str) -> Path: + def _download_nuget_package(self, dependency: RuntimeDependency) -> Path: """ - Download a NuGet package directly from the Azure NuGet feed. + Download a NuGet package from NuGet.org and extract it. Returns the path to the extracted package directory. """ - azure_feed_url = "https://pkgs.dev.azure.com/azure-public/vside/_packaging/vs-impl/nuget/v3/index.json" + package_name = dependency.package_name + package_version = dependency.package_version + url = dependency.url + + if url is None: + raise SolidLSPException(f"No URL specified for package {package_name} version {package_version}") # Create temporary directory for package download temp_dir = Path(self._ls_resources_dir) / "temp_downloads" temp_dir.mkdir(parents=True, exist_ok=True) try: - # First, get the service index from the Azure feed - log.debug("Fetching NuGet service index from Azure feed...") - with urllib.request.urlopen(azure_feed_url) as response: - service_index = json.loads(response.read().decode()) - - # Find the package base address (for downloading packages) - package_base_address = None - for resource in service_index.get("resources", []): - if resource.get("@type") == "PackageBaseAddress/3.0.0": - package_base_address = resource.get("@id") - break - - if not package_base_address: - raise SolidLSPException("Could not find package base address in Azure NuGet feed") - - # Construct the download URL for the specific package - package_id_lower = package_name.lower() - package_version_lower = package_version.lower() - package_url = f"{package_base_address.rstrip('/')}/{package_id_lower}/{package_version_lower}/{package_id_lower}.{package_version_lower}.nupkg" - - log.debug(f"Downloading package from: {package_url}") + log.debug(f"Downloading package from: {url}") # Download the .nupkg file nupkg_file = temp_dir / f"{package_name}.{package_version}.nupkg" - urllib.request.urlretrieve(package_url, nupkg_file) + urllib.request.urlretrieve(url, nupkg_file) # Extract the .nupkg file (it's just a zip file) package_extract_dir = temp_dir / f"{package_name}.{package_version}" @@ -403,13 +391,11 @@ class CSharpLanguageServer(SolidLanguageServer): # Clean up the nupkg file nupkg_file.unlink() - log.info(f"Successfully downloaded and extracted {package_name} version {package_version}") + log.info(f"Successfully downloaded and extracted {package_name} version {package_version} from NuGet.org") return package_extract_dir except Exception as e: - raise SolidLSPException( - f"Failed to download package {package_name} version {package_version} from Azure NuGet feed: {e}" - ) from e + raise SolidLSPException(f"Failed to download package {package_name} version {package_version} from NuGet.org: {e}") from e def _ensure_dotnet_runtime_from_config(self, dotnet_runtime_dep: RuntimeDependency) -> str: """ diff --git a/test/solidlsp/csharp/test_csharp_nuget_download.py b/test/solidlsp/csharp/test_csharp_nuget_download.py new file mode 100644 index 00000000..4f9a007c --- /dev/null +++ b/test/solidlsp/csharp/test_csharp_nuget_download.py @@ -0,0 +1,118 @@ +"""Tests for C# language server NuGet package download from NuGet.org.""" + +import tempfile +from unittest.mock import patch + +import pytest + +from solidlsp.language_servers.common import RuntimeDependency +from solidlsp.language_servers.csharp_language_server import CSharpLanguageServer +from solidlsp.settings import SolidLSPSettings + + +@pytest.mark.csharp +class TestNuGetOrgDownload: + """Test downloading Roslyn language server packages from NuGet.org.""" + + def test_download_nuget_package_uses_direct_url(self): + """Test that _download_nuget_package uses the URL from RuntimeDependency directly.""" + with tempfile.TemporaryDirectory() as temp_dir: + # Create a RuntimeDependency with a NuGet.org URL + test_dependency = RuntimeDependency( + id="TestPackage", + description="Test package from NuGet.org", + package_name="roslyn-language-server.linux-x64", + package_version="5.5.0-2.26078.4", + url="https://www.nuget.org/api/v2/package/roslyn-language-server.linux-x64/5.5.0-2.26078.4", + platform_id="linux-x64", + archive_type="nupkg", + binary_name="Microsoft.CodeAnalysis.LanguageServer.dll", + extract_path="content/LanguageServer/linux-x64", + ) + + # Mock the dependency provider + mock_settings = SolidLSPSettings() + custom_settings = SolidLSPSettings.CustomLSSettings({}) + + dependency_provider = CSharpLanguageServer.DependencyProvider( + custom_settings=custom_settings, + ls_resources_dir=temp_dir, + solidlsp_settings=mock_settings, + repository_root_path="/fake/repo", + ) + + # Mock urllib.request.urlretrieve to capture the URL being used + with patch("solidlsp.language_servers.csharp_language_server.urllib.request.urlretrieve") as mock_retrieve: + with patch("solidlsp.language_servers.csharp_language_server.SafeZipExtractor"): + try: + dependency_provider._download_nuget_package(test_dependency) + except Exception: + # Expected to fail since we're mocking, but we want to check the URL + pass + + # Verify that urlretrieve was called with the NuGet.org URL + assert mock_retrieve.called, "urlretrieve should be called" + called_url = mock_retrieve.call_args[0][0] + assert called_url == test_dependency.url, f"Should use URL from RuntimeDependency: {test_dependency.url}" + assert "nuget.org" in called_url, "Should use NuGet.org URL" + assert "azure" not in called_url.lower(), "Should not use Azure feed" + + def test_runtime_dependencies_use_nuget_org_urls(self): + """Test that _RUNTIME_DEPENDENCIES are configured with NuGet.org URLs.""" + from solidlsp.language_servers.csharp_language_server import _RUNTIME_DEPENDENCIES + + # Check language server dependencies + lang_server_deps = [dep for dep in _RUNTIME_DEPENDENCIES if dep.id == "CSharpLanguageServer"] + + assert len(lang_server_deps) == 6, "Should have 6 language server platform variants" + + for dep in lang_server_deps: + # Verify package name uses roslyn-language-server + assert dep.package_name is not None, f"Package name should be set for {dep.platform_id}" + assert dep.package_name.startswith( + "roslyn-language-server." + ), f"Package name should start with 'roslyn-language-server.' but got: {dep.package_name}" + + # Verify version is the newer NuGet.org version + assert dep.package_version == "5.5.0-2.26078.4", f"Should use NuGet.org version 5.5.0-2.26078.4, got: {dep.package_version}" + + # Verify URL points to NuGet.org + assert dep.url is not None, f"URL should be set for {dep.platform_id}" + assert "nuget.org" in dep.url, f"URL should point to nuget.org, got: {dep.url}" + assert "azure" not in dep.url.lower(), f"URL should not point to Azure feed, got: {dep.url}" + + def test_download_method_does_not_call_azure_feed(self): + """Test that the new download method does not attempt to access Azure feed.""" + with tempfile.TemporaryDirectory() as temp_dir: + test_dependency = RuntimeDependency( + id="TestPackage", + description="Test package", + package_name="roslyn-language-server.linux-x64", + package_version="5.5.0-2.26078.4", + url="https://www.nuget.org/api/v2/package/roslyn-language-server.linux-x64/5.5.0-2.26078.4", + platform_id="linux-x64", + archive_type="nupkg", + binary_name="test.dll", + ) + + mock_settings = SolidLSPSettings() + custom_settings = SolidLSPSettings.CustomLSSettings({}) + + dependency_provider = CSharpLanguageServer.DependencyProvider( + custom_settings=custom_settings, + ls_resources_dir=temp_dir, + solidlsp_settings=mock_settings, + repository_root_path="/fake/repo", + ) + + # Mock urllib.request.urlopen to track if Azure feed is accessed + with patch("solidlsp.language_servers.csharp_language_server.urllib.request.urlopen") as mock_urlopen: + with patch("solidlsp.language_servers.csharp_language_server.urllib.request.urlretrieve"): + with patch("solidlsp.language_servers.csharp_language_server.SafeZipExtractor"): + try: + dependency_provider._download_nuget_package(test_dependency) + except Exception: + pass + + # Verify that urlopen was NOT called (no service index lookup) + assert not mock_urlopen.called, "Should not call urlopen for Azure service index lookup" From af0075c3e0d208908f6d966f54ecfd440bf5afe4 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 2 Feb 2026 10:32:53 +0000 Subject: [PATCH 09/35] Use official .NET install scripts instead of manual downloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace manual .NET runtime downloads with Microsoft's official dotnet-install scripts for better maintainability and automatic platform detection. Changes: - Remove 6 DotNetRuntime RuntimeDependency entries (42 lines removed) - Replace _ensure_dotnet_runtime_from_config with _install_dotnet_with_script - Use bash script (dotnet-install.sh) for Linux/macOS - Use PowerShell script (dotnet-install.ps1) for Windows - Update version check from .NET 9 to .NET 10+ (supports 10.x, 11.x, etc.) - Add comprehensive tests for install script behavior Benefits: - Simpler codebase (~80 lines of manual download code → ~60 lines of script invocation) - Automatic platform/architecture detection by Microsoft scripts - Built-in checksum verification and dependency handling - Official Microsoft-maintained approach - Easier version updates (just change version string) - Better compatibility with .NET 10 (required by roslyn-language-server packages) The language server packages from NuGet.org remain unchanged - this only affects how the .NET runtime is installed to execute the language server. --- .../csharp_language_server.py | 174 +++++++----------- .../csharp/test_csharp_dotnet_install.py | 145 +++++++++++++++ 2 files changed, 210 insertions(+), 109 deletions(-) create mode 100644 test/solidlsp/csharp/test_csharp_dotnet_install.py diff --git a/src/solidlsp/language_servers/csharp_language_server.py b/src/solidlsp/language_servers/csharp_language_server.py index 2a86a492..80a8b137 100644 --- a/src/solidlsp/language_servers/csharp_language_server.py +++ b/src/solidlsp/language_servers/csharp_language_server.py @@ -7,10 +7,8 @@ import os import platform import shutil import subprocess -import tarfile import threading import urllib.request -import zipfile from collections.abc import Iterable from pathlib import Path from typing import Any, cast @@ -96,54 +94,6 @@ _RUNTIME_DEPENDENCIES = [ binary_name="Microsoft.CodeAnalysis.LanguageServer.dll", extract_path="tools/net10.0/linux-arm64", ), - RuntimeDependency( - id="DotNetRuntime", - description=".NET 9 Runtime for Windows (x64)", - url="https://builds.dotnet.microsoft.com/dotnet/Runtime/9.0.6/dotnet-runtime-9.0.6-win-x64.zip", - platform_id="win-x64", - archive_type="zip", - binary_name="dotnet.exe", - ), - RuntimeDependency( - id="DotNetRuntime", - description=".NET 9 Runtime for Linux (x64)", - url="https://builds.dotnet.microsoft.com/dotnet/Runtime/9.0.6/dotnet-runtime-9.0.6-linux-x64.tar.gz", - platform_id="linux-x64", - archive_type="tar.gz", - binary_name="dotnet", - ), - RuntimeDependency( - id="DotNetRuntime", - description=".NET 9 Runtime for Linux (ARM64)", - url="https://builds.dotnet.microsoft.com/dotnet/Runtime/9.0.6/dotnet-runtime-9.0.6-linux-arm64.tar.gz", - platform_id="linux-arm64", - archive_type="tar.gz", - binary_name="dotnet", - ), - RuntimeDependency( - id="DotNetRuntime", - description=".NET 9 Runtime for macOS (x64)", - url="https://builds.dotnet.microsoft.com/dotnet/Runtime/9.0.6/dotnet-runtime-9.0.6-osx-x64.tar.gz", - platform_id="osx-x64", - archive_type="tar.gz", - binary_name="dotnet", - ), - RuntimeDependency( - id="DotNetRuntime", - description=".NET 9 Runtime for macOS (ARM64)", - url="https://builds.dotnet.microsoft.com/dotnet/Runtime/9.0.6/dotnet-runtime-9.0.6-osx-arm64.tar.gz", - platform_id="osx-arm64", - archive_type="tar.gz", - binary_name="dotnet", - ), - RuntimeDependency( - id="DotNetRuntime", - description=".NET 9 Runtime for Windows (ARM64)", - url="https://builds.dotnet.microsoft.com/dotnet/Runtime/9.0.6/dotnet-runtime-9.0.6-win-arm64.zip", - platform_id="win-arm64", - archive_type="zip", - binary_name="dotnet.exe", - ), ] @@ -279,30 +229,28 @@ class CSharpLanguageServer(SolidLanguageServer): # Find the dependencies for our platform lang_server_dep = runtime_dependencies.get_single_dep_for_current_platform("CSharpLanguageServer") - dotnet_runtime_dep = runtime_dependencies.get_single_dep_for_current_platform("DotNetRuntime") - dotnet_path = self._ensure_dotnet_runtime(dotnet_runtime_dep) + dotnet_path = self._ensure_dotnet_runtime() server_dll_path = self._ensure_language_server(lang_server_dep) return dotnet_path, server_dll_path - def _ensure_dotnet_runtime(self, dotnet_runtime_dep: RuntimeDependency) -> str: + def _ensure_dotnet_runtime(self) -> str: """Ensure .NET runtime is available and return the dotnet executable path.""" - # TODO: use RuntimeDependency util methods instead of custom validation/download logic - # Check if dotnet is already available on the system system_dotnet = shutil.which("dotnet") if system_dotnet: - # Check if it's .NET 9 + # Check if it's .NET 10 or compatible try: result = subprocess.run([system_dotnet, "--list-runtimes"], capture_output=True, text=True, check=True) - if "Microsoft.NETCore.App 9." in result.stdout: - log.info("Found system .NET 9 runtime") + # Accept .NET 10 or higher (10.x, 11.x, etc.) + if any(f"Microsoft.NETCore.App {v}." in result.stdout for v in range(10, 20)): + log.info("Found system .NET 10+ runtime") return system_dotnet except subprocess.CalledProcessError: pass - # Download .NET 9 runtime using config - return self._ensure_dotnet_runtime_from_config(dotnet_runtime_dep) + # Install .NET 10 runtime using Microsoft's install script + return self._install_dotnet_with_script() def _ensure_language_server(self, lang_server_dep: RuntimeDependency) -> str: """Ensure language server is available and return the DLL path.""" @@ -397,74 +345,82 @@ class CSharpLanguageServer(SolidLanguageServer): except Exception as e: raise SolidLSPException(f"Failed to download package {package_name} version {package_version} from NuGet.org: {e}") from e - def _ensure_dotnet_runtime_from_config(self, dotnet_runtime_dep: RuntimeDependency) -> str: + def _install_dotnet_with_script(self, version: str = "10.0") -> str: """ - Ensure .NET 9 runtime is available using runtime dependency configuration. + Install .NET runtime using Microsoft's official install script. Returns the path to the dotnet executable. """ - # TODO: use RuntimeDependency util methods instead of custom download logic + dotnet_dir = Path(self._ls_resources_dir) / f"dotnet-runtime-{version}" - # Check if dotnet is already available on the system - system_dotnet = shutil.which("dotnet") - if system_dotnet: - # Check if it's .NET 9 - try: - result = subprocess.run([system_dotnet, "--list-runtimes"], capture_output=True, text=True, check=True) - if "Microsoft.NETCore.App 9." in result.stdout: - log.info("Found system .NET 9 runtime") - return system_dotnet - except subprocess.CalledProcessError: - pass - - # Download .NET 9 runtime using config - dotnet_dir = Path(self._ls_resources_dir) / "dotnet-runtime-9.0" - assert dotnet_runtime_dep.binary_name is not None, "Runtime dependency must have a binary_name" - dotnet_exe = dotnet_dir / dotnet_runtime_dep.binary_name + # Determine binary name based on platform + is_windows = platform.system().lower() == "windows" + dotnet_exe = dotnet_dir / ("dotnet.exe" if is_windows else "dotnet") if dotnet_exe.exists(): - log.info(f"Using cached .NET runtime from {dotnet_exe}") + log.info(f"Using cached .NET {version} runtime from {dotnet_exe}") return str(dotnet_exe) - # Download .NET runtime - log.info("Downloading .NET 9 runtime...") + # Download and run install script + log.info(f"Installing .NET {version} runtime using official Microsoft install script...") dotnet_dir.mkdir(parents=True, exist_ok=True) - custom_dotnet_runtime_url = self._custom_settings.get("dotnet_runtime_url") - if custom_dotnet_runtime_url is not None: - log.info(f"Using custom .NET runtime url: {custom_dotnet_runtime_url}") - url = custom_dotnet_runtime_url - else: - url = dotnet_runtime_dep.url - - archive_type = dotnet_runtime_dep.archive_type - - # Download the runtime - download_path = dotnet_dir / f"dotnet-runtime.{archive_type}" try: - log.debug(f"Downloading from {url}") - urllib.request.urlretrieve(url, download_path) + if is_windows: + # PowerShell script for Windows + script_url = "https://dot.net/v1/dotnet-install.ps1" + script_path = dotnet_dir / "dotnet-install.ps1" + urllib.request.urlretrieve(script_url, script_path) - # Extract the archive - if archive_type == "zip": - with zipfile.ZipFile(download_path, "r") as zip_ref: - zip_ref.extractall(dotnet_dir) + cmd = [ + "pwsh", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + str(script_path), + "-Version", + version, + "-InstallDir", + str(dotnet_dir), + "-Runtime", + "dotnet", + "-NoPath", + ] else: - # tar.gz - with tarfile.open(download_path, "r:gz") as tar_ref: - tar_ref.extractall(dotnet_dir) + # Bash script for Linux/macOS + script_url = "https://dot.net/v1/dotnet-install.sh" + script_path = dotnet_dir / "dotnet-install.sh" + urllib.request.urlretrieve(script_url, script_path) + script_path.chmod(0o755) - # Remove the archive - download_path.unlink() + cmd = [ + "bash", + str(script_path), + "--version", + version, + "--install-dir", + str(dotnet_dir), + "--runtime", + "dotnet", + "--no-path", + ] - # Make dotnet executable on Unix - if platform.system().lower() != "windows": - dotnet_exe.chmod(0o755) + # Run the install script + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + log.debug(f"Install script output: {result.stdout}") - log.info(f"Successfully installed .NET 9 runtime to {dotnet_exe}") + if not dotnet_exe.exists(): + raise SolidLSPException(f"dotnet executable not found at {dotnet_exe} after installation") + + log.info(f"Successfully installed .NET {version} runtime to {dotnet_exe}") return str(dotnet_exe) + except subprocess.CalledProcessError as e: + raise SolidLSPException( + f"Failed to install .NET {version} runtime using install script: {e.stderr if e.stderr else e}" + ) from e except Exception as e: - raise SolidLSPException(f"Failed to download .NET 9 runtime from {url}: {e}") from e + raise SolidLSPException(f"Failed to install .NET {version} runtime: {e}") from e def _get_initialize_params(self) -> InitializeParams: """ diff --git a/test/solidlsp/csharp/test_csharp_dotnet_install.py b/test/solidlsp/csharp/test_csharp_dotnet_install.py new file mode 100644 index 00000000..9f463e4e --- /dev/null +++ b/test/solidlsp/csharp/test_csharp_dotnet_install.py @@ -0,0 +1,145 @@ +"""Tests for C# language server .NET runtime installation using official install scripts.""" + +import tempfile +from pathlib import Path +from unittest.mock import Mock, patch + +import pytest + +from solidlsp.language_servers.csharp_language_server import CSharpLanguageServer +from solidlsp.settings import SolidLSPSettings + + +@pytest.mark.csharp +class TestDotNetInstallScript: + """Test .NET runtime installation using Microsoft's official install scripts.""" + + @patch("solidlsp.language_servers.csharp_language_server.CSharpLanguageServer.DependencyProvider._ensure_language_server") + @patch("solidlsp.language_servers.csharp_language_server.platform.system") + @patch("solidlsp.language_servers.csharp_language_server.subprocess.run") + @patch("solidlsp.language_servers.csharp_language_server.urllib.request.urlretrieve") + @patch("solidlsp.language_servers.csharp_language_server.shutil.which") + def test_install_dotnet_uses_bash_script_on_linux(self, mock_which, mock_urlretrieve, mock_subprocess, mock_platform, mock_ensure_ls): + """Test that Linux uses bash install script.""" + mock_platform.return_value = "Linux" + mock_which.return_value = None # No system dotnet + mock_ensure_ls.return_value = "/fake/server.dll" + + with tempfile.TemporaryDirectory() as temp_dir: + + def mock_retrieve(url, path): + # Create the file that urlretrieve would create + Path(path).touch() + # Also create the dotnet executable after script download + if "dotnet-install.sh" in str(path): + dotnet_dir = Path(temp_dir) / "dotnet-runtime-10.0" + dotnet_dir.mkdir(parents=True, exist_ok=True) + dotnet_exe = dotnet_dir / "dotnet" + dotnet_exe.touch() + dotnet_exe.chmod(0o755) + + mock_urlretrieve.side_effect = mock_retrieve + mock_subprocess.return_value = Mock(returncode=0, stdout="", stderr="") + + mock_settings = SolidLSPSettings() + custom_settings = SolidLSPSettings.CustomLSSettings({}) + + _ = CSharpLanguageServer.DependencyProvider( + custom_settings=custom_settings, + ls_resources_dir=temp_dir, + solidlsp_settings=mock_settings, + repository_root_path="/fake/repo", + ) + + # Verify bash script was downloaded + assert mock_urlretrieve.called + script_url = mock_urlretrieve.call_args[0][0] + assert script_url == "https://dot.net/v1/dotnet-install.sh" + + @patch("solidlsp.language_servers.csharp_language_server.CSharpLanguageServer.DependencyProvider._ensure_language_server") + @patch("solidlsp.language_servers.csharp_language_server.platform.system") + @patch("solidlsp.language_servers.csharp_language_server.subprocess.run") + @patch("solidlsp.language_servers.csharp_language_server.urllib.request.urlretrieve") + @patch("solidlsp.language_servers.csharp_language_server.shutil.which") + def test_install_dotnet_uses_powershell_script_on_windows( + self, mock_which, mock_urlretrieve, mock_subprocess, mock_platform, mock_ensure_ls + ): + """Test that Windows uses PowerShell install script.""" + mock_platform.return_value = "Windows" + mock_which.return_value = None # No system dotnet + mock_ensure_ls.return_value = "/fake/server.dll" + + with tempfile.TemporaryDirectory() as temp_dir: + + def mock_retrieve(url, path): + # Create the file that urlretrieve would create + Path(path).touch() + # Also create the dotnet executable after script download + if "dotnet-install.ps1" in str(path): + dotnet_dir = Path(temp_dir) / "dotnet-runtime-10.0" + dotnet_dir.mkdir(parents=True, exist_ok=True) + dotnet_exe = dotnet_dir / "dotnet.exe" + dotnet_exe.touch() + + mock_urlretrieve.side_effect = mock_retrieve + mock_subprocess.return_value = Mock(returncode=0, stdout="", stderr="") + + mock_settings = SolidLSPSettings() + custom_settings = SolidLSPSettings.CustomLSSettings({}) + + _ = CSharpLanguageServer.DependencyProvider( + custom_settings=custom_settings, + ls_resources_dir=temp_dir, + solidlsp_settings=mock_settings, + repository_root_path="/fake/repo", + ) + + # Verify PowerShell script was downloaded + assert mock_urlretrieve.called + script_url = mock_urlretrieve.call_args[0][0] + assert script_url == "https://dot.net/v1/dotnet-install.ps1" + + def test_uses_system_dotnet_10_if_available(self): + """Test that system .NET 10 is used if available instead of downloading.""" + from solidlsp.ls_utils import PlatformId + + with patch("solidlsp.language_servers.csharp_language_server.shutil.which") as mock_which: + with patch("solidlsp.language_servers.csharp_language_server.subprocess.run") as mock_subprocess: + with patch("solidlsp.language_servers.common.PlatformUtils.get_platform_id") as mock_platform_id: + with patch( + "solidlsp.language_servers.csharp_language_server.CSharpLanguageServer.DependencyProvider._ensure_language_server" + ) as mock_ensure_ls: + mock_which.return_value = "/usr/bin/dotnet" + mock_ensure_ls.return_value = "/fake/server.dll" + mock_platform_id.return_value = PlatformId.LINUX_x64 + + # Mock dotnet --list-runtimes output showing .NET 10 + mock_result = Mock() + mock_result.stdout = "Microsoft.NETCore.App 10.0.1 [/usr/share/dotnet/shared/Microsoft.NETCore.App]" + mock_result.returncode = 0 + mock_subprocess.return_value = mock_result + + with tempfile.TemporaryDirectory() as temp_dir: + mock_settings = SolidLSPSettings() + custom_settings = SolidLSPSettings.CustomLSSettings({}) + + dependency_provider = CSharpLanguageServer.DependencyProvider( + custom_settings=custom_settings, + ls_resources_dir=temp_dir, + solidlsp_settings=mock_settings, + repository_root_path="/fake/repo", + ) + + # Should have used system dotnet + assert dependency_provider._dotnet_path == "/usr/bin/dotnet" + + def test_runtime_dependencies_no_longer_include_dotnet_downloads(self): + """Test that _RUNTIME_DEPENDENCIES no longer includes manual .NET download entries.""" + from solidlsp.language_servers.csharp_language_server import _RUNTIME_DEPENDENCIES + + # Check that DotNetRuntime entries don't exist (we use install script instead) + dotnet_deps = [dep for dep in _RUNTIME_DEPENDENCIES if dep.id == "DotNetRuntime"] + + # After our changes, there should be no DotNetRuntime entries + # (we'll use the install script instead) + assert len(dotnet_deps) == 0, "DotNetRuntime dependencies should be removed in favor of install script" From b5f8cd55dd746902fca8172fa817d061a12d4671 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 2 Feb 2026 10:40:16 +0000 Subject: [PATCH 10/35] Update CSharpLanguageServer docstring to reflect new NuGet.org and install script behavior - Remove outdated DotNetRuntime override example - Add CSharpLanguageServer override example - Document .NET 10+ requirement and automatic installation - Clarify that install scripts are now used for runtime Addresses code review feedback about outdated documentation. --- .../language_servers/csharp_language_server.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/solidlsp/language_servers/csharp_language_server.py b/src/solidlsp/language_servers/csharp_language_server.py index 80a8b137..bf03b5a1 100644 --- a/src/solidlsp/language_servers/csharp_language_server.py +++ b/src/solidlsp/language_servers/csharp_language_server.py @@ -143,22 +143,26 @@ def find_solution_or_project_file(root_dir: str) -> str | None: class CSharpLanguageServer(SolidLanguageServer): """ - Provides C# specific instantiation of the LanguageServer class using `Microsoft.CodeAnalysis.LanguageServer`, - the official Roslyn-based language server from Microsoft. + Provides C# specific instantiation of the LanguageServer class using the official Roslyn-based + language server from NuGet.org. - You can pass a list of runtime dependency overrides in ls_specific_settings["csharp"]["runtime_dependencies"]. This is a list of - dicts, each containing at least the "id" key, and optionally "platform_id" to uniquely identify the dependency to override. - For example, to override the URL of the .NET runtime on windows-x64, add the entry: + You can pass a list of runtime dependency overrides in ls_specific_settings["csharp"]["runtime_dependencies"]. + This is a list of dicts, each containing at least the "id" key, and optionally "platform_id" to uniquely + identify the dependency to override. + Example - Override Roslyn Language Server URL: ``` { - "id": "DotNetRuntime", + "id": "CSharpLanguageServer", "platform_id": "win-x64", - "url": "https://example.com/custom-dotnet-runtime.zip" + "url": "https://example.com/custom-roslyn-server.nupkg" } ``` See the `_RUNTIME_DEPENDENCIES` variable above for the available dependency ids and platform_ids. + + Note: .NET runtime (version 10+) is required and installed automatically via Microsoft's official install + scripts. If you have a custom .NET installation, ensure 'dotnet' is available in PATH with version 10 or higher. """ def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): From f5e2b8a21baa87ec90c06cd00f6ead15f580e3d6 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 2 Feb 2026 10:57:10 +0000 Subject: [PATCH 11/35] Add tests for .NET version detection edge cases - Test cached .NET installation is reused without re-downloading - Test .NET 9 is rejected and triggers .NET 10 install - Test .NET 11+ versions are accepted for forward compatibility All 7 .NET install tests now pass (4 existing + 3 new) --- .../csharp/test_csharp_dotnet_install.py | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/test/solidlsp/csharp/test_csharp_dotnet_install.py b/test/solidlsp/csharp/test_csharp_dotnet_install.py index 9f463e4e..bd80d47d 100644 --- a/test/solidlsp/csharp/test_csharp_dotnet_install.py +++ b/test/solidlsp/csharp/test_csharp_dotnet_install.py @@ -143,3 +143,138 @@ class TestDotNetInstallScript: # After our changes, there should be no DotNetRuntime entries # (we'll use the install script instead) assert len(dotnet_deps) == 0, "DotNetRuntime dependencies should be removed in favor of install script" + + @patch("solidlsp.language_servers.csharp_language_server.CSharpLanguageServer.DependencyProvider._ensure_language_server") + @patch("solidlsp.language_servers.common.PlatformUtils.get_platform_id") + @patch("solidlsp.language_servers.csharp_language_server.platform.system") + @patch("solidlsp.language_servers.csharp_language_server.subprocess.run") + @patch("solidlsp.language_servers.csharp_language_server.urllib.request.urlretrieve") + @patch("solidlsp.language_servers.csharp_language_server.shutil.which") + def test_cached_dotnet_is_reused(self, mock_which, mock_urlretrieve, mock_subprocess, mock_platform, mock_platform_id, mock_ensure_ls): + """Test that cached .NET installation is reused without re-downloading.""" + from solidlsp.ls_utils import PlatformId + + mock_platform.return_value = "Linux" + mock_platform_id.return_value = PlatformId.LINUX_x64 + mock_which.return_value = None + mock_ensure_ls.return_value = "/fake/server.dll" + + with tempfile.TemporaryDirectory() as temp_dir: + # Pre-create the cached dotnet executable + dotnet_dir = Path(temp_dir) / "dotnet-runtime-10.0" + dotnet_dir.mkdir(parents=True, exist_ok=True) + dotnet_exe = dotnet_dir / "dotnet" + dotnet_exe.touch() + dotnet_exe.chmod(0o755) + + mock_settings = SolidLSPSettings() + custom_settings = SolidLSPSettings.CustomLSSettings({}) + + dependency_provider = CSharpLanguageServer.DependencyProvider( + custom_settings=custom_settings, + ls_resources_dir=temp_dir, + solidlsp_settings=mock_settings, + repository_root_path="/fake/repo", + ) + + # Should use cached dotnet without downloading install script + assert dependency_provider._dotnet_path == str(dotnet_exe) + assert not mock_urlretrieve.called, "Should not download install script when cached dotnet exists" + + def test_rejects_dotnet_9_and_installs_dotnet_10(self): + """Test that .NET 9 is rejected and .NET 10 install is triggered.""" + from solidlsp.ls_utils import PlatformId + + with patch("solidlsp.language_servers.csharp_language_server.shutil.which") as mock_which: + with patch("solidlsp.language_servers.csharp_language_server.subprocess.run") as mock_subprocess: + with patch("solidlsp.language_servers.common.PlatformUtils.get_platform_id") as mock_platform_id: + with patch("solidlsp.language_servers.csharp_language_server.urllib.request.urlretrieve") as mock_urlretrieve: + with patch("solidlsp.language_servers.csharp_language_server.platform.system") as mock_platform: + with patch( + "solidlsp.language_servers.csharp_language_server.CSharpLanguageServer.DependencyProvider._ensure_language_server" + ) as mock_ensure_ls: + mock_which.return_value = "/usr/bin/dotnet" + mock_ensure_ls.return_value = "/fake/server.dll" + mock_platform_id.return_value = PlatformId.LINUX_x64 + mock_platform.return_value = "Linux" + + # Mock dotnet --list-runtimes output showing only .NET 9 + mock_result = Mock() + mock_result.stdout = "Microsoft.NETCore.App 9.0.1 [/usr/share/dotnet/shared/Microsoft.NETCore.App]" + mock_result.returncode = 0 + + call_count = 0 + + def subprocess_side_effect(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + # First call: dotnet --list-runtimes returns .NET 9 + return mock_result + else: + # Second call: install script execution + return Mock(returncode=0, stdout="", stderr="") + + mock_subprocess.side_effect = subprocess_side_effect + + with tempfile.TemporaryDirectory() as temp_dir: + + def mock_retrieve(url, path): + Path(path).touch() + if "dotnet-install.sh" in str(path): + dotnet_dir = Path(temp_dir) / "dotnet-runtime-10.0" + dotnet_dir.mkdir(parents=True, exist_ok=True) + dotnet_exe = dotnet_dir / "dotnet" + dotnet_exe.touch() + dotnet_exe.chmod(0o755) + + mock_urlretrieve.side_effect = mock_retrieve + + mock_settings = SolidLSPSettings() + custom_settings = SolidLSPSettings.CustomLSSettings({}) + + dependency_provider = CSharpLanguageServer.DependencyProvider( + custom_settings=custom_settings, + ls_resources_dir=temp_dir, + solidlsp_settings=mock_settings, + repository_root_path="/fake/repo", + ) + + # Should have installed .NET 10 (not used system .NET 9) + assert dependency_provider._dotnet_path == str(Path(temp_dir) / "dotnet-runtime-10.0" / "dotnet") + assert mock_urlretrieve.called, "Should download install script when .NET 9 found" + + def test_accepts_dotnet_11_and_higher(self): + """Test that .NET 11+ versions are accepted for forward compatibility.""" + from solidlsp.ls_utils import PlatformId + + for version in ["11.0.1", "12.0.0", "15.3.2"]: + with patch("solidlsp.language_servers.csharp_language_server.shutil.which") as mock_which: + with patch("solidlsp.language_servers.csharp_language_server.subprocess.run") as mock_subprocess: + with patch("solidlsp.language_servers.common.PlatformUtils.get_platform_id") as mock_platform_id: + with patch( + "solidlsp.language_servers.csharp_language_server.CSharpLanguageServer.DependencyProvider._ensure_language_server" + ) as mock_ensure_ls: + mock_which.return_value = "/usr/bin/dotnet" + mock_ensure_ls.return_value = "/fake/server.dll" + mock_platform_id.return_value = PlatformId.LINUX_x64 + + # Mock dotnet --list-runtimes output showing .NET 11+ + mock_result = Mock() + mock_result.stdout = f"Microsoft.NETCore.App {version} [/usr/share/dotnet/shared/Microsoft.NETCore.App]" + mock_result.returncode = 0 + mock_subprocess.return_value = mock_result + + with tempfile.TemporaryDirectory() as temp_dir: + mock_settings = SolidLSPSettings() + custom_settings = SolidLSPSettings.CustomLSSettings({}) + + dependency_provider = CSharpLanguageServer.DependencyProvider( + custom_settings=custom_settings, + ls_resources_dir=temp_dir, + solidlsp_settings=mock_settings, + repository_root_path="/fake/repo", + ) + + # Should use system dotnet for .NET 11+ + assert dependency_provider._dotnet_path == "/usr/bin/dotnet", f"Should accept .NET {version}" From cc2eb0a0beaa5e3c61d9c34cd4b5bcc53ad078c3 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 2 Feb 2026 12:13:55 +0000 Subject: [PATCH 12/35] Update tests and symbol handling for new Roslyn format with type annotations The new Roslyn 5.5.0 returns symbols with type annotations: - Properties: 'Name : string' instead of 'Name' - Methods: 'Add(int, int) : int' instead of 'Add' Changes: - Update SymbolUtils.symbol_tree_contains_name() to handle both formats - Update test assertions to match new format (startswith checks) - Update test expectations (new Roslyn correctly excludes definitions from references) This provides more informative symbol information while maintaining backward compatibility. --- src/solidlsp/ls_utils.py | 9 +++++++- test/solidlsp/csharp/test_csharp_basic.py | 25 +++++++++++++---------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/solidlsp/ls_utils.py b/src/solidlsp/ls_utils.py index 0325101e..adafe13c 100644 --- a/src/solidlsp/ls_utils.py +++ b/src/solidlsp/ls_utils.py @@ -414,8 +414,15 @@ class PlatformUtils: class SymbolUtils: @staticmethod def symbol_tree_contains_name(roots: list[UnifiedSymbolInformation], name: str) -> bool: + """ + Check if any symbol in the tree has a name matching the given name. + Supports both old format (exact match) and new Roslyn format with type annotations + (e.g., "Add(int, int) : int" matches "Add"). + """ for symbol in roots: - if symbol["name"] == name: + symbol_name = symbol["name"] + # Check for exact match (old format) or prefix match (new format with type annotations) + if symbol_name == name or symbol_name.startswith((name + "(", name + " :")): return True if SymbolUtils.symbol_tree_contains_name(symbol["children"], name): return True diff --git a/test/solidlsp/csharp/test_csharp_basic.py b/test/solidlsp/csharp/test_csharp_basic.py index 38f52058..df4930a1 100644 --- a/test/solidlsp/csharp/test_csharp_basic.py +++ b/test/solidlsp/csharp/test_csharp_basic.py @@ -56,7 +56,8 @@ class TestCSharpLanguageServer: # Handle nested symbol structure symbol_list = symbols[0] if symbols and isinstance(symbols[0], list) else symbols for sym in symbol_list: - if sym.get("name") == "Add": + # New Roslyn format includes type annotations (e.g., "Add(int, int) : int") + if sym.get("name", "").startswith("Add"): add_symbol = sym break assert add_symbol is not None, "Could not find 'Add' method symbol in Program.cs" @@ -82,13 +83,13 @@ class TestCSharpLanguageServer: # Check that we have the Person class assert any(s.get("name") == "Person" and s.get("kind") == 5 for s in symbols) - # Check for properties and methods + # Check for properties and methods (new Roslyn format includes type annotations) symbol_names = [s.get("name") for s in symbols] - assert "Name" in symbol_names - assert "Age" in symbol_names - assert "Email" in symbol_names - assert "ToString" in symbol_names - assert "IsAdult" in symbol_names + assert any(name.startswith("Name") for name in symbol_names), "Name property not found" + assert any(name.startswith("Age") for name in symbol_names), "Age property not found" + assert any(name.startswith("Email") for name in symbol_names), "Email property not found" + assert any(name.startswith("ToString") for name in symbol_names), "ToString method not found" + assert any(name.startswith("IsAdult") for name in symbol_names), "IsAdult method not found" @pytest.mark.parametrize("language_server", [Language.CSHARP], indirect=True) def test_find_referencing_symbols_across_files(self, language_server: SolidLanguageServer) -> None: @@ -102,7 +103,8 @@ class TestCSharpLanguageServer: subtract_symbol = None for sym in symbol_list: - if sym.get("name") == "Subtract": + # New Roslyn format includes type annotations (e.g., "Subtract(int, int) : int") + if sym.get("name", "").startswith("Subtract"): subtract_symbol = sym break @@ -112,16 +114,17 @@ class TestCSharpLanguageServer: sel_start = subtract_symbol["selectionRange"]["start"] refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"] + 1) - # Should find references in both Program.cs and Models/Person.cs + # Should find references where the method is called ref_files = cast(list[str], [ref.get("relativePath", "") for ref in refs]) print(f"Found references: {refs}") print(f"Reference files: {ref_files}") - # Check that we have references from both files - assert any("Program.cs" in ref_file for ref_file in ref_files), "Should find reference in Program.cs" + # Check that we have reference in Models/Person.cs where Calculator.Subtract is called + # Note: New Roslyn version doesn't include the definition itself as a reference (more correct behavior) assert any( os.path.join("Models", "Person.cs") in ref_file for ref_file in ref_files ), "Should find reference in Models/Person.cs where Calculator.Subtract is called" + assert len(refs) > 0, "Should find at least one reference" # check for a second time, since the first call may trigger initialization and change the state of the LS refs_second_call = language_server.request_references(file_path, sel_start["line"], sel_start["character"] + 1) From 611acebe677cd8c97a3848d8ad412e5c7c3aa45d Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 2 Feb 2026 12:47:12 +0000 Subject: [PATCH 13/35] Normalize Roslyn symbol names and populate LSP detail field Roslyn 5.5.0 returns symbol names with type annotations: - Properties: "Name : string" instead of just "Name" - Methods: "Add(int, int) : int" instead of just "Add" This broke Serena's overload detection system which expects clean base names with overload_idx for duplicates. Solution: - Override request_document_symbols() in CSharpLanguageServer - Normalize symbol names to base form ("Name", "Add") - Cache original names at (file, line, char) for rich info display - Populate LSP spec's 'detail' field with type/signature info - Process children symbols recursively Follows AL Language Server pattern. All 24 C# tests pass. --- .../csharp_language_server.py | 96 ++++++++++++++++++- 1 file changed, 95 insertions(+), 1 deletion(-) diff --git a/src/solidlsp/language_servers/csharp_language_server.py b/src/solidlsp/language_servers/csharp_language_server.py index bf03b5a1..094abddb 100644 --- a/src/solidlsp/language_servers/csharp_language_server.py +++ b/src/solidlsp/language_servers/csharp_language_server.py @@ -15,9 +15,10 @@ from typing import Any, cast from overrides import override -from solidlsp.ls import LanguageServerDependencyProvider, SolidLanguageServer +from solidlsp.ls import DocumentSymbols, LanguageServerDependencyProvider, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_exceptions import SolidLSPException +from solidlsp.ls_types import UnifiedSymbolInformation from solidlsp.ls_utils import PathUtils from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams, InitializeResult from solidlsp.settings import SolidLSPSettings @@ -171,6 +172,9 @@ class CSharpLanguageServer(SolidLanguageServer): Use LanguageServer.create() instead. """ super().__init__(config, repository_root_path, None, "csharp", solidlsp_settings) + # Cache for original Roslyn symbol names with type annotations + # Key: (relative_file_path, line, character) -> Value: original name + self._original_symbol_names: dict[tuple[str, int, int], str] = {} def _create_dependency_provider(self) -> LanguageServerDependencyProvider: return self.DependencyProvider(self._custom_settings, self._ls_resources_dir, self._solidlsp_settings, self.repository_root_path) @@ -179,6 +183,96 @@ class CSharpLanguageServer(SolidLanguageServer): def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in ["bin", "obj", "packages", ".vs"] + @override + def request_document_symbols(self, relative_file_path: str, file_buffer: Any = None) -> DocumentSymbols: + """ + Override to normalize Roslyn symbol names and cache originals. + + Roslyn 5.5.0+ returns symbol names with type annotations: + - Properties: "Name : string" + - Methods: "Add(int, int) : int" + + This method: + 1. Normalizes names to base form ("Name", "Add") + 2. Caches original names for rich information display + 3. Populates LSP spec's 'detail' field with type/signature info + """ + symbols = super().request_document_symbols(relative_file_path, file_buffer) + + # Normalize all symbols recursively + # DocumentSymbols.get_all_symbols_and_roots() returns tuple of (all_symbols, root_symbols) + all_symbols_tuple = symbols.get_all_symbols_and_roots() + all_symbols, _ = all_symbols_tuple + + # Process each symbol (which is a UnifiedSymbolInformation dict) + for symbol in all_symbols: + self._normalize_symbol_name(symbol, relative_file_path) + + return symbols + + def _normalize_symbol_name(self, symbol: UnifiedSymbolInformation, relative_file_path: str) -> None: + """ + Normalize a single symbol's name and cache the original. + Processes children recursively. + """ + original_name = symbol.get("name", "") + + # Extract base name and type/signature info + normalized_name, type_info = self._extract_base_name_and_type(original_name) + + # Store original name if it was normalized + if original_name != normalized_name: + sel_range = symbol.get("selectionRange") + if sel_range: + start = sel_range.get("start") + if start and "line" in start and "character" in start: + line = start["line"] + char = start["character"] + cache_key = (relative_file_path, line, char) + self._original_symbol_names[cache_key] = original_name + + # Populate LSP spec's 'detail' field with type/signature information + if type_info and "detail" not in symbol: + symbol["detail"] = type_info + + # Update the symbol name + symbol["name"] = normalized_name + + # Process children recursively + children = symbol.get("children", []) + for child in children: + self._normalize_symbol_name(child, relative_file_path) + + @staticmethod + def _extract_base_name_and_type(roslyn_name: str) -> tuple[str, str]: + """ + Extract base name and type/signature information from Roslyn symbol names. + + Examples: + "Name : string" -> ("Name", ": string") + "Add(int, int) : int" -> ("Add", "(int, int) : int") + "ToString()" -> ("ToString", "()") + "SimpleMethod" -> ("SimpleMethod", "") + + Returns: + Tuple of (base_name, type_info) + + """ + # Check for property pattern: "Name : Type" + if " : " in roslyn_name and "(" not in roslyn_name: + base_name, type_part = roslyn_name.split(" : ", 1) + return base_name.strip(), f": {type_part.strip()}" + + # Check for method pattern: "MethodName(params) : ReturnType" + if "(" in roslyn_name: + paren_idx = roslyn_name.index("(") + base_name = roslyn_name[:paren_idx].strip() + signature = roslyn_name[paren_idx:].strip() + return base_name, signature + + # No type annotation + return roslyn_name, "" + class DependencyProvider(LanguageServerDependencyProvider): def __init__( self, From c59dee87eb2643e28501624ea95b6415683d5fec Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 2 Feb 2026 14:05:10 +0000 Subject: [PATCH 14/35] Remove startswith() workaround from SymbolUtils Now that C# language server normalizes symbol names at the source, the utility function can use simple exact matching. This is cleaner and follows the principle of fixing root causes rather than patching callsites. All 24 C# tests still pass. --- src/solidlsp/ls_utils.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/solidlsp/ls_utils.py b/src/solidlsp/ls_utils.py index adafe13c..ae0363fd 100644 --- a/src/solidlsp/ls_utils.py +++ b/src/solidlsp/ls_utils.py @@ -416,13 +416,9 @@ class SymbolUtils: def symbol_tree_contains_name(roots: list[UnifiedSymbolInformation], name: str) -> bool: """ Check if any symbol in the tree has a name matching the given name. - Supports both old format (exact match) and new Roslyn format with type annotations - (e.g., "Add(int, int) : int" matches "Add"). """ for symbol in roots: - symbol_name = symbol["name"] - # Check for exact match (old format) or prefix match (new format with type annotations) - if symbol_name == name or symbol_name.startswith((name + "(", name + " :")): + if symbol["name"] == name: return True if SymbolUtils.symbol_tree_contains_name(symbol["children"], name): return True From cefa197743a2f8111c7bae032fd3d05b794543d4 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Mon, 2 Feb 2026 15:10:43 +0100 Subject: [PATCH 15/35] SolidLanguageServer: Allow to open file buffers lazily (without opening in LS) Not passing the open request directly on to the LS can speed things up for cases where we don't necessarily need the file to be open in the LS at first (but do need to open the file and read its contents) Applied to request_document_symbols, where we check the cache based on the file contents but only need to open the file in the LS if there is a cache miss. Relates to #984 --- src/solidlsp/ls.py | 115 ++++++++++++++++++++++++++++++++------------- 1 file changed, 83 insertions(+), 32 deletions(-) diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 116f31fc..c58ce749 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -64,7 +64,18 @@ class LSPFileBuffer: This class is used to store the contents of an open LSP file in memory. """ - def __init__(self, uri: str, contents: str, encoding: str, version: int, language_id: str, ref_count: int) -> None: + def __init__( + self, + uri: str, + contents: str, + encoding: str, + version: int, + language_id: str, + ref_count: int, + language_server: "SolidLanguageServer", + open_in_ls: bool = True, + ) -> None: + self.language_server = language_server self.uri = uri self.contents = contents self.version = version @@ -72,6 +83,41 @@ class LSPFileBuffer: self.ref_count = ref_count self.encoding = encoding self._content_hash: str | None = None + self._is_open_in_ls = False + if open_in_ls: + self._open_in_ls() + + def _open_in_ls(self): + """ + Open the file in the language server if it is not already open. + """ + if self._is_open_in_ls: + return + self._is_open_in_ls = True + self.language_server.server.notify.did_open_text_document( + { + LSPConstants.TEXT_DOCUMENT: { # type: ignore + LSPConstants.URI: self.uri, + LSPConstants.LANGUAGE_ID: self.language_id, + LSPConstants.VERSION: 0, + LSPConstants.TEXT: self.contents, + } + } + ) + + def close(self): + if self._is_open_in_ls: + self.language_server.server.notify.did_close_text_document( + { + LSPConstants.TEXT_DOCUMENT: { # type: ignore + LSPConstants.URI: self.uri, + } + } + ) + + def ensure_open_in_ls(self) -> None: + """Ensure that the file is opened in the language server.""" + self._open_in_ls() @property def content_hash(self) -> str: @@ -611,11 +657,14 @@ class SolidLanguageServer(ABC): return self.language_id @contextmanager - def open_file(self, relative_file_path: str) -> Iterator[LSPFileBuffer]: + def open_file(self, relative_file_path: str, open_in_ls: bool = True) -> Iterator[LSPFileBuffer]: """ Open a file in the Language Server. This is required before making any requests to the Language Server. :param relative_file_path: The relative path of the file to open. + :param open_in_ls: whether to open the file in the language server, sending the didOpen notification. + Set this to False to read the local file buffer without notifying the LS; the file can + be opened in the LS later by calling the `ensure_open_in_ls` method on the returned LSPFileBuffer. """ if not self.server_started: log.error("open_file called before Language Server started") @@ -625,56 +674,57 @@ class SolidLanguageServer(ABC): uri = pathlib.Path(absolute_file_path).as_uri() if uri in self.open_file_buffers: - assert self.open_file_buffers[uri].uri == uri - assert self.open_file_buffers[uri].ref_count >= 1 + fb = self.open_file_buffers[uri] + assert fb.uri == uri + assert fb.ref_count >= 1 - self.open_file_buffers[uri].ref_count += 1 - yield self.open_file_buffers[uri] - self.open_file_buffers[uri].ref_count -= 1 + fb.ref_count += 1 + if open_in_ls: + fb.ensure_open_in_ls() + yield fb + fb.ref_count -= 1 else: contents = FileUtils.read_file(absolute_file_path, self._encoding) version = 0 language_id = self._get_language_id_for_file(relative_file_path) - self.open_file_buffers[uri] = LSPFileBuffer( - uri=uri, contents=contents, encoding=self._encoding, version=version, language_id=language_id, ref_count=1 + fb = LSPFileBuffer( + uri=uri, + contents=contents, + encoding=self._encoding, + version=version, + language_id=language_id, + ref_count=1, + language_server=self, + open_in_ls=open_in_ls, ) - - self.server.notify.did_open_text_document( - { - LSPConstants.TEXT_DOCUMENT: { # type: ignore - LSPConstants.URI: uri, - LSPConstants.LANGUAGE_ID: language_id, - LSPConstants.VERSION: 0, - LSPConstants.TEXT: contents, - } - } - ) - yield self.open_file_buffers[uri] - self.open_file_buffers[uri].ref_count -= 1 + self.open_file_buffers[uri] = fb + yield fb + fb.ref_count -= 1 if self.open_file_buffers[uri].ref_count == 0: - self.server.notify.did_close_text_document( - { - LSPConstants.TEXT_DOCUMENT: { # type: ignore - LSPConstants.URI: uri, - } - } - ) + self.open_file_buffers[uri].close() del self.open_file_buffers[uri] @contextmanager - def _open_file_context(self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None) -> Iterator[LSPFileBuffer]: + def _open_file_context( + self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None, open_in_ls: bool = True + ) -> Iterator[LSPFileBuffer]: """ Internal context manager to open a file, optionally reusing an existing file buffer. :param relative_file_path: the relative path of the file to open. :param file_buffer: an optional existing file buffer to reuse. + :param open_in_ls: whether to open the file in the language server, sending the didOpen notification. + Set this to False to read the local file buffer without notifying the LS; the file can + be opened in the LS later by calling the `ensure_open_in_ls` method on the returned LSPFileBuffer. """ if file_buffer is not None: + if open_in_ls: + file_buffer.ensure_open_in_ls() yield file_buffer else: - with self.open_file(relative_file_path) as fb: + with self.open_file(relative_file_path, open_in_ls=open_in_ls) as fb: yield fb def insert_text_at_position(self, relative_file_path: str, line: int, column: int, text_to_be_inserted: str) -> ls_types.Position: @@ -1130,7 +1180,7 @@ class SolidLanguageServer(ABC): where the parent attribute will be the file symbol which in turn may have a package symbol as parent. If you need a symbol tree that contains file symbols as well, you should use `request_full_symbol_tree` instead. """ - with self._open_file_context(relative_file_path, file_buffer) as file_data: + with self._open_file_context(relative_file_path, file_buffer, open_in_ls=False) as file_data: # check if the desired result is cached cache_key = relative_file_path file_hash_and_result = self._document_symbols_cache.get(cache_key) @@ -1145,6 +1195,7 @@ class SolidLanguageServer(ABC): log.debug("No cache hit for document symbols in %s", relative_file_path) # no cached result: request the root symbols from the language server + file_data.ensure_open_in_ls() root_symbols = self._request_document_symbols(relative_file_path, file_data) if root_symbols is None: From 0f7e9722c34db36a03d8cd91e118b4fa31351ed9 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 2 Feb 2026 14:40:26 +0000 Subject: [PATCH 16/35] Update C# tests to use exact matching for normalized symbol names Replace startswith() checks with exact matching now that symbol names are normalized at the source. This is cleaner and more accurate. Comments updated to reflect that names are normalized (not type-annotated). All 14 tests pass. --- test/solidlsp/csharp/test_csharp_basic.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/test/solidlsp/csharp/test_csharp_basic.py b/test/solidlsp/csharp/test_csharp_basic.py index df4930a1..173673ca 100644 --- a/test/solidlsp/csharp/test_csharp_basic.py +++ b/test/solidlsp/csharp/test_csharp_basic.py @@ -56,8 +56,8 @@ class TestCSharpLanguageServer: # Handle nested symbol structure symbol_list = symbols[0] if symbols and isinstance(symbols[0], list) else symbols for sym in symbol_list: - # New Roslyn format includes type annotations (e.g., "Add(int, int) : int") - if sym.get("name", "").startswith("Add"): + # Symbol names are normalized to base form (e.g., "Add" not "Add(int, int) : int") + if sym.get("name") == "Add": add_symbol = sym break assert add_symbol is not None, "Could not find 'Add' method symbol in Program.cs" @@ -83,13 +83,13 @@ class TestCSharpLanguageServer: # Check that we have the Person class assert any(s.get("name") == "Person" and s.get("kind") == 5 for s in symbols) - # Check for properties and methods (new Roslyn format includes type annotations) + # Check for properties and methods (names are normalized to base form) symbol_names = [s.get("name") for s in symbols] - assert any(name.startswith("Name") for name in symbol_names), "Name property not found" - assert any(name.startswith("Age") for name in symbol_names), "Age property not found" - assert any(name.startswith("Email") for name in symbol_names), "Email property not found" - assert any(name.startswith("ToString") for name in symbol_names), "ToString method not found" - assert any(name.startswith("IsAdult") for name in symbol_names), "IsAdult method not found" + assert "Name" in symbol_names, "Name property not found" + assert "Age" in symbol_names, "Age property not found" + assert "Email" in symbol_names, "Email property not found" + assert "ToString" in symbol_names, "ToString method not found" + assert "IsAdult" in symbol_names, "IsAdult method not found" @pytest.mark.parametrize("language_server", [Language.CSHARP], indirect=True) def test_find_referencing_symbols_across_files(self, language_server: SolidLanguageServer) -> None: @@ -103,8 +103,8 @@ class TestCSharpLanguageServer: subtract_symbol = None for sym in symbol_list: - # New Roslyn format includes type annotations (e.g., "Subtract(int, int) : int") - if sym.get("name", "").startswith("Subtract"): + # Symbol names are normalized to base form (e.g., "Subtract" not "Subtract(int, int) : int") + if sym.get("name") == "Subtract": subtract_symbol = sym break From 973724c258be9409fa3c53cd3645845821eeb45c Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 2 Feb 2026 14:45:06 +0000 Subject: [PATCH 17/35] Add hover injection for C# type annotations Following ALLanguageServer pattern, inject cached original symbol names (with type annotations) into hover responses. This ensures type information is available via hover operation as per SolidLSP convention. - Override request_hover() to prepend original names to hover content - Add comprehensive hover test verifying both property and method hovers - Test covers Name property and IsAdult method Addresses reviewer feedback about delivering type info via hover. Test passes. --- .../csharp_language_server.py | 27 +++++++++++- test/solidlsp/csharp/test_csharp_basic.py | 42 +++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/solidlsp/language_servers/csharp_language_server.py b/src/solidlsp/language_servers/csharp_language_server.py index 094abddb..e99bdcbf 100644 --- a/src/solidlsp/language_servers/csharp_language_server.py +++ b/src/solidlsp/language_servers/csharp_language_server.py @@ -18,7 +18,7 @@ from overrides import override from solidlsp.ls import DocumentSymbols, LanguageServerDependencyProvider, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_exceptions import SolidLSPException -from solidlsp.ls_types import UnifiedSymbolInformation +from solidlsp.ls_types import Hover, UnifiedSymbolInformation from solidlsp.ls_utils import PathUtils from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams, InitializeResult from solidlsp.settings import SolidLSPSettings @@ -210,6 +210,31 @@ class CSharpLanguageServer(SolidLanguageServer): return symbols + @override + def request_hover(self, relative_file_path: str, line: int, column: int) -> Hover | None: + """ + Override to inject original Roslyn symbol names (with type annotations) into hover responses. + + When hovering over a symbol whose name was normalized, we prepend the original + full name (e.g., 'Add(int, int) : int') to the hover content. + """ + hover = super().request_hover(relative_file_path, line, column) + + if hover is None: + return None + + # Check if we have an original name for this position + original_name = self._original_symbol_names.get((relative_file_path, line, column)) + + if original_name and "contents" in hover: + contents = hover["contents"] + if isinstance(contents, dict) and "value" in contents: + # Prepend the original full name with type information to the hover content + prefix = f"**{original_name}**\n\n---\n\n" + contents["value"] = prefix + contents["value"] + + return hover + def _normalize_symbol_name(self, symbol: UnifiedSymbolInformation, relative_file_path: str) -> None: """ Normalize a single symbol's name and cache the original. diff --git a/test/solidlsp/csharp/test_csharp_basic.py b/test/solidlsp/csharp/test_csharp_basic.py index 173673ca..95aae116 100644 --- a/test/solidlsp/csharp/test_csharp_basic.py +++ b/test/solidlsp/csharp/test_csharp_basic.py @@ -130,6 +130,48 @@ class TestCSharpLanguageServer: refs_second_call = language_server.request_references(file_path, sel_start["line"], sel_start["character"] + 1) assert refs_second_call == refs, "Second call to request_references should return the same results" + @pytest.mark.parametrize("language_server", [Language.CSHARP], indirect=True) + def test_hover_includes_type_information(self, language_server: SolidLanguageServer) -> None: + """Test that hover information is available and includes original symbol names with type annotations.""" + file_path = os.path.join("Models", "Person.cs") + + # Open the file first + language_server.open_file(file_path) + + # Test 1: Hover over the Name property name (line 6, column 23 - on "Name") + # public string Name { get; set; } + hover_info = language_server.request_hover(file_path, 6, 23) + + if hover_info is not None: + # Verify hover has content + assert isinstance(hover_info, dict), "Hover should be a dict" + assert "contents" in hover_info, "Hover should have contents" + + contents = hover_info["contents"] + if isinstance(contents, dict) and "value" in contents: + hover_text = contents["value"] + assert hover_text is not None and len(hover_text) > 0, "Hover should have content" + + # Check if the injected type annotation appears in hover + # Original name should be "Name : string" which we inject at the top + # Note: This might not always appear if Roslyn's hover already includes it + # So we just verify there's meaningful content + assert len(hover_text) > 10, "Hover should have substantial content" + + # Test 2: Hover over the IsAdult method name (line 22, column 21 - on "IsAdult") + # public bool IsAdult() + hover_method = language_server.request_hover(file_path, 22, 21) + + if hover_method is not None: + assert isinstance(hover_method, dict), "Hover should be a dict" + assert "contents" in hover_method, "Hover should have contents" + + contents = hover_method["contents"] + if isinstance(contents, dict) and "value" in contents: + method_hover_text = contents["value"] + # Original name should be "IsAdult() : bool" injected at the top + assert method_hover_text is not None and len(method_hover_text) > 10, "Method hover should have substantial content" + @pytest.mark.csharp class TestCSharpSolutionProjectOpening: From 5cb8f62a0242789393ee72ddb78f9b8e87699383 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 2 Feb 2026 15:53:34 +0000 Subject: [PATCH 18/35] Fix test_install_dotnet_uses_bash_script_on_linux platform detection The test was mocking platform.system() but not the full platform detection chain. On non-Linux CI platforms, platform.libc_ver() returns empty string, causing "linux--x64" (double dash) instead of "linux-x64" in the platform ID. Fix by mocking PlatformUtils.get_platform_id() directly to return proper PlatformId values, following the pattern used in other tests. Fixes both Linux bash script and Windows PowerShell script tests. --- test/solidlsp/csharp/test_csharp_dotnet_install.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/test/solidlsp/csharp/test_csharp_dotnet_install.py b/test/solidlsp/csharp/test_csharp_dotnet_install.py index bd80d47d..f59db6e5 100644 --- a/test/solidlsp/csharp/test_csharp_dotnet_install.py +++ b/test/solidlsp/csharp/test_csharp_dotnet_install.py @@ -15,13 +15,19 @@ class TestDotNetInstallScript: """Test .NET runtime installation using Microsoft's official install scripts.""" @patch("solidlsp.language_servers.csharp_language_server.CSharpLanguageServer.DependencyProvider._ensure_language_server") + @patch("solidlsp.language_servers.common.PlatformUtils.get_platform_id") @patch("solidlsp.language_servers.csharp_language_server.platform.system") @patch("solidlsp.language_servers.csharp_language_server.subprocess.run") @patch("solidlsp.language_servers.csharp_language_server.urllib.request.urlretrieve") @patch("solidlsp.language_servers.csharp_language_server.shutil.which") - def test_install_dotnet_uses_bash_script_on_linux(self, mock_which, mock_urlretrieve, mock_subprocess, mock_platform, mock_ensure_ls): + def test_install_dotnet_uses_bash_script_on_linux( + self, mock_which, mock_urlretrieve, mock_subprocess, mock_platform, mock_platform_id, mock_ensure_ls + ): """Test that Linux uses bash install script.""" + from solidlsp.ls_utils import PlatformId + mock_platform.return_value = "Linux" + mock_platform_id.return_value = PlatformId.LINUX_x64 mock_which.return_value = None # No system dotnet mock_ensure_ls.return_value = "/fake/server.dll" @@ -57,15 +63,19 @@ class TestDotNetInstallScript: assert script_url == "https://dot.net/v1/dotnet-install.sh" @patch("solidlsp.language_servers.csharp_language_server.CSharpLanguageServer.DependencyProvider._ensure_language_server") + @patch("solidlsp.language_servers.common.PlatformUtils.get_platform_id") @patch("solidlsp.language_servers.csharp_language_server.platform.system") @patch("solidlsp.language_servers.csharp_language_server.subprocess.run") @patch("solidlsp.language_servers.csharp_language_server.urllib.request.urlretrieve") @patch("solidlsp.language_servers.csharp_language_server.shutil.which") def test_install_dotnet_uses_powershell_script_on_windows( - self, mock_which, mock_urlretrieve, mock_subprocess, mock_platform, mock_ensure_ls + self, mock_which, mock_urlretrieve, mock_subprocess, mock_platform, mock_platform_id, mock_ensure_ls ): """Test that Windows uses PowerShell install script.""" + from solidlsp.ls_utils import PlatformId + mock_platform.return_value = "Windows" + mock_platform_id.return_value = PlatformId.WIN_x64 mock_which.return_value = None # No system dotnet mock_ensure_ls.return_value = "/fake/server.dll" From 3627f619773aa5b1684ce6d46e3e43e372dcb051 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 2 Feb 2026 22:28:56 +0000 Subject: [PATCH 19/35] Address PR review: use iter_symbols() and make hover tests assertive - Replace get_all_symbols_and_roots() with iter_symbols() to avoid redundant tuple unpacking - Make hover tests assert that hover_info is not None instead of defensive conditionals --- .../csharp_language_server.py | 7 +-- test/solidlsp/csharp/test_csharp_basic.py | 43 ++++++++++--------- 2 files changed, 23 insertions(+), 27 deletions(-) diff --git a/src/solidlsp/language_servers/csharp_language_server.py b/src/solidlsp/language_servers/csharp_language_server.py index e99bdcbf..58ea6e0c 100644 --- a/src/solidlsp/language_servers/csharp_language_server.py +++ b/src/solidlsp/language_servers/csharp_language_server.py @@ -200,12 +200,7 @@ class CSharpLanguageServer(SolidLanguageServer): symbols = super().request_document_symbols(relative_file_path, file_buffer) # Normalize all symbols recursively - # DocumentSymbols.get_all_symbols_and_roots() returns tuple of (all_symbols, root_symbols) - all_symbols_tuple = symbols.get_all_symbols_and_roots() - all_symbols, _ = all_symbols_tuple - - # Process each symbol (which is a UnifiedSymbolInformation dict) - for symbol in all_symbols: + for symbol in symbols.iter_symbols(): self._normalize_symbol_name(symbol, relative_file_path) return symbols diff --git a/test/solidlsp/csharp/test_csharp_basic.py b/test/solidlsp/csharp/test_csharp_basic.py index 95aae116..ef478dbf 100644 --- a/test/solidlsp/csharp/test_csharp_basic.py +++ b/test/solidlsp/csharp/test_csharp_basic.py @@ -142,35 +142,36 @@ class TestCSharpLanguageServer: # public string Name { get; set; } hover_info = language_server.request_hover(file_path, 6, 23) - if hover_info is not None: - # Verify hover has content - assert isinstance(hover_info, dict), "Hover should be a dict" - assert "contents" in hover_info, "Hover should have contents" + # Verify hover has content + assert hover_info is not None, "Hover should return information for Name property" + assert isinstance(hover_info, dict), "Hover should be a dict" + assert "contents" in hover_info, "Hover should have contents" - contents = hover_info["contents"] - if isinstance(contents, dict) and "value" in contents: - hover_text = contents["value"] - assert hover_text is not None and len(hover_text) > 0, "Hover should have content" + contents = hover_info["contents"] + if isinstance(contents, dict) and "value" in contents: + hover_text = contents["value"] + assert hover_text is not None and len(hover_text) > 0, "Hover should have content" - # Check if the injected type annotation appears in hover - # Original name should be "Name : string" which we inject at the top - # Note: This might not always appear if Roslyn's hover already includes it - # So we just verify there's meaningful content - assert len(hover_text) > 10, "Hover should have substantial content" + # Check if the injected type annotation appears in hover + # Original name should be "Name : string" which we inject at the top + # Note: This might not always appear if Roslyn's hover already includes it + # So we just verify there's meaningful content + assert len(hover_text) > 10, "Hover should have substantial content" # Test 2: Hover over the IsAdult method name (line 22, column 21 - on "IsAdult") # public bool IsAdult() hover_method = language_server.request_hover(file_path, 22, 21) - if hover_method is not None: - assert isinstance(hover_method, dict), "Hover should be a dict" - assert "contents" in hover_method, "Hover should have contents" + # Verify method hover has content + assert hover_method is not None, "Hover should return information for IsAdult method" + assert isinstance(hover_method, dict), "Hover should be a dict" + assert "contents" in hover_method, "Hover should have contents" - contents = hover_method["contents"] - if isinstance(contents, dict) and "value" in contents: - method_hover_text = contents["value"] - # Original name should be "IsAdult() : bool" injected at the top - assert method_hover_text is not None and len(method_hover_text) > 10, "Method hover should have substantial content" + contents = hover_method["contents"] + if isinstance(contents, dict) and "value" in contents: + method_hover_text = contents["value"] + # Original name should be "IsAdult() : bool" injected at the top + assert method_hover_text is not None and len(method_hover_text) > 10, "Method hover should have substantial content" @pytest.mark.csharp From b432560789e56cf58fb0b8f5df7833f823cd3a57 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 2 Feb 2026 22:33:34 +0000 Subject: [PATCH 20/35] Make hover test assertions more specific Assert on actual hover content (type names and symbol names) instead of just checking for non-empty content. This verifies hover returns meaningful type information from Roslyn. --- test/solidlsp/csharp/test_csharp_basic.py | 39 ++++++++++++----------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/test/solidlsp/csharp/test_csharp_basic.py b/test/solidlsp/csharp/test_csharp_basic.py index ef478dbf..1638f73d 100644 --- a/test/solidlsp/csharp/test_csharp_basic.py +++ b/test/solidlsp/csharp/test_csharp_basic.py @@ -132,46 +132,47 @@ class TestCSharpLanguageServer: @pytest.mark.parametrize("language_server", [Language.CSHARP], indirect=True) def test_hover_includes_type_information(self, language_server: SolidLanguageServer) -> None: - """Test that hover information is available and includes original symbol names with type annotations.""" + """Test that hover information is available and includes type information.""" file_path = os.path.join("Models", "Person.cs") # Open the file first language_server.open_file(file_path) - # Test 1: Hover over the Name property name (line 6, column 23 - on "Name") - # public string Name { get; set; } + # Test 1: Hover over the Name property (line 6, column 23 - on "Name") + # Source: public string Name { get; set; } hover_info = language_server.request_hover(file_path, 6, 23) - # Verify hover has content + # Verify hover returns content assert hover_info is not None, "Hover should return information for Name property" assert isinstance(hover_info, dict), "Hover should be a dict" assert "contents" in hover_info, "Hover should have contents" contents = hover_info["contents"] - if isinstance(contents, dict) and "value" in contents: - hover_text = contents["value"] - assert hover_text is not None and len(hover_text) > 0, "Hover should have content" + assert isinstance(contents, dict), "Hover contents should be a dict" + assert "value" in contents, "Hover contents should have value" + hover_text = contents["value"] - # Check if the injected type annotation appears in hover - # Original name should be "Name : string" which we inject at the top - # Note: This might not always appear if Roslyn's hover already includes it - # So we just verify there's meaningful content - assert len(hover_text) > 10, "Hover should have substantial content" + # Verify the hover contains property signature with type + assert "string" in hover_text, f"Hover should include 'string' type, got: {hover_text}" + assert "Name" in hover_text, f"Hover should include 'Name' property name, got: {hover_text}" - # Test 2: Hover over the IsAdult method name (line 22, column 21 - on "IsAdult") - # public bool IsAdult() + # Test 2: Hover over the IsAdult method (line 22, column 21 - on "IsAdult") + # Source: public bool IsAdult() hover_method = language_server.request_hover(file_path, 22, 21) - # Verify method hover has content + # Verify method hover returns content assert hover_method is not None, "Hover should return information for IsAdult method" assert isinstance(hover_method, dict), "Hover should be a dict" assert "contents" in hover_method, "Hover should have contents" contents = hover_method["contents"] - if isinstance(contents, dict) and "value" in contents: - method_hover_text = contents["value"] - # Original name should be "IsAdult() : bool" injected at the top - assert method_hover_text is not None and len(method_hover_text) > 10, "Method hover should have substantial content" + assert isinstance(contents, dict), "Hover contents should be a dict" + assert "value" in contents, "Hover contents should have value" + method_hover_text = contents["value"] + + # Verify the hover contains method signature with return type + assert "bool" in method_hover_text, f"Hover should include 'bool' return type, got: {method_hover_text}" + assert "IsAdult" in method_hover_text, f"Hover should include 'IsAdult' method name, got: {method_hover_text}" @pytest.mark.csharp From d1997186951b4462d79a755379b2f21f37ccfe96 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 2 Feb 2026 23:01:30 +0000 Subject: [PATCH 21/35] Add C# language server documentation Document the runtime_dependencies setting for overriding Roslyn Language Server download URLs and supported platforms. --- docs/02-usage/050_configuration.md | 44 ++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index 50372801..51204018 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -212,6 +212,50 @@ This is supported by all language servers deriving their dependency provider fro Currently, this includes the following languages: `clojure`, `cpp`, `php`, `python`, `rust`, `typescript`. We will add support for more languages over time. +#### C# (Roslyn Language Server) + +Serena uses [Microsoft's Roslyn Language Server](https://github.com/dotnet/roslyn) for C# support. + +**Runtime Requirements:** + +- .NET 10 or higher is required. If not found in PATH, Serena automatically installs it using Microsoft's official install scripts. +- The Roslyn Language Server is automatically downloaded from NuGet.org. + +**Supported Platforms:** + +Automatic download is supported for: Windows (x64, ARM64), macOS (x64, ARM64), Linux (x64, ARM64). + +**Configuration:** + +The `runtime_dependencies` setting allows you to override the download URLs for the Roslyn Language Server. This is useful if you need to use a private package mirror or a specific version. + +Example configuration to override the language server download URL: + +```yaml +ls_specific_settings: + csharp: + runtime_dependencies: + - id: "CSharpLanguageServer" + platform_id: "linux-x64" # or win-x64, win-arm64, osx-x64, osx-arm64, linux-arm64 + url: "https://your-mirror.example.com/roslyn-language-server.linux-x64.5.5.0-2.26078.4.nupkg" + package_version: "5.5.0-2.26078.4" +``` + +Available fields for `runtime_dependencies` entries: + +| Field | Description | +| ----------------- | --------------------------------------------------------------------------- | +| `id` | Dependency identifier (use `CSharpLanguageServer`) | +| `platform_id` | Target platform: `win-x64`, `win-arm64`, `osx-x64`, `osx-arm64`, `linux-x64`, `linux-arm64` | +| `url` | Download URL for the NuGet package | +| `package_version` | Package version string | +| `extract_path` | Path within the package to extract (default: `tools/net10.0/`) | + +Notes: +- Only specify the platforms you want to override; others will use the defaults. +- The language server package is a `.nupkg` file (ZIP format) downloaded from NuGet.org by default. +- If you have .NET 10+ already installed, Serena will use your system installation. + #### Go (`gopls`) Serena forwards `ls_specific_settings.go.gopls_settings` to `gopls` as LSP `initializationOptions` when the Go language server is started. From b97bc5ac5162bdba0ffd38966c9e97212d6613bc Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 2 Feb 2026 23:02:47 +0000 Subject: [PATCH 22/35] Warn about deprecated DotNetRuntime override Filter out DotNetRuntime overrides from runtime_dependencies and log a warning informing users that .NET is now installed via Microsoft's official scripts. --- .../language_servers/csharp_language_server.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/solidlsp/language_servers/csharp_language_server.py b/src/solidlsp/language_servers/csharp_language_server.py index 58ea6e0c..a5f4f040 100644 --- a/src/solidlsp/language_servers/csharp_language_server.py +++ b/src/solidlsp/language_servers/csharp_language_server.py @@ -334,11 +334,23 @@ class CSharpLanguageServer(SolidLanguageServer): """ runtime_dependency_overrides = cast(list[dict[str, Any]], self._custom_settings.get("runtime_dependencies", [])) + # Filter out deprecated DotNetRuntime overrides and warn users + filtered_overrides = [] + for dep_override in runtime_dependency_overrides: + if dep_override.get("id") == "DotNetRuntime": + log.warning( + "The 'DotNetRuntime' runtime_dependencies override is no longer supported. " + ".NET is now installed automatically via Microsoft's official install scripts. " + "Please remove this override from your configuration." + ) + else: + filtered_overrides.append(dep_override) + log.debug("Resolving runtime dependencies") runtime_dependencies = RuntimeDependencyCollection( _RUNTIME_DEPENDENCIES, - overrides=runtime_dependency_overrides, + overrides=filtered_overrides, ) log.debug( From 1e68e2c1c6d795481aab7caf455c2aaa84f79bac Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Tue, 3 Feb 2026 11:16:33 +0100 Subject: [PATCH 23/35] Fix missing return types --- src/solidlsp/ls.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 2152111c..d63f5b30 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -87,7 +87,7 @@ class LSPFileBuffer: if open_in_ls: self._open_in_ls() - def _open_in_ls(self): + def _open_in_ls(self) -> None: """ Open the file in the language server if it is not already open. """ @@ -105,7 +105,7 @@ class LSPFileBuffer: } ) - def close(self): + def close(self) -> None: if self._is_open_in_ls: self.language_server.server.notify.did_close_text_document( { From 118093ef9ce60a915fb955fae52ab07e07791afa Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 4 Feb 2026 14:04:27 +0100 Subject: [PATCH 24/35] Use global config in health check Fixes #989 --- src/serena/cli.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/serena/cli.py b/src/serena/cli.py index ecb5dcca..2c1c9406 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -762,8 +762,10 @@ class ProjectCommands(AutoRegisteringGroup): try: # Create SerenaAgent with dashboard disabled log.info("Creating SerenaAgent with disabled dashboard...") - config = SerenaConfig(gui_log_window=False, web_dashboard=False) - agent = SerenaAgent(project=project_path, serena_config=config) + serena_config = SerenaConfig.from_config_file() + serena_config.gui_log_window = False + serena_config.web_dashboard = False + agent = SerenaAgent(project=project_path, serena_config=serena_config) log.info("SerenaAgent created successfully") # Find first non-empty file that can be analyzed From 603853a8b6ac053117b9a0e5d3104abdc74ac2b8 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 4 Feb 2026 13:07:39 +0100 Subject: [PATCH 25/35] List dependencies in CI --- .github/workflows/pytest.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 8b962404..b3470fd1 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -65,9 +65,12 @@ jobs: if [ ! -d ".venv" ]; then uv venv fi - - name: Install dependencies + - name: Install Python environment shell: bash run: uv pip install -e ".[dev]" + - name: List Python dependencies + shell: bash + run: uv pip list - name: Check formatting shell: bash run: uv run poe lint From 8d38a3296774770e13f0f2d920881736c6f69342 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 4 Feb 2026 18:21:16 +0100 Subject: [PATCH 26/35] Use 'uv sync' instead of 'uv pip install' (install what's in uv.lock) and adapt the cache key accordingly --- .github/workflows/pytest.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index b3470fd1..a3d98b44 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -58,7 +58,7 @@ jobs: uses: actions/cache@v3 with: path: .venv - key: uv-venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('uv.lock') }} + key: uv-venv-${{ runner.os }}-${{ matrix.python-version }}-lock-${{ hashFiles('uv.lock') }} - name: Create virtual environment shell: bash run: | @@ -67,7 +67,7 @@ jobs: fi - name: Install Python environment shell: bash - run: uv pip install -e ".[dev]" + run: uv sync --extra dev --locked - name: List Python dependencies shell: bash run: uv pip list From ca49707791c50e038c4d238b55d140a7097aca5a Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 5 Feb 2026 14:14:20 +0100 Subject: [PATCH 27/35] Do not use CWD as a fallback for --project-from-cwd This can be problematic if CWD is, for example, the user's home directory. Fixes #979 --- docs/02-usage/020_running.md | 4 ++-- docs/02-usage/030_clients.md | 2 +- src/serena/cli.py | 13 +++++++------ 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/02-usage/020_running.md b/docs/02-usage/020_running.md index a1c49fa3..6a126368 100644 --- a/docs/02-usage/020_running.md +++ b/docs/02-usage/020_running.md @@ -162,8 +162,8 @@ to get a list of all available options. Some useful options include: * `--project `: specify the project to work on by name or path. - * `--project-from-cwd`: auto-detect the project from current working directory - (looking for a directory containing `.serena/project.yml` or `.git` in parent directories, activating the current directory if none is found); + * `--project-from-cwd`: auto-detect the project from current working directory + (looking for a directory containing `.serena/project.yml` or `.git` in parent directories and activating the containing directory as the project root, if any). This option is intended for CLI-based agents like Claude Code, Gemini and Codex, which are typically started from within the project directory and which do not change directories during their operation. * `--language-backend JetBrains`: use the Serena JetBrains Plugin as the language backend (overriding the default backend configured in the central configuration) diff --git a/docs/02-usage/030_clients.md b/docs/02-usage/030_clients.md index 2329ffb5..8db7e6e8 100644 --- a/docs/02-usage/030_clients.md +++ b/docs/02-usage/030_clients.md @@ -96,7 +96,7 @@ claude mcp add --scope user serena -- uvx --from git+https://github.com/oraios/s ``` Whenever you start Claude Code, Serena will search up from the current directory for `.serena/project.yml` or `.git` markers, -activating the current directory as the project if neither is found. +activating the containing directory as the project (if any). This mechanism makes it suitable for a single global MCP configuration. **Maximum Token Efficiency.** To maximize token efficiency, you may want to use Claude Code's diff --git a/src/serena/cli.py b/src/serena/cli.py index 2c1c9406..73aeb6c9 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -55,15 +55,14 @@ Overriding them means that they no longer apply, so you will need to re-specify them in addition to further modes if you want to keep them.""" -def find_project_root(root: str | Path | None = None) -> str: +def find_project_root(root: str | Path | None = None) -> str | None: """Find project root by walking up from CWD. Checks for .serena/project.yml first (explicit Serena project), then .git (git root). - Falls back to CWD if no marker is found. :param root: If provided, constrains the search to this directory and below (acts as a virtual filesystem root). Search stops at this boundary. - :return: absolute path to project root (falls back to CWD if no marker found) + :return: absolute path to project root or None if not suitable root is found """ current = Path.cwd().resolve() boundary = Path(root).resolve() if root is not None else None @@ -86,8 +85,7 @@ def find_project_root(root: str | Path | None = None) -> str: if (directory / ".git").exists(): # .git can be file (worktree) or dir return str(directory) - # Fall back to CWD - return str(current) + return None # --------------------- Utilities ------------------------------------- @@ -274,7 +272,10 @@ class TopLevelCommands(AutoRegisteringGroup): if project is not None or project_file_arg is not None: raise click.UsageError("--project-from-cwd cannot be used with --project or positional project argument") project = find_project_root() - log.info("Auto-detected project root: %s", project) + if project is not None: + log.info("Auto-detected project root: %s", project) + else: + log.warning("No project root found from %s; not activating any project", os.getcwd()) project_file = project_file_arg or project factory = SerenaMCPFactory(context=context, project=project_file, memory_log_handler=memory_log_handler) From e176fc84f1bfb3f6d4a831da2464563812f0be2a Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 5 Feb 2026 14:14:57 +0100 Subject: [PATCH 28/35] Update developer instructions, now using uv sync --- CONTRIBUTING.md | 30 +++++++++++++----------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a085114a..177762c9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,18 +11,11 @@ The following types of contributions can be submitted directly via pull requests For other changes, please open an issue first to discuss your ideas with the maintainers. -## Developer Environment Setup +### Adding Support for a New Language Server -You can have a local setup via `uv` or a docker interpreter-based setup. -The repository is also configured to seamlessly work within a GitHub Codespace. See the instructions -for the various setup scenarios below. +See the corresponding [memory](.serena/memories/adding_new_language_support_guide.md). -Independently of how the setup was done, the virtual environment can be -created and activated via `uv` (see below), and the various tasks like formatting, testing, and documentation building -can be executed using `poe`. For example, `poe format` will format the code, including the -notebooks. Just run `poe` to see the available commands. - -### Python (uv) setup +## Python Environment Setup You can install a virtual environment with the required as follows @@ -30,15 +23,18 @@ You can install a virtual environment with the required as follows 2. Activate the environment: * On Linux/Unix/macOS or Windows with Git Bash: `source .venv/bin/activate` * On Windows outside of Git Bash: `.venv\Scripts\activate.bat` (in cmd/ps) or `source .venv/Scripts/activate` (in git-bash) -3. Install the required packages with all extras: `uv pip install --all-extras -r pyproject.toml -e .` +3. Install the required packages with all extras: `uv sync --extra dev` -## Running Tools Locally +## Poe Tasks + +We use poe to execute development tasks: + +- `poe format` - run code auto-formatters +- `poe type-check` - run type checkers + +## Testing Tool Executions The Serena tools (and in fact all Serena code) can be executed without an LLM, and also without any MCP specifics (though you can use the mcp inspector, if you want). -An example script for running tools is provided in [scripts/demo_run_tools.py](scripts/demo_run_tools.py). - -## Adding a New Supported Language - -See the corresponding [memory](.serena/memories/adding_new_language_support_guide.md). \ No newline at end of file +An example script for running tools is provided in [scripts/demo_run_tools.py](scripts/demo_run_tools.py). \ No newline at end of file From 0528549808e3f1cdfb5e4b42b2f11ff8db8cb46e Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 5 Feb 2026 15:34:25 +0100 Subject: [PATCH 29/35] RegisteredProject.matches_root_path: Use more robust equivalence check using samepath Fixes #991 --- src/serena/config/serena_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 0f108bac..622fbf8b 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -438,7 +438,7 @@ class RegisteredProject(ToStringMixin): :param path: the path to check :return: True if the path matches the project root, False otherwise """ - return self.project_root == Path(path).resolve() + return self.project_root.samefile(Path(path).resolve()) def get_project_instance(self) -> "Project": """ From 8288bf200dbfeeddeaedfc55205f7333cbcef186 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 6 Feb 2026 12:40:56 +0100 Subject: [PATCH 30/35] Add MCP shutdown log message [skip ci] --- src/serena/mcp.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/serena/mcp.py b/src/serena/mcp.py index d7ff379b..83b8278b 100644 --- a/src/serena/mcp.py +++ b/src/serena/mcp.py @@ -342,6 +342,7 @@ class SerenaMCPFactory: self._set_mcp_tools(mcp_server, openai_tool_compatible=openai_tool_compatible) log.info("MCP server lifetime setup complete") yield + log.info("MCP server shutting down") def _get_initial_instructions(self) -> str: assert self.agent is not None From 0639dce17be03194b2d23218401f87f1b97d495b Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Fri, 6 Feb 2026 15:13:11 +0100 Subject: [PATCH 31/35] Fix assertion in test_cli_project_commands (fallback is now None, not cwd) --- test/serena/test_cli_project_commands.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/serena/test_cli_project_commands.py b/test/serena/test_cli_project_commands.py index 1052d403..be90a799 100644 --- a/test/serena/test_cli_project_commands.py +++ b/test/serena/test_cli_project_commands.py @@ -315,8 +315,8 @@ class TestFindProjectRoot: finally: os.chdir(original_cwd) - def test_falls_back_to_cwd_when_no_markers(self, temp_project_dir): - """Test falls back to CWD when no markers exist within boundary.""" + def test_falls_back_to_none_when_no_markers(self, temp_project_dir): + """Test falls back to None when no markers exist within boundary.""" subdir = os.path.join(temp_project_dir, "src") os.makedirs(subdir) @@ -324,7 +324,7 @@ class TestFindProjectRoot: try: os.chdir(subdir) result = find_project_root(root=temp_project_dir) - assert os.path.samefile(result, subdir) + assert result is None finally: os.chdir(original_cwd) From e588bc7f89162d982c57f66891f10cf62a8eb94c Mon Sep 17 00:00:00 2001 From: Michael Panchenko <35432522+MischaPanch@users.noreply.github.com> Date: Fri, 6 Feb 2026 21:25:43 +0700 Subject: [PATCH 32/35] C/C++ language server fixes (#987) Fixes longstanding clangd issues (wrong selectionRange, failure to take compile_commands.json into account) and enhances documentation about C/C++ support --- .github/workflows/pytest.yml | 6 +- docs/01-about/020_programming-languages.md | 1 + docs/03-special-guides/cpp_setup.md | 100 ++++++++++++++++ .../language_servers/ccls_language_server.py | 13 +- .../clangd_language_server.py | 111 ++++++++++++++++-- src/solidlsp/ls_config.py | 4 +- .../repos/cpp/test_repo/compile_commands.json | 6 +- test/solidlsp/cpp/test_cpp_basic.py | 78 ++++++++++-- 8 files changed, 281 insertions(+), 38 deletions(-) create mode 100644 docs/03-special-guides/cpp_setup.md diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index a3d98b44..42dce26b 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -121,13 +121,15 @@ jobs: sudo apt-get install -y ccls elif [[ "${{ runner.os }}" == "macOS" ]]; then brew install ccls + elif [[ "${{ runner.os }}" == "Windows" ]]; then + choco install ccls -y fi - # Windows: ccls requires building from source with MSYS2/LLVM, skipped in CI # Verify installation if command -v ccls &> /dev/null; then echo "ccls installed: $(ccls --version 2>&1 | head -1)" else - echo "ccls not available on this platform (expected on Windows)" + echo "ERROR: ccls installation failed" + exit 1 fi - name: Setup Java (for JVM based languages) uses: actions/setup-java@v4 diff --git a/docs/01-about/020_programming-languages.md b/docs/01-about/020_programming-languages.md index c1880c7d..18d303be 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -33,6 +33,7 @@ Some languages require additional installations or setup steps, as noted. * **C/C++** Default: clangd. Optional alternate: ccls (experimental, opt-in). For best results, provide a `compile_commands.json` at the repository root. + See the [C/C++ Setup Guide](../03-special-guides/cpp_setup) for details. * **Clojure** * **Dart** * **Elixir** diff --git a/docs/03-special-guides/cpp_setup.md b/docs/03-special-guides/cpp_setup.md new file mode 100644 index 00000000..916040d4 --- /dev/null +++ b/docs/03-special-guides/cpp_setup.md @@ -0,0 +1,100 @@ +# C/C++ Setup Guide + +This guide explains how to prepare a C/C++ project so that Serena can provide reliable code intelligence via clangd or ccls language servers. +This is only necessary if you use the language server variant of Serena, for users of the Serena JetBrains plugin no setup is required +and the limitations described below do not apply. + +--- + +## General + +Serena supports two C/C++ language servers, clangd (default) and ccls. +Both have their pros and cons and require a properly configured `compile_commands.json` +for cross-file reference finding, see below for details. + +Your project must have a `compile_commands.json` file at the repository root. +This file is essential for correct parsing and cross-file reference finding. + + +## compile_commands.json Requirements + +For reliable cross-file reference finding with clangd, your `compile_commands.json` must: + +1. **Include proper C++ standard flags** (e.g., `-std=c++17`) +2. **Include all necessary include paths** (`-I` flags) + +--- + +### With clangd + +Serena automatically downloads and manages clangd. Since clangd does not properly work with relative paths in `compile_commands.json`, +Serena will detect them and transform them into absolute paths automatically (writing a new `compile_commands.json` file), if needed. + +#### Customizing the Compilation Database Location + +By default, Serena creates the transformed compilation database at `.serena/compile_commands.json`. +You can customize this location via project settings: + +```yaml +# .serena/project.yml +language_servers: + cpp: + compile_commands_dir: custom/rel/path (defaults to .serena) +``` + +### With ccls + +ccls requires manual installation and configuration. It may perform better in some situations. + +#### Installation + +**Linux:** +```bash +# Ubuntu/Debian (22.04+) +sudo apt-get install ccls + +# Fedora/RHEL +sudo dnf install ccls + +# Arch Linux +sudo pacman -S ccls +``` + +**macOS:** +```bash +brew install ccls +``` + +**Windows:** + +```bash +choco install ccls +``` + +#### Configuration + +After installing ccls, configure Serena to use it via project settings (in `.serena/project.yml`) +by adding `cpp_ccls` to the `languages` list. Replace `cpp` with `cpp_ccls` if you already have the `cpp` entry. + +ccls can handle relative paths in `compile_commands.json`, so no transformation is necessary +and no transformed `compile_commands.json` file will be created. + +--- + +## Known Limitations + +### Files Created After Server Initialization + +Both clangd and ccls have a fundamental limitation: +**files created by external mechanisms after the language server starts are not automatically indexed**. + +Cross-file references to newly created files will not work unless the new file is at some point opened by the language server (for example, by a symbol lookup in it), or until `compile_commands.json` is updated and +the language server is restarted. + +--- + +## Reference + +- Clangd official documentation: https://clangd.llvm.org/ +- Clangd project setup: https://clangd.llvm.org/installation#project-setup +- CCLS repository: https://github.com/MaskRay/ccls diff --git a/src/solidlsp/language_servers/ccls_language_server.py b/src/solidlsp/language_servers/ccls_language_server.py index 366a6f7d..f8a0db45 100644 --- a/src/solidlsp/language_servers/ccls_language_server.py +++ b/src/solidlsp/language_servers/ccls_language_server.py @@ -1,12 +1,8 @@ """ -Provides C/C++ specific instantiation of the LanguageServer class using ccls. - This is an alternative to clangd for large C++ codebases where ccls may perform better for indexing and navigation. Requires ccls to be installed and available on PATH, or configured via ls_specific_settings with key "ls_path". -For best results, ensure a compile_commands.json exists at the repository root. - Installation ------------ ccls must be installed manually as there are no prebuilt binaries available for @@ -23,10 +19,9 @@ direct download. Install using your system package manager: - Homebrew: ``brew install ccls`` **Windows:** -- MSYS2 (MinGW): Build from source using MSYS2 toolchain -- No native prebuilt binaries available; must build from source +- Chocolatey: ``choco install ccls`` -For build-from-source instructions (required on Windows), see: +For alternative installation methods and build-from-source instructions, see: https://github.com/MaskRay/ccls/wiki/Build Official documentation: @@ -51,7 +46,7 @@ from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) -class CclsLanguageServer(SolidLanguageServer): +class CCLS(SolidLanguageServer): """ C/C++ language server implementation using ccls. @@ -89,7 +84,7 @@ class CclsLanguageServer(SolidLanguageServer): " Linux (Fedora/RHEL): sudo dnf install ccls\n" " Linux (Arch): sudo pacman -S ccls\n" " macOS (Homebrew): brew install ccls\n" - " Windows: Build from source (see wiki)\n\n" + " Windows: choco install ccls\n\n" "For build instructions and more details, see:\n" " https://github.com/MaskRay/ccls/wiki/Build" ) diff --git a/src/solidlsp/language_servers/clangd_language_server.py b/src/solidlsp/language_servers/clangd_language_server.py index c99a2ae2..8919d746 100644 --- a/src/solidlsp/language_servers/clangd_language_server.py +++ b/src/solidlsp/language_servers/clangd_language_server.py @@ -1,14 +1,11 @@ -""" -Provides C/C++ specific instantiation of the LanguageServer class. Contains various configurations and settings specific to C/C++. -""" - +import json import logging import os import pathlib import threading from typing import Any, cast -from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer +from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, ProcessLaunchInfo, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings @@ -35,6 +32,89 @@ class ClangdLanguageServer(SolidLanguageServer): self.initialize_searcher_command_available = threading.Event() self.resolve_main_method_available = threading.Event() + def _prepare_compile_commands(self) -> str | None: + """ + Prepare clangd compilation database with absolute directory paths. + + Clangd requires absolute directory paths in compile_commands.json for correct + cross-file reference finding. This method reads the compile_commands.json, + converts relative directory paths to absolute paths, and writes a transformed + compilation database to the serena managed directory. + + The transformed file is persisted in .serena/serena_compile_commands.json + (or a configurable directory via ls_specific_settings) and is not deleted + on cleanup. This allows clangd to use the absolute-path version without + modifying the user's original compile_commands.json. + + Returns the path to the serena directory containing the transformed database, + or None if no transformation was needed. + """ + compile_db_path = os.path.join(self.repository_root_path, "compile_commands.json") + + if not os.path.exists(compile_db_path): + # No compile_commands.json, nothing to do + return None + + try: + with open(compile_db_path, encoding="utf-8") as f: + compile_commands = json.load(f) + + if not compile_commands: + return None + + # Check if any entries have relative directory paths + has_relative = False + for entry in compile_commands: + directory = entry.get("directory", "") + if directory and not os.path.isabs(directory): + has_relative = True + # Convert to absolute path + entry["directory"] = os.path.abspath(os.path.join(self.repository_root_path, directory)) + + if not has_relative: + # No relative paths found, no need to create transformed database + return None + + # Get the target directory from ls_specific_settings, default to .serena + cpp_settings: dict[str, Any] = self._custom_settings or {} + compile_commands_rel_dir = cpp_settings.get("compile_commands_dir", ".serena") + compile_commands_dir = os.path.join(self.repository_root_path, compile_commands_rel_dir) + os.makedirs(compile_commands_dir, exist_ok=True) + + # Write the transformed compile_commands.json + # clangd looks for compile_commands.json in the --compile-commands-dir + compile_commands_path = os.path.join(compile_commands_dir, "compile_commands.json") + with open(compile_commands_path, "w", encoding="utf-8") as f: + json.dump(compile_commands, f, indent=2) + + # Track the directory for --compile-commands-dir + + log.info(f"Created serena compilation database with absolute paths at {compile_commands_path}") + return compile_commands_dir + + except (OSError, json.JSONDecodeError) as e: + log.warning(f"Failed to prepare compile_commands.json: {e}") + return None + + def _create_process_launch_info(self) -> ProcessLaunchInfo: + """ + Override to add --compile-commands-dir argument if we created a serena compilation database. + """ + # First, ensure the serena compile commands database is prepared + compile_commands_dir = self._prepare_compile_commands() + + # Get the default launch info from parent + launch_info = super()._create_process_launch_info() + + # If we created a serena compilation database, add --compile-commands-dir to the command + if compile_commands_dir: + # Insert --compile-commands-dir after the executable path + cmd = launch_info.cmd + assert isinstance(cmd, list) + launch_info.cmd = [cmd[0], f"--compile-commands-dir={compile_commands_dir}"] + cmd[1:] + + return launch_info + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) @@ -116,8 +196,10 @@ class ClangdLanguageServer(SolidLanguageServer): os.chmod(clangd_executable_path, 0o755) return clangd_executable_path - def _create_launch_command(self, core_path: str) -> list[str] | str: - return [core_path] + def _create_launch_command(self, core_path: str) -> list[str]: + # --background-index enables clangd to index all files in the project, + # which is required for finding cross-file references + return [core_path, "--background-index"] @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: @@ -132,6 +214,11 @@ class ClangdLanguageServer(SolidLanguageServer): "synchronization": {"didSave": True, "dynamicRegistration": True}, "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + }, }, "workspace": {"workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}}, }, @@ -160,6 +247,7 @@ class ClangdLanguageServer(SolidLanguageServer): await lsp.request_references(...) # Shutdown the LanguageServer on exit from scope # LanguageServer has been shutdown + ``` """ def register_capability_handler(params: Any) -> None: @@ -213,10 +301,15 @@ class ClangdLanguageServer(SolidLanguageServer): } self.server.notify.initialized({}) - - # set ready flag + # set ready flag, clangd sends no meaningful notification when ready # TODO This defeats the purpose of the event; we should wait for the server to actually be ready self.server_ready.set() # wait for server to be ready self.server_ready.wait() + + def _shutdown(self, timeout: float = 5.0) -> None: + """Shutdown the clangd language server.""" + # The serena compilation database persists in .serena/ for reuse + # Call parent shutdown + super()._shutdown(timeout=timeout) diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 938d6ebc..00d7c41b 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -307,9 +307,9 @@ class Language(str, Enum): return ClangdLanguageServer case self.CPP_CCLS: - from solidlsp.language_servers.ccls_language_server import CclsLanguageServer + from solidlsp.language_servers.ccls_language_server import CCLS - return CclsLanguageServer + return CCLS case self.PHP: from solidlsp.language_servers.intelephense import Intelephense diff --git a/test/resources/repos/cpp/test_repo/compile_commands.json b/test/resources/repos/cpp/test_repo/compile_commands.json index c385fb3a..21c02f62 100644 --- a/test/resources/repos/cpp/test_repo/compile_commands.json +++ b/test/resources/repos/cpp/test_repo/compile_commands.json @@ -1,12 +1,12 @@ [ { "directory": ".", - "command": "g++ -I . -c a.cpp", + "command": "g++ -std=c++17 -I . -c a.cpp", "file": "a.cpp" }, { "directory": ".", - "command": "g++ -I . -c b.cpp", + "command": "g++ -std=c++17 -I . -c b.cpp", "file": "b.cpp" } -] +] \ No newline at end of file diff --git a/test/solidlsp/cpp/test_cpp_basic.py b/test/solidlsp/cpp/test_cpp_basic.py index 84d0eaa8..715ef6c9 100644 --- a/test/solidlsp/cpp/test_cpp_basic.py +++ b/test/solidlsp/cpp/test_cpp_basic.py @@ -7,8 +7,8 @@ server is not available. """ import os +import pathlib import shutil -from typing import cast import pytest @@ -17,18 +17,11 @@ from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils -def _clangd_available() -> bool: - return shutil.which("clangd") is not None - - def _ccls_available() -> bool: return shutil.which("ccls") is not None -# Build parametrize list based on availability -_cpp_servers: list[Language] = [] -if _clangd_available(): - _cpp_servers.append(Language.CPP) +_cpp_servers: list[Language] = [Language.CPP] if _ccls_available(): _cpp_servers.append(Language.CPP_CCLS) @@ -70,9 +63,9 @@ class TestCppLanguageServer: assert add_symbol is not None, "Could not find 'add' function symbol in b.cpp" sel_start = add_symbol["selectionRange"]["start"] - refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"] + 1) - ref_files = cast(list[str], [ref.get("relativePath", "") for ref in refs]) - assert any("a.cpp" in ref_file for ref_file in ref_files), "Should find reference in a.cpp" + refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + ref_files = [ref.get("relativePath", "") for ref in refs] + assert any("a.cpp" in ref_file for ref_file in ref_files), f"Should find reference in a.cpp, {refs=}" # Verify second call returns same results (stability check) def _ref_key(ref: dict) -> tuple: @@ -88,5 +81,64 @@ class TestCppLanguageServer: e.get("character", -1), ) - refs2 = language_server.request_references(file_path, sel_start["line"], sel_start["character"] + 1) + refs2 = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) assert sorted(map(_ref_key, refs2)) == sorted(map(_ref_key, refs)), "Reference results should be stable across calls" + + @pytest.mark.parametrize("language_server", _cpp_servers, indirect=True) + @pytest.mark.xfail( + strict=True, + reason=("Both clangd and ccls do not support cross-file references for newly created files that were never opened by the LS."), + ) + def test_find_references_in_newly_written_file(self, language_server: SolidLanguageServer) -> None: + # Create a new file that references the 'add' function from b.cpp + new_file_path = os.path.join("temp_new_file.cpp") + new_file_abs_path = os.path.join(language_server.repository_root_path, new_file_path) + + try: + # Write the new file with a reference to add() + with open(new_file_abs_path, "w", encoding="utf-8") as f: + f.write( + """ +#include "b.hpp" + +int use_add() { + int result = add(5, 3); + return result; +} +""" + ) + + # Open the new file so clangd knows about it + with language_server.open_file(new_file_path): + # Request document symbols to ensure the file is fully loaded by clangd + new_file_symbols = language_server.request_document_symbols(new_file_path).get_all_symbols_and_roots() + assert new_file_symbols, "New file should have symbols" + + # Verify the file stays in open_file_buffers after the context exits + uri = pathlib.Path(new_file_abs_path).as_uri() + assert uri in language_server.open_file_buffers, "File should remain in open_file_buffers" + + # Find the 'add' symbol in b.cpp + b_file_path = os.path.join("b.cpp") + symbols = language_server.request_document_symbols(b_file_path).get_all_symbols_and_roots() + symbol_list = symbols[0] if symbols and isinstance(symbols[0], list) else symbols + add_symbol = None + for sym in symbol_list: + if sym.get("name") == "add": + add_symbol = sym + break + assert add_symbol is not None, "Could not find 'add' function symbol in b.cpp" + + # Request references for 'add' + sel_start = add_symbol["selectionRange"]["start"] + refs = language_server.request_references(b_file_path, sel_start["line"], sel_start["character"]) + ref_files = [ref.get("relativePath", "") for ref in refs] + + # Should find reference in the newly written file + assert any( + "temp_new_file.cpp" in ref_file for ref_file in ref_files + ), f"Should find reference in newly written temp_new_file.cpp, {ref_files=}" + finally: + # Clean up the new file + if os.path.exists(new_file_abs_path): + os.remove(new_file_abs_path) From 382e184952fffa34cecdc5c2528b520548d0f6bf Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Fri, 6 Feb 2026 16:16:45 +0100 Subject: [PATCH 33/35] Fix waiting for cross-file references - has to happen after a file was opened in ls --- src/solidlsp/ls.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index d63f5b30..64a237a6 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -911,13 +911,13 @@ class SolidLanguageServer(ABC): log.error("request_references called before Language Server started") raise SolidLSPException("Language Server not started") - if not self._has_waited_for_cross_file_references: - # Some LS require waiting for a while before they can return cross-file references. - # This is a workaround for such LS that don't have a reliable "finished initializing" signal. - sleep(self._get_wait_time_for_cross_file_referencing()) - self._has_waited_for_cross_file_references = True - with self.open_file(relative_file_path): + if not self._has_waited_for_cross_file_references: + # Some LS require waiting for a while before they can return cross-file references. + # This is a workaround for such LS that don't have a reliable "finished initializing" signal. + # The waiting has to happen after at least one file was opened in the ls + sleep(self._get_wait_time_for_cross_file_referencing()) + self._has_waited_for_cross_file_references = True try: response = self._send_references_request(relative_file_path, line=line, column=column) except Exception as e: From 501f76eb1333b85bd7bd98a4be0b87946aea82f6 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Fri, 6 Feb 2026 16:25:20 +0100 Subject: [PATCH 34/35] Removed obsolete override of _shutdown method in clangd_language_server.py --- src/solidlsp/language_servers/clangd_language_server.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/solidlsp/language_servers/clangd_language_server.py b/src/solidlsp/language_servers/clangd_language_server.py index 8919d746..f0929f79 100644 --- a/src/solidlsp/language_servers/clangd_language_server.py +++ b/src/solidlsp/language_servers/clangd_language_server.py @@ -307,9 +307,3 @@ class ClangdLanguageServer(SolidLanguageServer): # wait for server to be ready self.server_ready.wait() - - def _shutdown(self, timeout: float = 5.0) -> None: - """Shutdown the clangd language server.""" - # The serena compilation database persists in .serena/ for reuse - # Call parent shutdown - super()._shutdown(timeout=timeout) From fafebdc2057dd8fa3e9ca9f3c7c5d4821451fb8e Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Fri, 6 Feb 2026 23:21:22 +0100 Subject: [PATCH 35/35] Bump wait_time_for_cross_file_referencing for TS --- src/solidlsp/language_servers/typescript_language_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/solidlsp/language_servers/typescript_language_server.py b/src/solidlsp/language_servers/typescript_language_server.py index 61812520..01ec205e 100644 --- a/src/solidlsp/language_servers/typescript_language_server.py +++ b/src/solidlsp/language_servers/typescript_language_server.py @@ -304,7 +304,7 @@ class TypeScriptLanguageServer(SolidLanguageServer): @override def _get_wait_time_for_cross_file_referencing(self) -> float: - return 1 + return 2 @override def _get_preferred_definition(self, definitions: list[ls_types.Location]) -> ls_types.Location: