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 001/105] 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 002/105] 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 003/105] 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 004/105] 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 005/105] 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 006/105] 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 007/105] 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 008/105] 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 009/105] 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 010/105] 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 011/105] 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 012/105] 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 013/105] 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 014/105] 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 015/105] 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 016/105] 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 017/105] 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 018/105] 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 019/105] 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 020/105] 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 021/105] 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 022/105] 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 023/105] 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 024/105] 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 025/105] 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 026/105] 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 027/105] 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 028/105] 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 029/105] 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 030/105] 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 031/105] 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 032/105] 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 033/105] 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 034/105] 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: From 43dca464d6a8bb8716dc7e534ebabb750f523c47 Mon Sep 17 00:00:00 2001 From: takeokunn Date: Sat, 7 Feb 2026 19:31:07 +0900 Subject: [PATCH 035/105] feat(config): add system-global ignored_paths support - Introduce ignored_paths to SerenaConfig for global ignore patterns - Merge global and project ignored_paths additively in Project logic - Update CLI and config loading to thread global_ignored_paths correctly - Add tests for global ignored_paths, additive merge, glob patterns, and compatibility - Update config templates and docstrings for new global ignored_paths option --- src/serena/cli.py | 11 +- src/serena/config/serena_config.py | 39 ++- src/serena/project.py | 33 ++- src/serena/resources/project.template.yml | 5 +- .../resources/serena_config.template.yml | 5 + .../config/test_global_ignored_paths.py | 256 ++++++++++++++++++ 6 files changed, 328 insertions(+), 21 deletions(-) create mode 100644 test/serena/config/test_global_ignored_paths.py diff --git a/src/serena/cli.py b/src/serena/cli.py index 73aeb6c9..3a1b968e 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -567,7 +567,7 @@ class ProjectCommands(AutoRegisteringGroup): serena_config = SerenaConfig.from_config_file() registered_project = serena_config.get_registered_project(str(project_root)) if registered_project is None: - registered_project = RegisteredProject(str(project_root), generated_conf) + registered_project = RegisteredProject(str(project_root), generated_conf, global_ignored_paths=serena_config.ignored_paths) serena_config.add_registered_project(registered_project) return registered_project @@ -690,7 +690,8 @@ class ProjectCommands(AutoRegisteringGroup): :param path: The path to check. :param project: The path to the project directory, defaults to the current working directory. """ - proj = Project.load(os.path.abspath(project)) + serena_config = SerenaConfig.from_config_file() + proj = Project.load(os.path.abspath(project), global_ignored_paths=serena_config.ignored_paths) if os.path.isabs(path): path = os.path.relpath(path, start=proj.project_root) is_ignored = proj.is_ignored_path(path) @@ -712,7 +713,8 @@ class ProjectCommands(AutoRegisteringGroup): :param project: path to the project directory, defaults to the current working directory. :param verbose: if set, prints detailed information about the indexed symbols. """ - proj = Project.load(os.path.abspath(project)) + serena_config = SerenaConfig.from_config_file() + proj = Project.load(os.path.abspath(project), global_ignored_paths=serena_config.ignored_paths) if os.path.isabs(file): file = os.path.relpath(file, start=proj.project_root) if proj.is_ignored_path(file, ignore_non_source_files=True): @@ -749,7 +751,8 @@ class ProjectCommands(AutoRegisteringGroup): # NOTE: completely written by Claude Code, only functionality was reviewed, not implementation logging.configure(level=logging.INFO) project_path = os.path.abspath(project) - proj = Project.load(project_path) + serena_config = SerenaConfig.from_config_file() + proj = Project.load(project_path, global_ignored_paths=serena_config.ignored_paths) # Create log file with timestamp timestamp = datetime_tag() diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 622fbf8b..ecfb3a2b 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -397,16 +397,25 @@ class ProjectConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMi class RegisteredProject(ToStringMixin): - def __init__(self, project_root: str, project_config: "ProjectConfig", project_instance: Optional["Project"] = None) -> None: + def __init__( + self, + project_root: str, + project_config: "ProjectConfig", + project_instance: Optional["Project"] = None, + global_ignored_paths: Sequence[str] = (), + ) -> None: """ Represents a registered project in the Serena configuration. :param project_root: the root directory of the project :param project_config: the configuration of the project + :param project_instance: an existing project instance (if already loaded) + :param global_ignored_paths: global ignored paths from SerenaConfig, merged additively with project-level patterns """ self.project_root = Path(project_root).resolve() self.project_config = project_config self._project_instance = project_instance + self._global_ignored_paths = global_ignored_paths def _tostring_exclude_private(self) -> bool: return True @@ -416,19 +425,21 @@ class RegisteredProject(ToStringMixin): return self.project_config.project_name @classmethod - def from_project_instance(cls, project_instance: "Project") -> "RegisteredProject": + def from_project_instance(cls, project_instance: "Project", global_ignored_paths: Sequence[str] = ()) -> "RegisteredProject": return RegisteredProject( project_root=project_instance.project_root, project_config=project_instance.project_config, project_instance=project_instance, + global_ignored_paths=global_ignored_paths, ) @classmethod - def from_project_root(cls, project_root: str | Path) -> "RegisteredProject": + def from_project_root(cls, project_root: str | Path, global_ignored_paths: Sequence[str] = ()) -> "RegisteredProject": project_config = ProjectConfig.load(project_root) return RegisteredProject( project_root=str(project_root), project_config=project_config, + global_ignored_paths=global_ignored_paths, ) def matches_root_path(self, path: str | Path) -> bool: @@ -448,7 +459,11 @@ class RegisteredProject(ToStringMixin): from ..project import Project with LogTime(f"Loading project instance for {self}", logger=log): - self._project_instance = Project(project_root=str(self.project_root), project_config=self.project_config) + self._project_instance = Project( + project_root=str(self.project_root), + project_config=self.project_config, + global_ignored_paths=self._global_ignored_paths, + ) return self._project_instance @@ -493,6 +508,10 @@ class SerenaConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMix ls_specific_settings: dict = field(default_factory=dict) """Advanced configuration option allowing to configure language server implementation specific options, see SolidLSPSettings for more info.""" + ignored_paths: list[str] = field(default_factory=list) + """List of paths to ignore across all projects. Same syntax as gitignore, so you can use * and **. + These patterns are merged additively with each project's own ignored_paths.""" + # settings with overridden defaults default_modes: Sequence[str] | None = ("interactive", "editing") @@ -610,6 +629,7 @@ class SerenaConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMix project = RegisteredProject( project_root=str(path), project_config=project_config, + global_ignored_paths=instance.ignored_paths, ) instance.projects.append(project) @@ -700,7 +720,7 @@ class SerenaConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMix if autoregister: config_path = ProjectConfig.path_to_project_yml(project_root_or_name) if os.path.isfile(config_path): - registered_project = RegisteredProject.from_project_root(project_root_or_name) + registered_project = RegisteredProject.from_project_root(project_root_or_name, global_ignored_paths=self.ignored_paths) self.add_registered_project(registered_project) return registered_project # nothing found @@ -745,8 +765,13 @@ class SerenaConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMix project_config = ProjectConfig.load(project_root, autogenerate=True) - new_project = Project(project_root=str(project_root), project_config=project_config, is_newly_created=True) - self.add_registered_project(RegisteredProject.from_project_instance(new_project)) + new_project = Project( + project_root=str(project_root), + project_config=project_config, + is_newly_created=True, + global_ignored_paths=self.ignored_paths, + ) + self.add_registered_project(RegisteredProject.from_project_instance(new_project, global_ignored_paths=self.ignored_paths)) return new_project diff --git a/src/serena/project.py b/src/serena/project.py index ea3674ad..ae7bcc0f 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -2,6 +2,7 @@ import json import logging import os import threading +from collections.abc import Sequence from pathlib import Path from typing import Any @@ -56,7 +57,13 @@ class MemoriesManager: class Project(ToStringMixin): - def __init__(self, project_root: str, project_config: ProjectConfig, is_newly_created: bool = False): + def __init__( + self, + project_root: str, + project_config: ProjectConfig, + is_newly_created: bool = False, + global_ignored_paths: Sequence[str] = (), + ): self.project_root = project_root self.project_config = project_config self.memories_manager = MemoriesManager(project_root) @@ -72,6 +79,7 @@ class Project(ToStringMixin): f.write(f"/{SolidLanguageServer.CACHE_FOLDER_NAME}\n") # prepare ignore spec asynchronously, ensuring immediate project activation. + self._global_ignored_paths = global_ignored_paths self.__ignored_patterns: list[str] self.__ignore_spec: pathspec.PathSpec self._ignore_spec_available = threading.Event() @@ -80,11 +88,15 @@ class Project(ToStringMixin): 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}") + # gather ignored paths from the global configuration, project configuration, and gitignore files + ignored_patterns = list(self._global_ignored_paths) + list(self.project_config.ignored_paths) + if len(self._global_ignored_paths) > 0: + log.info(f"Using {len(self._global_ignored_paths)} ignored paths from the global configuration.") + log.debug(f"Global ignored paths: {list(self._global_ignored_paths)}") + if len(self.project_config.ignored_paths) > 0: + log.info(f"Using {len(self.project_config.ignored_paths)} ignored paths from the project configuration.") + log.debug(f"Project ignored paths: {self.project_config.ignored_paths}") + log.debug(f"Combined ignored patterns: {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(): @@ -115,12 +127,17 @@ class Project(ToStringMixin): return self.project_config.project_name @classmethod - def load(cls, project_root: str | Path, autogenerate: bool = True) -> "Project": + def load( + cls, + project_root: str | Path, + autogenerate: bool = True, + global_ignored_paths: Sequence[str] = (), + ) -> "Project": project_root = Path(project_root).resolve() if not project_root.exists(): raise FileNotFoundError(f"Project root not found: {project_root}") project_config = ProjectConfig.load(project_root, autogenerate=autogenerate) - return Project(project_root=str(project_root), project_config=project_config) + return Project(project_root=str(project_root), project_config=project_config, global_ignored_paths=global_ignored_paths) def save_config(self) -> None: """ diff --git a/src/serena/resources/project.template.yml b/src/serena/resources/project.template.yml index 9472ba95..c314bc82 100644 --- a/src/serena/resources/project.template.yml +++ b/src/serena/resources/project.template.yml @@ -34,8 +34,9 @@ encoding: "utf-8" # whether to use project's .gitignore files to ignore files ignore_all_files_in_gitignore: true -# list of additional paths to ignore in all projects -# same syntax as gitignore, so you can use * and ** +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Note: global ignored_paths from serena_config.yml are also applied additively. ignored_paths: [] # whether the project is in read-only mode diff --git a/src/serena/resources/serena_config.template.yml b/src/serena/resources/serena_config.template.yml index 7a660e64..ac11088e 100644 --- a/src/serena/resources/serena_config.template.yml +++ b/src/serena/resources/serena_config.template.yml @@ -63,6 +63,11 @@ trace_lsp_communication: False # No documentation on options means no options are available. ls_specific_settings: {} +# list of paths to ignore across all projects. +# Same syntax as gitignore, so you can use * and **. +# These patterns are merged additively with each project's own ignored_paths. +ignored_paths: [] + # timeout, in seconds, after which tool executions are terminated tool_timeout: 240 diff --git a/test/serena/config/test_global_ignored_paths.py b/test/serena/config/test_global_ignored_paths.py new file mode 100644 index 00000000..f699915d --- /dev/null +++ b/test/serena/config/test_global_ignored_paths.py @@ -0,0 +1,256 @@ +import os +import shutil +import tempfile +from pathlib import Path + +from serena.config.serena_config import ProjectConfig, RegisteredProject, SerenaConfig +from serena.project import Project +from solidlsp.ls_config import Language + + +def _create_test_project( + project_root: Path, + project_ignored_paths: list[str] | None = None, + global_ignored_paths: list[str] | None = None, +) -> Project: + """Helper to create a Project with the given ignored paths configuration.""" + config = ProjectConfig( + project_name="test_project", + languages=[Language.PYTHON], + ignored_paths=project_ignored_paths or [], + ignore_all_files_in_gitignore=False, + ) + return Project( + project_root=str(project_root), + project_config=config, + global_ignored_paths=global_ignored_paths or [], + ) + + +class TestGlobalIgnoredPaths: + """Tests for system-global ignored_paths feature.""" + + def setup_method(self) -> None: + self.test_dir = tempfile.mkdtemp() + self.project_path = Path(self.test_dir) + # Create some test files and directories + (self.project_path / "main.py").write_text("print('hello')") + os.makedirs(self.project_path / "node_modules" / "pkg", exist_ok=True) + (self.project_path / "node_modules" / "pkg" / "index.js").write_text("module.exports = {}") + os.makedirs(self.project_path / "build", exist_ok=True) + (self.project_path / "build" / "output.js").write_text("compiled") + os.makedirs(self.project_path / "src", exist_ok=True) + (self.project_path / "src" / "app.py").write_text("def app(): pass") + (self.project_path / "debug.log").write_text("log data") + + def teardown_method(self) -> None: + shutil.rmtree(self.test_dir) + + def test_global_ignored_paths_are_applied(self) -> None: + """Global ignored_paths from SerenaConfig are respected by Project.is_ignored_path().""" + project = _create_test_project( + self.project_path, + global_ignored_paths=["node_modules"], + ) + assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg" / "index.js")) + assert not project.is_ignored_path(str(self.project_path / "src" / "app.py")) + + def test_additive_merge_of_global_and_project_patterns(self) -> None: + """Global + project patterns are merged additively (both applied).""" + project = _create_test_project( + self.project_path, + project_ignored_paths=["build"], + global_ignored_paths=["node_modules"], + ) + # Global pattern should be applied + assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg" / "index.js")) + # Project pattern should also be applied + assert project.is_ignored_path(str(self.project_path / "build" / "output.js")) + # Non-ignored files should not be affected + assert not project.is_ignored_path(str(self.project_path / "src" / "app.py")) + + def test_empty_global_ignored_paths_has_no_effect(self) -> None: + """Empty global ignored_paths (default) has no effect on existing behavior.""" + project = _create_test_project( + self.project_path, + project_ignored_paths=["build"], + global_ignored_paths=[], + ) + # Project pattern still works + assert project.is_ignored_path(str(self.project_path / "build" / "output.js")) + # Non-ignored files still accessible + assert not project.is_ignored_path(str(self.project_path / "node_modules" / "pkg" / "index.js")) + + def test_default_global_ignored_paths_backward_compatible(self) -> None: + """Project created without global_ignored_paths parameter works as before.""" + config = ProjectConfig( + project_name="test_project", + languages=[Language.PYTHON], + ignored_paths=["build"], + ignore_all_files_in_gitignore=False, + ) + project = Project( + project_root=str(self.project_path), + project_config=config, + ) + assert project.is_ignored_path(str(self.project_path / "build" / "output.js")) + assert not project.is_ignored_path(str(self.project_path / "node_modules" / "pkg" / "index.js")) + + def test_duplicate_patterns_across_global_and_project(self) -> None: + """Duplicate patterns across global and project do not cause errors.""" + project = _create_test_project( + self.project_path, + project_ignored_paths=["node_modules", "build"], + global_ignored_paths=["node_modules", "build"], + ) + assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg" / "index.js")) + assert project.is_ignored_path(str(self.project_path / "build" / "output.js")) + assert not project.is_ignored_path(str(self.project_path / "src" / "app.py")) + + def test_glob_patterns_in_global_ignored_paths(self) -> None: + """Global ignored_paths support gitignore-style glob patterns.""" + project = _create_test_project( + self.project_path, + global_ignored_paths=["*.log"], + ) + assert project.is_ignored_path(str(self.project_path / "debug.log")) + assert not project.is_ignored_path(str(self.project_path / "main.py")) + + +class TestRegisteredProjectGlobalIgnoredPaths: + """RegisteredProject.get_project_instance() correctly passes global patterns to Project.""" + + def setup_method(self) -> None: + self.test_dir = tempfile.mkdtemp() + self.project_path = Path(self.test_dir).resolve() + (self.project_path / "main.py").write_text("print('hello')") + os.makedirs(self.project_path / "node_modules", exist_ok=True) + (self.project_path / "node_modules" / "pkg.js").write_text("module") + + def teardown_method(self) -> None: + shutil.rmtree(self.test_dir) + + def test_get_project_instance_passes_global_ignored_paths(self) -> None: + """RegisteredProject.get_project_instance() passes global_ignored_paths to Project.""" + config = ProjectConfig( + project_name="test_project", + languages=[Language.PYTHON], + ignored_paths=[], + ignore_all_files_in_gitignore=False, + ) + registered = RegisteredProject( + project_root=str(self.project_path), + project_config=config, + global_ignored_paths=["node_modules"], + ) + project = registered.get_project_instance() + assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) + + def test_get_project_instance_without_global_ignored_paths(self) -> None: + """RegisteredProject without global_ignored_paths defaults to empty.""" + config = ProjectConfig( + project_name="test_project", + languages=[Language.PYTHON], + ignored_paths=[], + ignore_all_files_in_gitignore=False, + ) + registered = RegisteredProject( + project_root=str(self.project_path), + project_config=config, + ) + project = registered.get_project_instance() + assert not project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) + + def test_from_project_root_passes_global_ignored_paths(self) -> None: + """RegisteredProject.from_project_root() threads global_ignored_paths to Project.""" + # Create a minimal project.yml so from_project_root can load config + serena_dir = self.project_path / ".serena" + serena_dir.mkdir(exist_ok=True) + (serena_dir / "project.yml").write_text( + 'project_name: "test_project"\nlanguages: ["python"]\nignored_paths: []\nignore_all_files_in_gitignore: false\n' + ) + registered = RegisteredProject.from_project_root( + str(self.project_path), + global_ignored_paths=["node_modules"], + ) + project = registered.get_project_instance() + assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) + + def test_from_project_instance_passes_global_ignored_paths(self) -> None: + """RegisteredProject.from_project_instance() threads global_ignored_paths to Project.""" + config = ProjectConfig( + project_name="test_project", + languages=[Language.PYTHON], + ignored_paths=[], + ignore_all_files_in_gitignore=False, + ) + project = Project( + project_root=str(self.project_path), + project_config=config, + global_ignored_paths=["node_modules"], + ) + registered = RegisteredProject.from_project_instance(project, global_ignored_paths=["node_modules"]) + # The registered project already has a project_instance, so get_project_instance() returns it directly + retrieved = registered.get_project_instance() + assert retrieved.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) + + +class TestGlobalIgnoredPathsWithGitignore: + """Global ignored_paths combined with ignore_all_files_in_gitignore produces correct three-way merge.""" + + def setup_method(self) -> None: + self.test_dir = tempfile.mkdtemp() + self.project_path = Path(self.test_dir).resolve() + # Create test files + (self.project_path / "main.py").write_text("print('hello')") + os.makedirs(self.project_path / "node_modules", exist_ok=True) + (self.project_path / "node_modules" / "pkg.js").write_text("module") + os.makedirs(self.project_path / "dist", exist_ok=True) + (self.project_path / "dist" / "bundle.js").write_text("bundled") + os.makedirs(self.project_path / "build", exist_ok=True) + (self.project_path / "build" / "output.js").write_text("compiled") + # Create .gitignore that ignores dist/ + (self.project_path / ".gitignore").write_text("dist/\n") + + def teardown_method(self) -> None: + shutil.rmtree(self.test_dir) + + def test_three_way_merge_global_project_and_gitignore(self) -> None: + """Global patterns, project patterns, and .gitignore patterns are all applied together.""" + config = ProjectConfig( + project_name="test_project", + languages=[Language.PYTHON], + ignored_paths=["build"], + ignore_all_files_in_gitignore=True, + ) + project = Project( + project_root=str(self.project_path), + project_config=config, + global_ignored_paths=["node_modules"], + ) + # Global pattern: node_modules + assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) + # Project pattern: build + assert project.is_ignored_path(str(self.project_path / "build" / "output.js")) + # Gitignore pattern: dist/ + assert project.is_ignored_path(str(self.project_path / "dist" / "bundle.js")) + # Non-ignored file + assert not project.is_ignored_path(str(self.project_path / "main.py")) + + +class TestSerenaConfigIgnoredPaths: + """Config loading with ignored_paths in serena_config.yml works correctly.""" + + def test_serena_config_default_ignored_paths(self) -> None: + """SerenaConfig defaults to empty ignored_paths.""" + config = SerenaConfig(gui_log_window=False, web_dashboard=False) + assert config.ignored_paths == [] + + def test_serena_config_with_ignored_paths(self) -> None: + """SerenaConfig can be created with explicit ignored_paths.""" + config = SerenaConfig( + gui_log_window=False, + web_dashboard=False, + ignored_paths=["node_modules", "*.log", "build"], + ) + assert config.ignored_paths == ["node_modules", "*.log", "build"] From 5b18747ece11c1d79af0f23b7cf3dbc67b8e6c08 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 7 Feb 2026 12:49:44 +0100 Subject: [PATCH 036/105] Rename SolidLanguageServerHandler -> LanguageServerProcess --- src/solidlsp/ls.py | 6 +++--- src/solidlsp/ls_handler.py | 7 +++---- src/solidlsp/ls_request.py | 4 ++-- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 64a237a6..bfa60aa7 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -24,7 +24,7 @@ from serena.util.file_system import match_path from solidlsp import ls_types from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.ls_exceptions import SolidLSPException -from solidlsp.ls_handler import SolidLanguageServerHandler +from solidlsp.ls_handler import LanguageServerProcess from solidlsp.ls_types import UnifiedSymbolInformation from solidlsp.ls_utils import FileUtils, PathUtils, TextUtils from solidlsp.lsp_protocol_handler import lsp_types @@ -470,7 +470,7 @@ class SolidLanguageServer(ABC): self._dependency_provider = self._create_dependency_provider() process_launch_info = self._create_process_launch_info() log.debug(f"Creating language server instance with {language_id=} and process launch info: {process_launch_info}") - self.server = SolidLanguageServerHandler( + self.server = LanguageServerProcess( process_launch_info, language=self.language, determine_log_level=self._determine_log_level, @@ -2152,7 +2152,7 @@ class SolidLanguageServer(ABC): return self @property - def handler(self) -> SolidLanguageServerHandler: + def handler(self) -> LanguageServerProcess: """Access the underlying language server handler. Useful for advanced operations like sending custom commands diff --git a/src/solidlsp/ls_handler.py b/src/solidlsp/ls_handler.py index e8ddbcae..50bfc0e6 100644 --- a/src/solidlsp/ls_handler.py +++ b/src/solidlsp/ls_handler.py @@ -93,11 +93,10 @@ class Request(ToStringMixin): raise e -class SolidLanguageServerHandler: +class LanguageServerProcess: """ - This class provides the implementation of Python client for the Language Server Protocol. - A class that launches the language server and communicates with it - using the Language Server Protocol (LSP). + Represents a language server process and provides methods for communicating with it using the + Language Server Protocol (LSP). It provides methods for sending requests, responses, and notifications to the server and for registering handlers for requests and notifications from the server. diff --git a/src/solidlsp/ls_request.py b/src/solidlsp/ls_request.py index cacd2872..241c91a1 100644 --- a/src/solidlsp/ls_request.py +++ b/src/solidlsp/ls_request.py @@ -3,11 +3,11 @@ from typing import TYPE_CHECKING, Any, Union from solidlsp.lsp_protocol_handler import lsp_types if TYPE_CHECKING: - from .ls_handler import SolidLanguageServerHandler + from .ls_handler import LanguageServerProcess class LanguageServerRequest: - def __init__(self, handler: "SolidLanguageServerHandler"): + def __init__(self, handler: "LanguageServerProcess"): self.handler = handler def _send_request(self, method: str, params: Any | None = None) -> Any: From 60226fdee709ee37915fa6b8e995e65c10541a8e Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 7 Feb 2026 12:51:07 +0100 Subject: [PATCH 037/105] Rename module ls_handler -> ls_process --- src/solidlsp/ls.py | 2 +- src/solidlsp/ls_exceptions.py | 4 ++-- src/solidlsp/{ls_handler.py => ls_process.py} | 0 src/solidlsp/ls_request.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) rename src/solidlsp/{ls_handler.py => ls_process.py} (100%) diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index bfa60aa7..6286776f 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -24,7 +24,7 @@ from serena.util.file_system import match_path from solidlsp import ls_types from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.ls_exceptions import SolidLSPException -from solidlsp.ls_handler import LanguageServerProcess +from solidlsp.ls_process import LanguageServerProcess from solidlsp.ls_types import UnifiedSymbolInformation from solidlsp.ls_utils import FileUtils, PathUtils, TextUtils from solidlsp.lsp_protocol_handler import lsp_types diff --git a/src/solidlsp/ls_exceptions.py b/src/solidlsp/ls_exceptions.py index a7c7edb4..481d188e 100644 --- a/src/solidlsp/ls_exceptions.py +++ b/src/solidlsp/ls_exceptions.py @@ -24,7 +24,7 @@ class SolidLSPException(Exception): :return: True if the exception is caused by the language server having terminated as indicated by the causing exception being an instance of LanguageServerTerminatedException. """ - from .ls_handler import LanguageServerTerminatedException + from .ls_process import LanguageServerTerminatedException return isinstance(self.cause, LanguageServerTerminatedException) @@ -32,7 +32,7 @@ class SolidLSPException(Exception): """ :return: the affected language for the case where the exception is caused by the language server having terminated """ - from .ls_handler import LanguageServerTerminatedException + from .ls_process import LanguageServerTerminatedException if isinstance(self.cause, LanguageServerTerminatedException): return self.cause.language diff --git a/src/solidlsp/ls_handler.py b/src/solidlsp/ls_process.py similarity index 100% rename from src/solidlsp/ls_handler.py rename to src/solidlsp/ls_process.py diff --git a/src/solidlsp/ls_request.py b/src/solidlsp/ls_request.py index 241c91a1..2b2f6b8c 100644 --- a/src/solidlsp/ls_request.py +++ b/src/solidlsp/ls_request.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Any, Union from solidlsp.lsp_protocol_handler import lsp_types if TYPE_CHECKING: - from .ls_handler import LanguageServerProcess + from .ls_process import LanguageServerProcess class LanguageServerRequest: From 8515abec66510109ae86be202cf92fef4b0c6e05 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 12:07:29 +0000 Subject: [PATCH 038/105] Initial plan From 61891370e4dafc6fa0c774958563a3e2a04a071a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 12:09:52 +0000 Subject: [PATCH 039/105] Add installation date filtering to news display logic Co-authored-by: MischaPanch <35432522+MischaPanch@users.noreply.github.com> --- src/serena/dashboard.py | 32 +++++++ test/serena/test_dashboard.py | 156 ++++++++++++++++++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 test/serena/test_dashboard.py diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index fdc55656..df72dbda 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -2,6 +2,7 @@ import os import socket import threading from collections.abc import Callable +from datetime import datetime from pathlib import Path from typing import TYPE_CHECKING, Any, Self @@ -142,6 +143,31 @@ class SerenaDashboardAPI: def memory_log_handler(self) -> MemoryLogHandler: return self._memory_log_handler + @staticmethod + def _get_installation_date_id() -> int: + """ + Get the installation date ID (YYYYMMDD format) based on the creation time + of the SERENA_HOME directory. + + :return: Installation date as an integer in YYYYMMDD format + """ + serena_home = SerenaPaths().serena_user_home_dir + + # If SERENA_HOME doesn't exist yet, return today's date + if not os.path.exists(serena_home): + today = datetime.now() + return int(today.strftime("%Y%m%d")) + + # Get the creation/modification time of the SERENA_HOME directory + stat_info = os.stat(serena_home) + # Use st_ctime on Unix-like systems (change time, closest to creation time) + # or st_mtime if ctime is not reliable + creation_timestamp = stat_info.st_ctime + creation_date = datetime.fromtimestamp(creation_timestamp) + + # Return as YYYYMMDD integer + return int(creation_date.strftime("%Y%m%d")) + def _setup_routes(self) -> None: # Static files @self._app.route("/dashboard/") @@ -324,6 +350,12 @@ class SerenaDashboardAPI: def _get_unread_news_ids() -> list[int]: all_news_files = (Path(SERENA_DASHBOARD_DIR) / "news").glob("*.html") all_news_ids = [int(f.stem) for f in all_news_files] + + # Filter news items by installation date + installation_date_id = self._get_installation_date_id() + # Only include news items published on or after the installation date + all_news_ids = [news_id for news_id in all_news_ids if news_id >= installation_date_id] + news_snippet_id_file = SerenaPaths().news_snippet_id_file if not os.path.exists(news_snippet_id_file): return all_news_ids diff --git a/test/serena/test_dashboard.py b/test/serena/test_dashboard.py new file mode 100644 index 00000000..5637b3e4 --- /dev/null +++ b/test/serena/test_dashboard.py @@ -0,0 +1,156 @@ +""" +Tests for the dashboard API, specifically the news display logic. +""" + +import os +import tempfile +from datetime import datetime +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from serena.dashboard import SerenaDashboardAPI + + +class TestNewsDisplayLogic: + """Test the news display logic with installation date filtering.""" + + def test_get_installation_date_id_existing_dir(self): + """Test getting installation date from an existing directory.""" + with tempfile.TemporaryDirectory() as temp_dir: + # Get the installation date ID + with patch("serena.dashboard.SerenaPaths") as mock_paths: + mock_paths_instance = MagicMock() + mock_paths_instance.serena_user_home_dir = temp_dir + mock_paths.return_value = mock_paths_instance + + result = SerenaDashboardAPI._get_installation_date_id() + + # Should return a valid YYYYMMDD format integer + assert isinstance(result, int) + assert 20000101 <= result <= 99991231 # Valid date range + + # Should match today's or recent date (since we just created the directory) + result_str = str(result) + assert len(result_str) == 8 + year = int(result_str[0:4]) + month = int(result_str[4:6]) + day = int(result_str[6:8]) + assert 2020 <= year <= 2030 # Reasonable year range + assert 1 <= month <= 12 + assert 1 <= day <= 31 + + def test_get_installation_date_id_nonexistent_dir(self): + """Test getting installation date when directory doesn't exist yet.""" + with patch("serena.dashboard.SerenaPaths") as mock_paths: + mock_paths_instance = MagicMock() + mock_paths_instance.serena_user_home_dir = "/nonexistent/path/that/does/not/exist" + mock_paths.return_value = mock_paths_instance + + result = SerenaDashboardAPI._get_installation_date_id() + + # Should return today's date + today = datetime.now() + expected = int(today.strftime("%Y%m%d")) + assert result == expected + + def test_news_filtering_by_installation_date(self): + """Test that news items are filtered based on installation date.""" + with tempfile.TemporaryDirectory() as temp_dir: + # Create a mock news directory with some news files + news_dir = Path(temp_dir) / "news" + news_dir.mkdir() + + # Create news files with dates before and after a simulated installation date + old_news_file = news_dir / "20250101.html" + recent_news_file = news_dir / "20260201.html" + future_news_file = news_dir / "20260301.html" + + old_news_file.write_text("
Old news
") + recent_news_file.write_text("
Recent news
") + future_news_file.write_text("
Future news
") + + # Mock the installation date to be 2026-02-01 + with patch.object(SerenaDashboardAPI, "_get_installation_date_id", return_value=20260201): + # Create mocks for the API + mock_memory_log_handler = MagicMock() + mock_agent = MagicMock() + mock_tool_usage_stats = MagicMock() + + api = SerenaDashboardAPI( + memory_log_handler=mock_memory_log_handler, + tool_names=[], + agent=mock_agent, + tool_usage_stats=mock_tool_usage_stats, + ) + + # Mock SERENA_DASHBOARD_DIR to point to our temp directory + with patch("serena.dashboard.SERENA_DASHBOARD_DIR", temp_dir): + # Mock SerenaPaths to return a non-existent news snippet file + # so all news items are considered unread + with patch("serena.dashboard.SerenaPaths") as mock_paths: + mock_paths_instance = MagicMock() + mock_paths_instance.news_snippet_id_file = os.path.join(temp_dir, "nonexistent.txt") + mock_paths.return_value = mock_paths_instance + + # Get the client + client = api._app.test_client() + response = client.get("/news_snippet_ids") + data = response.get_json() + + # Should only include news from 20260201 onwards (not 20250101) + assert data["status"] == "success" + news_ids = data["news_snippet_ids"] + assert 20250101 not in news_ids # Old news filtered out + assert 20260201 in news_ids # Recent news included + assert 20260301 in news_ids # Future news included + + def test_news_filtering_with_read_status(self): + """Test that news items respect both installation date and read status.""" + with tempfile.TemporaryDirectory() as temp_dir: + # Create a mock news directory + news_dir = Path(temp_dir) / "news" + news_dir.mkdir() + + # Create news files + (news_dir / "20260201.html").write_text("
News 1
") + (news_dir / "20260202.html").write_text("
News 2
") + (news_dir / "20260203.html").write_text("
News 3
") + + # Create a news snippet file marking 20260201 as read + news_snippet_file = Path(temp_dir) / "last_read_news_snippet_id.txt" + news_snippet_file.write_text("20260201") + + # Mock the installation date to be 2026-02-01 + with patch.object(SerenaDashboardAPI, "_get_installation_date_id", return_value=20260201): + # Create mocks for the API + mock_memory_log_handler = MagicMock() + mock_agent = MagicMock() + mock_tool_usage_stats = MagicMock() + + api = SerenaDashboardAPI( + memory_log_handler=mock_memory_log_handler, + tool_names=[], + agent=mock_agent, + tool_usage_stats=mock_tool_usage_stats, + ) + + # Mock SERENA_DASHBOARD_DIR and SerenaPaths + with patch("serena.dashboard.SERENA_DASHBOARD_DIR", temp_dir): + with patch("serena.dashboard.SerenaPaths") as mock_paths: + mock_paths_instance = MagicMock() + mock_paths_instance.news_snippet_id_file = str(news_snippet_file) + mock_paths.return_value = mock_paths_instance + + # Get the client + client = api._app.test_client() + response = client.get("/news_snippet_ids") + data = response.get_json() + + # Should only include unread news after installation date + assert data["status"] == "success" + news_ids = data["news_snippet_ids"] + assert 20260201 not in news_ids # Read + assert 20260202 in news_ids # Unread and after installation + assert 20260203 in news_ids # Unread and after installation From 8c6fdda64629c2fa2fb2f403f8110b00413bfd67 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 12:12:17 +0000 Subject: [PATCH 040/105] Fix linting and formatting issues Co-authored-by: MischaPanch <35432522+MischaPanch@users.noreply.github.com> --- src/serena/dashboard.py | 18 +++++++++--------- test/serena/test_dashboard.py | 4 +--- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index df72dbda..190f1b1d 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -2,7 +2,7 @@ import os import socket import threading from collections.abc import Callable -from datetime import datetime +from datetime import UTC, datetime from pathlib import Path from typing import TYPE_CHECKING, Any, Self @@ -148,23 +148,23 @@ class SerenaDashboardAPI: """ Get the installation date ID (YYYYMMDD format) based on the creation time of the SERENA_HOME directory. - + :return: Installation date as an integer in YYYYMMDD format """ serena_home = SerenaPaths().serena_user_home_dir - + # If SERENA_HOME doesn't exist yet, return today's date if not os.path.exists(serena_home): - today = datetime.now() + today = datetime.now(tz=UTC) return int(today.strftime("%Y%m%d")) - + # Get the creation/modification time of the SERENA_HOME directory stat_info = os.stat(serena_home) # Use st_ctime on Unix-like systems (change time, closest to creation time) # or st_mtime if ctime is not reliable creation_timestamp = stat_info.st_ctime - creation_date = datetime.fromtimestamp(creation_timestamp) - + creation_date = datetime.fromtimestamp(creation_timestamp, tz=UTC) + # Return as YYYYMMDD integer return int(creation_date.strftime("%Y%m%d")) @@ -350,12 +350,12 @@ class SerenaDashboardAPI: def _get_unread_news_ids() -> list[int]: all_news_files = (Path(SERENA_DASHBOARD_DIR) / "news").glob("*.html") all_news_ids = [int(f.stem) for f in all_news_files] - + # Filter news items by installation date installation_date_id = self._get_installation_date_id() # Only include news items published on or after the installation date all_news_ids = [news_id for news_id in all_news_ids if news_id >= installation_date_id] - + news_snippet_id_file = SerenaPaths().news_snippet_id_file if not os.path.exists(news_snippet_id_file): return all_news_ids diff --git a/test/serena/test_dashboard.py b/test/serena/test_dashboard.py index 5637b3e4..6b073793 100644 --- a/test/serena/test_dashboard.py +++ b/test/serena/test_dashboard.py @@ -8,8 +8,6 @@ from datetime import datetime from pathlib import Path from unittest.mock import MagicMock, patch -import pytest - from serena.dashboard import SerenaDashboardAPI @@ -30,7 +28,7 @@ class TestNewsDisplayLogic: # Should return a valid YYYYMMDD format integer assert isinstance(result, int) assert 20000101 <= result <= 99991231 # Valid date range - + # Should match today's or recent date (since we just created the directory) result_str = str(result) assert len(result_str) == 8 From 373baef0408779105b0403004c6291c454ec5991 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 12:13:37 +0000 Subject: [PATCH 041/105] Address code review feedback for consistency Co-authored-by: MischaPanch <35432522+MischaPanch@users.noreply.github.com> --- src/serena/dashboard.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index 190f1b1d..0197a8bd 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -155,7 +155,7 @@ class SerenaDashboardAPI: # If SERENA_HOME doesn't exist yet, return today's date if not os.path.exists(serena_home): - today = datetime.now(tz=UTC) + today = datetime.now(UTC) return int(today.strftime("%Y%m%d")) # Get the creation/modification time of the SERENA_HOME directory @@ -163,7 +163,7 @@ class SerenaDashboardAPI: # Use st_ctime on Unix-like systems (change time, closest to creation time) # or st_mtime if ctime is not reliable creation_timestamp = stat_info.st_ctime - creation_date = datetime.fromtimestamp(creation_timestamp, tz=UTC) + creation_date = datetime.fromtimestamp(creation_timestamp, UTC) # Return as YYYYMMDD integer return int(creation_date.strftime("%Y%m%d")) From 8f7ef3b4bf4f982c1fedbcf69241f573e2620e85 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 7 Feb 2026 13:17:53 +0100 Subject: [PATCH 042/105] Fix: Use regular logger instead of LSP trace logger in LanguageServerProcess Previously, even important error cases were not logged at all, because the trace logger is rarely enabled. Fixes #999 --- src/solidlsp/ls_process.py | 62 ++++++++++++++------------------------ 1 file changed, 23 insertions(+), 39 deletions(-) diff --git a/src/solidlsp/ls_process.py b/src/solidlsp/ls_process.py index 50bfc0e6..f90f7a44 100644 --- a/src/solidlsp/ls_process.py +++ b/src/solidlsp/ls_process.py @@ -22,7 +22,6 @@ from solidlsp.lsp_protocol_handler.lsp_types import ErrorCodes from solidlsp.lsp_protocol_handler.server import ( ENCODING, LSPError, - MessageType, PayloadLike, ProcessLaunchInfo, StringDict, @@ -116,7 +115,7 @@ class LanguageServerProcess: that handle requests from the server. on_notification_handlers: A dictionary that maps method names to callback functions that handle notifications from the server. - logger: An optional function that takes two strings (source and destination) and + _trace_log_fn: An optional function that takes two strings (source and destination) and a payload dictionary, and logs the communication between the client and the server. tasks: A dictionary that maps task ids to asyncio.Task objects that represent the asynchronous tasks created by the handler. @@ -151,7 +150,7 @@ class LanguageServerProcess: self._pending_requests: dict[Any, Request] = {} self.on_request_handlers: dict[str, Callable[[Any], Any]] = {} self.on_notification_handlers: dict[str, Callable[[Any], None]] = {} - self.logger = logger + self._trace_log_fn = logger self.tasks: dict[int, Any] = {} self.task_counter = 0 self.loop = None @@ -300,19 +299,19 @@ class LanguageServerProcess: Perform the shutdown sequence for the client, including sending the shutdown request to the server and notifying it of exit """ self._is_shutting_down = True - self._log("Sending shutdown request to server") + log.info("Sending shutdown request to server") self.send.shutdown() - self._log("Received shutdown response from server") - self._log("Sending exit notification to server") + log.info("Received shutdown response from server") + log.info("Sending exit notification to server") self.notify.exit() - self._log("Sent exit notification to server") + log.info("Sent exit notification to server") - def _log(self, message: str | StringDict) -> None: + def _trace(self, src: str, dest: str, message: str | StringDict) -> None: """ - Create a log message + Traces LS communication by logging the message with the source and destination of the message """ - if self.logger is not None: - self.logger("client", "logger", message) + if self._trace_log_fn is not None: + self._trace_log_fn(src, dest, message) def _read_bytes_from_process(self, process, stream, num_bytes) -> bytes: # type: ignore """Read exactly num_bytes from process stdout""" @@ -401,18 +400,17 @@ class LanguageServerProcess: try: self._receive_payload(json.loads(body)) except OSError as ex: - self._log(f"malformed {ENCODING}: {ex}") + log.error(f"Error processing payload: {ex}", exc_info=ex) except UnicodeDecodeError as ex: - self._log(f"malformed {ENCODING}: {ex}") + log.error(f"Decoding error for encoding={ENCODING}: {ex}") except json.JSONDecodeError as ex: - self._log(f"malformed JSON: {ex}") + log.error(f"JSON decoding error: {ex}") def _receive_payload(self, payload: StringDict) -> None: """ Determine if the payload received from server is for a request, response, or notification and invoke the appropriate handler """ - if self.logger: - self.logger("server", "client", payload) + self._trace("ls", "solidlsp", payload) try: if "method" in payload: if "id" in payload: @@ -422,9 +420,9 @@ class LanguageServerProcess: elif "id" in payload: self._response_handler(payload) else: - self._log(f"Unknown payload type: {payload}") + log.error(f"Unknown payload type: {payload}") except Exception as err: - self._log(f"Error handling server payload: {err}") + log.error(f"Error handling server payload: {err}") def send_notification(self, method: str, params: dict | None = None) -> None: """ @@ -442,7 +440,6 @@ class LanguageServerProcess: """ Send error response to the given request id to the server with the given error """ - # Use lock to prevent race conditions on tasks and task_counter self._send_payload(make_error_response(request_id, err)) def _cancel_pending_requests(self, exception: Exception) -> None: @@ -472,15 +469,14 @@ class LanguageServerProcess: self._send_payload(make_request(method, request_id, params)) - self._log(f"Waiting for response to request {method} with params:\n{params}") + log.debug("Waiting for response to request %s with params:\n%s", method, params) result = request.get_result(timeout=self._request_timeout) log.debug("Completed: %s", request) - self._log("Processing result") if result.is_error(): raise SolidLSPException(f"Error processing request {method} with params:\n{params}", cause=result.error) from result.error - self._log(f"Returning non-error result, which is:\n{result.payload}") + log.debug("Returning result:\n%s", result.payload) return result.payload def _send_payload(self, payload: StringDict) -> None: @@ -489,7 +485,7 @@ class LanguageServerProcess: """ if not self.process or not self.process.stdin: return - self._log(payload) + self._trace("solidlsp", "ls", payload) msg = create_message(payload) # Use lock to prevent concurrent writes to stdin that cause buffer corruption @@ -499,8 +495,7 @@ class LanguageServerProcess: self.process.stdin.flush() except (BrokenPipeError, ConnectionResetError, OSError) as e: # Log the error but don't raise to prevent cascading failures - if self.logger: - self.logger("client", "logger", f"Failed to write to stdin: {e}") + log.error(f"Failed to write to stdin: {e}") return def on_request(self, method: str, cb: Callable[[Any], Any]) -> None: @@ -568,23 +563,12 @@ class LanguageServerProcess: params = response.get("params") handler = self.on_notification_handlers.get(method) if not handler: - self._log(f"unhandled {method}") + log.warning("Unhandled method '%s'", method) return try: handler(params) except asyncio.CancelledError: return except Exception as ex: - if (not self._is_shutting_down) and self.logger: - self.logger( - "client", - "logger", - str( - { - "type": MessageType.error, - "message": str(ex), - "method": method, - "params": params, - } - ), - ) + if not self._is_shutting_down: + log.error("Error handling notification for method '%s': %s", method, ex, exc_info=ex) From eff7cc605a758f1b2b19cbb4bd3358b841891d0b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 12:52:30 +0000 Subject: [PATCH 043/105] Remove test file as requested Co-authored-by: MischaPanch <35432522+MischaPanch@users.noreply.github.com> --- test/serena/test_dashboard.py | 154 ---------------------------------- 1 file changed, 154 deletions(-) delete mode 100644 test/serena/test_dashboard.py diff --git a/test/serena/test_dashboard.py b/test/serena/test_dashboard.py deleted file mode 100644 index 6b073793..00000000 --- a/test/serena/test_dashboard.py +++ /dev/null @@ -1,154 +0,0 @@ -""" -Tests for the dashboard API, specifically the news display logic. -""" - -import os -import tempfile -from datetime import datetime -from pathlib import Path -from unittest.mock import MagicMock, patch - -from serena.dashboard import SerenaDashboardAPI - - -class TestNewsDisplayLogic: - """Test the news display logic with installation date filtering.""" - - def test_get_installation_date_id_existing_dir(self): - """Test getting installation date from an existing directory.""" - with tempfile.TemporaryDirectory() as temp_dir: - # Get the installation date ID - with patch("serena.dashboard.SerenaPaths") as mock_paths: - mock_paths_instance = MagicMock() - mock_paths_instance.serena_user_home_dir = temp_dir - mock_paths.return_value = mock_paths_instance - - result = SerenaDashboardAPI._get_installation_date_id() - - # Should return a valid YYYYMMDD format integer - assert isinstance(result, int) - assert 20000101 <= result <= 99991231 # Valid date range - - # Should match today's or recent date (since we just created the directory) - result_str = str(result) - assert len(result_str) == 8 - year = int(result_str[0:4]) - month = int(result_str[4:6]) - day = int(result_str[6:8]) - assert 2020 <= year <= 2030 # Reasonable year range - assert 1 <= month <= 12 - assert 1 <= day <= 31 - - def test_get_installation_date_id_nonexistent_dir(self): - """Test getting installation date when directory doesn't exist yet.""" - with patch("serena.dashboard.SerenaPaths") as mock_paths: - mock_paths_instance = MagicMock() - mock_paths_instance.serena_user_home_dir = "/nonexistent/path/that/does/not/exist" - mock_paths.return_value = mock_paths_instance - - result = SerenaDashboardAPI._get_installation_date_id() - - # Should return today's date - today = datetime.now() - expected = int(today.strftime("%Y%m%d")) - assert result == expected - - def test_news_filtering_by_installation_date(self): - """Test that news items are filtered based on installation date.""" - with tempfile.TemporaryDirectory() as temp_dir: - # Create a mock news directory with some news files - news_dir = Path(temp_dir) / "news" - news_dir.mkdir() - - # Create news files with dates before and after a simulated installation date - old_news_file = news_dir / "20250101.html" - recent_news_file = news_dir / "20260201.html" - future_news_file = news_dir / "20260301.html" - - old_news_file.write_text("
Old news
") - recent_news_file.write_text("
Recent news
") - future_news_file.write_text("
Future news
") - - # Mock the installation date to be 2026-02-01 - with patch.object(SerenaDashboardAPI, "_get_installation_date_id", return_value=20260201): - # Create mocks for the API - mock_memory_log_handler = MagicMock() - mock_agent = MagicMock() - mock_tool_usage_stats = MagicMock() - - api = SerenaDashboardAPI( - memory_log_handler=mock_memory_log_handler, - tool_names=[], - agent=mock_agent, - tool_usage_stats=mock_tool_usage_stats, - ) - - # Mock SERENA_DASHBOARD_DIR to point to our temp directory - with patch("serena.dashboard.SERENA_DASHBOARD_DIR", temp_dir): - # Mock SerenaPaths to return a non-existent news snippet file - # so all news items are considered unread - with patch("serena.dashboard.SerenaPaths") as mock_paths: - mock_paths_instance = MagicMock() - mock_paths_instance.news_snippet_id_file = os.path.join(temp_dir, "nonexistent.txt") - mock_paths.return_value = mock_paths_instance - - # Get the client - client = api._app.test_client() - response = client.get("/news_snippet_ids") - data = response.get_json() - - # Should only include news from 20260201 onwards (not 20250101) - assert data["status"] == "success" - news_ids = data["news_snippet_ids"] - assert 20250101 not in news_ids # Old news filtered out - assert 20260201 in news_ids # Recent news included - assert 20260301 in news_ids # Future news included - - def test_news_filtering_with_read_status(self): - """Test that news items respect both installation date and read status.""" - with tempfile.TemporaryDirectory() as temp_dir: - # Create a mock news directory - news_dir = Path(temp_dir) / "news" - news_dir.mkdir() - - # Create news files - (news_dir / "20260201.html").write_text("
News 1
") - (news_dir / "20260202.html").write_text("
News 2
") - (news_dir / "20260203.html").write_text("
News 3
") - - # Create a news snippet file marking 20260201 as read - news_snippet_file = Path(temp_dir) / "last_read_news_snippet_id.txt" - news_snippet_file.write_text("20260201") - - # Mock the installation date to be 2026-02-01 - with patch.object(SerenaDashboardAPI, "_get_installation_date_id", return_value=20260201): - # Create mocks for the API - mock_memory_log_handler = MagicMock() - mock_agent = MagicMock() - mock_tool_usage_stats = MagicMock() - - api = SerenaDashboardAPI( - memory_log_handler=mock_memory_log_handler, - tool_names=[], - agent=mock_agent, - tool_usage_stats=mock_tool_usage_stats, - ) - - # Mock SERENA_DASHBOARD_DIR and SerenaPaths - with patch("serena.dashboard.SERENA_DASHBOARD_DIR", temp_dir): - with patch("serena.dashboard.SerenaPaths") as mock_paths: - mock_paths_instance = MagicMock() - mock_paths_instance.news_snippet_id_file = str(news_snippet_file) - mock_paths.return_value = mock_paths_instance - - # Get the client - client = api._app.test_client() - response = client.get("/news_snippet_ids") - data = response.get_json() - - # Should only include unread news after installation date - assert data["status"] == "success" - news_ids = data["news_snippet_ids"] - assert 20260201 not in news_ids # Read - assert 20260202 in news_ids # Unread and after installation - assert 20260203 in news_ids # Unread and after installation From 65c24a0df8918f3d047607c489527faa8b47c2b1 Mon Sep 17 00:00:00 2001 From: takeokunn Date: Sat, 7 Feb 2026 22:40:56 +0900 Subject: [PATCH 044/105] feat(php): add Phpactor as alternative PHP language server - Introduce PHP_PHPACTOR enum and integration in ls_config - Implement PhpactorServer for PHP with dependency management - Update docs and changelog for Phpactor support and usage - Add project template entry for php_phpactor - Provide tests for Phpactor LSP definition and reference features --- CHANGELOG.md | 1 + docs/01-about/020_programming-languages.md | 4 +- src/serena/resources/project.template.yml | 8 +- src/solidlsp/language_servers/phpactor.py | 148 +++++++++++++++++++++ src/solidlsp/ls_config.py | 11 +- test/resources/repos/php_phpactor | 1 + test/solidlsp/php/test_php_phpactor.py | 111 ++++++++++++++++ 7 files changed, 277 insertions(+), 7 deletions(-) create mode 100644 src/solidlsp/language_servers/phpactor.py create mode 120000 test/resources/repos/php_phpactor create mode 100644 test/solidlsp/php/test_php_phpactor.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a2fe6dc..b2cdfcca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ Status of the `main` branch. Changes prior to the next official version change w * Language support: + * **Add Phpactor as alternative PHP language server** (specify `php_phpactor` as language; requires PHP 8.1+) * **Add support for Fortran** via fortls language server (requires `pip install fortls`) * **Add partial support for Groovy** requires user-provided Groovy language server JAR (see [setup guide](docs/03-special-guides/groovy_setup_guide_for_serena.md)) * **Add support for Julia** via LanguageServer.jl diff --git a/docs/01-about/020_programming-languages.md b/docs/01-about/020_programming-languages.md index aa4a4834..c17fb0a6 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -64,8 +64,8 @@ Some languages require additional installations or setup steps, as noted. (Free Pascal/Lazarus; automatically downloads pasls binary; set PP and FPCDIR environment variables for source navigation) * **Perl** (requires installation of Perl::LanguageServer) -* **PHP** - (uses Intelephense LSP; set `INTELEPHENSE_LICENSE_KEY` environment variable for premium features) +* **PHP** + (by default, uses Intelephense LSP; set `INTELEPHENSE_LICENSE_KEY` environment variable for premium features; specify `php_phpactor` as your language to use the open-source [Phpactor](https://github.com/phpactor/phpactor) implementation, requires PHP 8.1+) * **Python** * **R** (requires installation of the `languageserver` R package) diff --git a/src/serena/resources/project.template.yml b/src/serena/resources/project.template.yml index 9472ba95..1ce23a99 100644 --- a/src/serena/resources/project.template.yml +++ b/src/serena/resources/project.template.yml @@ -8,10 +8,10 @@ project_name: "project_name" # fortran fsharp go groovy haskell # java julia kotlin lua markdown # matlab nix pascal perl php -# powershell python python_jedi r rego -# ruby ruby_solargraph rust scala swift -# terraform toml typescript typescript_vts vue -# yaml zig +# php_phpactor powershell python python_jedi r +# rego ruby ruby_solargraph rust scala +# swift terraform toml typescript typescript_vts +# vue yaml zig # (This list may be outdated. For the current list, see values of Language enum here: # https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py # For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) diff --git a/src/solidlsp/language_servers/phpactor.py b/src/solidlsp/language_servers/phpactor.py new file mode 100644 index 00000000..67dc4995 --- /dev/null +++ b/src/solidlsp/language_servers/phpactor.py @@ -0,0 +1,148 @@ +""" +Provides PHP specific instantiation of the LanguageServer class using Phpactor. +""" + +import logging +import os +import pathlib +import shutil +import stat + +from overrides import override + +from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer +from solidlsp.ls_config import Language, LanguageServerConfig +from solidlsp.ls_utils import FileUtils +from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + +PHPACTOR_VERSION = "2025.12.21.1" +PHPACTOR_PHAR_URL = f"https://github.com/phpactor/phpactor/releases/download/{PHPACTOR_VERSION}/phpactor.phar" + + +class PhpactorServer(SolidLanguageServer): + """ + Provides PHP specific instantiation of the LanguageServer class using Phpactor. + + Phpactor is an open-source (MIT) PHP language server that requires PHP 8.1+ on the system. + It is an alternative to Intelephense, which is the default PHP language server. + + You can pass the following entries in ls_specific_settings["php_phpactor"]: + - ignore_vendor: whether to ignore directories named "vendor" (default: true) + """ + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in self._ignored_dirnames + + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + def _get_or_install_core_dependency(self) -> str: + """ + Setup runtime dependencies for Phpactor and return the path to the PHAR file. + """ + # Verify PHP is installed + php_path = shutil.which("php") + assert ( + php_path is not None + ), "PHP is not installed or not found in PATH. Phpactor requires PHP 8.1+. Please install PHP and try again." + + phpactor_phar_path = os.path.join(self._ls_resources_dir, "phpactor.phar") + if not os.path.exists(phpactor_phar_path): + os.makedirs(self._ls_resources_dir, exist_ok=True) + log.info(f"Downloading phpactor PHAR from {PHPACTOR_PHAR_URL}") + FileUtils.download_and_extract_archive(PHPACTOR_PHAR_URL, phpactor_phar_path, "binary") + + assert os.path.exists(phpactor_phar_path), f"phpactor PHAR not found at {phpactor_phar_path}, download may have failed." + + # Ensure the PHAR is executable + current_mode = os.stat(phpactor_phar_path).st_mode + os.chmod(phpactor_phar_path, current_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + + return phpactor_phar_path + + def _create_launch_command(self, core_path: str) -> list[str]: + return ["php", core_path, "language-server"] + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + super().__init__(config, repository_root_path, None, "php", solidlsp_settings) + # Override internal language enum for correct file matching + self.language = Language.PHP_PHPACTOR + + self._ignored_dirnames = {"node_modules", "cache"} + if self._custom_settings.get("ignore_vendor", True): + self._ignored_dirnames.add("vendor") + log.info(f"Ignoring the following directories for PHP (Phpactor): {', '.join(sorted(self._ignored_dirnames))}") + + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) + + def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialization params for the Phpactor Language Server. + """ + root_uri = pathlib.Path(repository_absolute_path).as_uri() + initialize_params = { + "processId": os.getpid(), + "rootPath": repository_absolute_path, + "rootUri": root_uri, + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "definition": {"dynamicRegistration": True}, + "documentSymbol": { + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + }, + }, + "workspaceFolders": [ + { + "uri": root_uri, + "name": os.path.basename(repository_absolute_path), + } + ], + "initializationOptions": { + "language_server_phpstan.enabled": False, + "language_server_psalm.enabled": False, + "language_server_php_cs_fixer.enabled": False, + }, + } + return initialize_params # type: ignore + + def _start_server(self) -> None: + """Start Phpactor server process.""" + + def register_capability_handler(params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + def do_nothing(params: dict) -> None: + return + + self.server.on_request("client/registerCapability", register_capability_handler) + 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 Phpactor server process") + self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + log.info("After sent initialize params") + + # Verify server capabilities + assert "capabilities" in init_response + assert init_response["capabilities"].get("definitionProvider"), "Phpactor did not advertise definition support" + + self.server.notify.initialized({}) + self.completions_available.set() diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 04627009..37a9d79b 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -87,6 +87,10 @@ class Language(str, Enum): """Solargraph language server for Ruby (legacy, experimental). Use Language.RUBY (ruby-lsp) for better performance and modern LSP features. """ + PHP_PHPACTOR = "php_phpactor" + """Phpactor language server for PHP (instead of Intelephense, which is the default). + Requires PHP 8.1+ on the system. Fully open-source (MIT license). + """ MARKDOWN = "markdown" """Marksman language server for Markdown (experimental). Must be explicitly specified as the main language, not auto-detected. @@ -120,6 +124,7 @@ class Language(str, Enum): self.PYTHON_JEDI, self.CSHARP_OMNISHARP, self.RUBY_SOLARGRAPH, + self.PHP_PHPACTOR, self.MARKDOWN, self.YAML, self.TOML, @@ -176,7 +181,7 @@ class Language(str, Enum): return FilenameMatcher("*.kt", "*.kts") case self.DART: return FilenameMatcher("*.dart") - case self.PHP: + case self.PHP | self.PHP_PHPACTOR: return FilenameMatcher("*.php") case self.R: return FilenameMatcher("*.R", "*.r", "*.Rmd", "*.Rnw") @@ -308,6 +313,10 @@ class Language(str, Enum): from solidlsp.language_servers.intelephense import Intelephense return Intelephense + case self.PHP_PHPACTOR: + from solidlsp.language_servers.phpactor import PhpactorServer + + return PhpactorServer case self.PERL: from solidlsp.language_servers.perl_language_server import PerlLanguageServer diff --git a/test/resources/repos/php_phpactor b/test/resources/repos/php_phpactor new file mode 120000 index 00000000..fa557ad9 --- /dev/null +++ b/test/resources/repos/php_phpactor @@ -0,0 +1 @@ +php \ No newline at end of file diff --git a/test/solidlsp/php/test_php_phpactor.py b/test/solidlsp/php/test_php_phpactor.py new file mode 100644 index 00000000..65fab2f6 --- /dev/null +++ b/test/solidlsp/php/test_php_phpactor.py @@ -0,0 +1,111 @@ +from pathlib import Path + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language + + +@pytest.mark.php +class TestPhpPhpactorLanguageServer: + @pytest.mark.parametrize("language_server", [Language.PHP_PHPACTOR], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) + def test_ls_is_running(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that the phpactor language server starts and stops successfully.""" + assert language_server.is_running() + assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve() + + @pytest.mark.parametrize("language_server", [Language.PHP_PHPACTOR], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) + def test_find_definition_within_file(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + # In index.php (0-indexed lines): + # Line 9: $greeting = greet($userName); + # Line 11: echo $greeting; + # Find the definition of $greeting from its usage on line 11 (0-indexed), char 6 + definition_location_list = language_server.request_definition(str(repo_path / "index.php"), 11, 6) + + assert definition_location_list, f"Expected non-empty definition_location_list but got {definition_location_list=}" + assert len(definition_location_list) >= 1 + definition_location = definition_location_list[0] + assert definition_location["uri"].endswith("index.php") + assert definition_location["range"]["start"]["line"] == 9 + + @pytest.mark.parametrize("language_server", [Language.PHP_PHPACTOR], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) + def test_find_definition_across_files(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + # In index.php (0-indexed lines): + # Line 13: helperFunction(); + # Find the definition of helperFunction which is in helper.php, line 2 (0-indexed) + definition_location_list = language_server.request_definition(str(repo_path / "index.php"), 13, 5) + + assert definition_location_list, f"Expected non-empty definition_location_list but got {definition_location_list=}" + assert len(definition_location_list) >= 1 + definition_location = definition_location_list[0] + assert definition_location["uri"].endswith("helper.php") + assert definition_location["range"]["start"]["line"] == 2 + + @pytest.mark.parametrize("language_server", [Language.PHP_PHPACTOR], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) + def test_find_definition_simple_variable(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + file_path = str(repo_path / "simple_var.php") + + # In simple_var.php (0-indexed lines): + # Line 1: $localVar = "test"; + # Line 2: echo $localVar; + # Find definition of $localVar from its usage on line 2, char 6 + definition_location_list = language_server.request_definition(file_path, 2, 6) + + assert definition_location_list, f"Expected non-empty definition_location_list but got {definition_location_list=}" + assert len(definition_location_list) >= 1 + definition_location = definition_location_list[0] + assert definition_location["uri"].endswith("simple_var.php") + assert definition_location["range"]["start"]["line"] == 1 + + @pytest.mark.parametrize("language_server", [Language.PHP_PHPACTOR], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) + def test_find_references_within_file(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + index_php_path = str(repo_path / "index.php") + + # In index.php (0-indexed lines): + # Line 9: $greeting = greet($userName); // Definition of $greeting + # Line 11: echo $greeting; // Usage of $greeting + # Find references for $greeting from its usage on line 11, char 6 + references = language_server.request_references(index_php_path, 11, 6) + + assert references, f"Expected non-empty references for $greeting but got {references=}" + + actual_locations = [ + { + "uri_suffix": loc["uri"].split("/")[-1], + "line": loc["range"]["start"]["line"], + } + for loc in references + ] + + # Check that at least one reference points to $greeting usage in index.php + matching = [loc for loc in actual_locations if loc["uri_suffix"] == "index.php" and loc["line"] == 11] + assert matching, f"Expected reference to $greeting on line 11 of index.php, got {actual_locations}" + + @pytest.mark.parametrize("language_server", [Language.PHP_PHPACTOR], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) + def test_find_references_across_files(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + helper_php_path = str(repo_path / "helper.php") + + # Find references for helperFunction from its definition in helper.php + # Line 2 (0-indexed): function helperFunction(): void { + references = language_server.request_references(helper_php_path, 2, len("function ")) + + assert references, f"Expected non-empty references for helperFunction but got {references=}" + + actual_locations_comparable = [] + for loc in references: + actual_locations_comparable.append( + { + "uri_suffix": loc["uri"].split("/")[-1], + "line": loc["range"]["start"]["line"], + } + ) + + # Check that helperFunction usage in index.php line 13 is found + matching = [loc for loc in actual_locations_comparable if loc["uri_suffix"] == "index.php" and loc["line"] == 13] + assert matching, f"Usage of helperFunction in index.php (line 13) not found in {actual_locations_comparable}" From d39967dd678c405e6cc2826539587b02b1ef8121 Mon Sep 17 00:00:00 2001 From: takeokunn Date: Sun, 8 Feb 2026 01:03:04 +0900 Subject: [PATCH 045/105] fix(php): add PHP version validation for Phpactor Check that the installed PHP version is >= 8.1 before proceeding with Phpactor setup. Raises RuntimeError with a clear message if the version is too old, and logs a warning if the version string cannot be parsed. Addresses review comment on PR #1002. --- src/solidlsp/language_servers/phpactor.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/solidlsp/language_servers/phpactor.py b/src/solidlsp/language_servers/phpactor.py index 67dc4995..570fc895 100644 --- a/src/solidlsp/language_servers/phpactor.py +++ b/src/solidlsp/language_servers/phpactor.py @@ -5,8 +5,10 @@ Provides PHP specific instantiation of the LanguageServer class using Phpactor. import logging import os import pathlib +import re import shutil import stat +import subprocess from overrides import override @@ -48,6 +50,18 @@ class PhpactorServer(SolidLanguageServer): php_path is not None ), "PHP is not installed or not found in PATH. Phpactor requires PHP 8.1+. Please install PHP and try again." + # Check PHP version (Phpactor requires PHP 8.1+) + result = subprocess.run(["php", "--version"], capture_output=True, text=True, check=False) + php_version_output = result.stdout.strip() + log.info(f"PHP version: {php_version_output}") + version_match = re.search(r"PHP (\d+)\.(\d+)", php_version_output) + if version_match: + major, minor = int(version_match.group(1)), int(version_match.group(2)) + if major < 8 or (major == 8 and minor < 1): + raise RuntimeError(f"PHP {major}.{minor} detected, but Phpactor requires PHP 8.1+. Please upgrade PHP.") + else: + log.warning("Could not parse PHP version from output. Continuing anyway.") + phpactor_phar_path = os.path.join(self._ls_resources_dir, "phpactor.phar") if not os.path.exists(phpactor_phar_path): os.makedirs(self._ls_resources_dir, exist_ok=True) From c972380d0a11c291c874b6c68a4b6ddb3eea1ff2 Mon Sep 17 00:00:00 2001 From: takeokunn Date: Sun, 8 Feb 2026 01:03:12 +0900 Subject: [PATCH 046/105] fix(test): remove php_phpactor symlink and add repo alias mapping Remove the unnecessary php_phpactor -> php symlink. Instead, add a _LANGUAGE_REPO_ALIASES mapping in conftest.py so that variant language servers (PHP_PHPACTOR, PYTHON_JEDI, RUBY_SOLARGRAPH) resolve to their base language's test repository without needing filesystem symlinks. Addresses review comment on PR #1002. --- test/conftest.py | 10 +++++++++- test/resources/repos/php_phpactor | 1 - 2 files changed, 9 insertions(+), 2 deletions(-) delete mode 120000 test/resources/repos/php_phpactor diff --git a/test/conftest.py b/test/conftest.py index e02b428c..74dff913 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -34,8 +34,16 @@ class LanguageParamRequest: param: Language +_LANGUAGE_REPO_ALIASES: dict[Language, Language] = { + Language.PHP_PHPACTOR: Language.PHP, + Language.PYTHON_JEDI: Language.PYTHON, + Language.RUBY_SOLARGRAPH: Language.RUBY, +} + + def get_repo_path(language: Language) -> Path: - return Path(__file__).parent / "resources" / "repos" / language / "test_repo" + repo_language = _LANGUAGE_REPO_ALIASES.get(language, language) + return Path(__file__).parent / "resources" / "repos" / repo_language / "test_repo" def _create_ls( diff --git a/test/resources/repos/php_phpactor b/test/resources/repos/php_phpactor deleted file mode 120000 index fa557ad9..00000000 --- a/test/resources/repos/php_phpactor +++ /dev/null @@ -1 +0,0 @@ -php \ No newline at end of file From 46fabd69f4cdb6bc2adb75d0c46dac8ddb495653 Mon Sep 17 00:00:00 2001 From: takeokunn Date: Sun, 8 Feb 2026 01:03:17 +0900 Subject: [PATCH 047/105] fix(test): merge PHP test classes into TestPhpLanguageServers Consolidate TestPhpLanguageServer and TestPhpPhpactorLanguageServer into a single TestPhpLanguageServers class. Both Language.PHP and Language.PHP_PHPACTOR are now parametrized together, with conditional branching where the two language servers differ in behavior. Addresses review comment on PR #1002. --- test/solidlsp/php/test_php_basic.py | 142 ++++++++++++++++--------- test/solidlsp/php/test_php_phpactor.py | 111 ------------------- 2 files changed, 94 insertions(+), 159 deletions(-) delete mode 100644 test/solidlsp/php/test_php_phpactor.py diff --git a/test/solidlsp/php/test_php_basic.py b/test/solidlsp/php/test_php_basic.py index 391c565b..68207dca 100644 --- a/test/solidlsp/php/test_php_basic.py +++ b/test/solidlsp/php/test_php_basic.py @@ -7,16 +7,15 @@ from solidlsp.ls_config import Language @pytest.mark.php -class TestPhpLanguageServer: - @pytest.mark.parametrize("language_server", [Language.PHP], indirect=True) +class TestPhpLanguageServers: + @pytest.mark.parametrize("language_server", [Language.PHP, Language.PHP_PHPACTOR], indirect=True) @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) def test_ls_is_running(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test that the language server starts and stops successfully.""" - # The fixture already handles start and stop assert language_server.is_running() assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve() - @pytest.mark.parametrize("language_server", [Language.PHP], indirect=True) + @pytest.mark.parametrize("language_server", [Language.PHP, Language.PHP_PHPACTOR], indirect=True) @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) def test_find_definition_within_file(self, language_server: SolidLanguageServer, repo_path: Path) -> None: @@ -26,32 +25,47 @@ class TestPhpLanguageServer: # We want to find the definition of $greeting (defined on line 9) # from its usage in echo $greeting; on line 11. # LSP is 0-indexed: definition on line 8, usage on line 10. - # $greeting in echo $greeting; is at char 5 on line 11 (0-indexed: line 10, char 5) - # e c h o $ g r e e t i n g - # ^ char 5 - definition_location_list = language_server.request_definition(str(repo_path / "index.php"), 10, 6) # cursor on 'g' in $greeting + # $greeting in echo $greeting; (e c h o $ g r e e t i n g) + # ^ char 5 + # Intelephense uses line 10 (0-indexed), Phpactor uses line 11 (0-indexed) + if language_server.language_server.language == Language.PHP_PHPACTOR: + definition_location_list = language_server.request_definition(str(repo_path / "index.php"), 11, 6) + else: + definition_location_list = language_server.request_definition(str(repo_path / "index.php"), 10, 6) assert definition_location_list, f"Expected non-empty definition_location_list but got {definition_location_list=}" - assert len(definition_location_list) == 1 + if language_server.language_server.language == Language.PHP_PHPACTOR: + assert len(definition_location_list) >= 1 + else: + assert len(definition_location_list) == 1 definition_location = definition_location_list[0] assert definition_location["uri"].endswith("index.php") # Definition of $greeting is on line 10 (1-indexed) / line 9 (0-indexed), char 0 assert definition_location["range"]["start"]["line"] == 9 - assert definition_location["range"]["start"]["character"] == 0 + if language_server.language_server.language != Language.PHP_PHPACTOR: + assert definition_location["range"]["start"]["character"] == 0 - @pytest.mark.parametrize("language_server", [Language.PHP], indirect=True) + @pytest.mark.parametrize("language_server", [Language.PHP, Language.PHP_PHPACTOR], indirect=True) @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) def test_find_definition_across_files(self, language_server: SolidLanguageServer, repo_path: Path) -> None: - definition_location_list = language_server.request_definition(str(repo_path / "index.php"), 12, 5) # helperFunction + # Intelephense uses line 12 (0-indexed), Phpactor uses line 13 (0-indexed) + if language_server.language_server.language == Language.PHP_PHPACTOR: + definition_location_list = language_server.request_definition(str(repo_path / "index.php"), 13, 5) + else: + definition_location_list = language_server.request_definition(str(repo_path / "index.php"), 12, 5) assert definition_location_list, f"Expected non-empty definition_location_list but got {definition_location_list=}" - assert len(definition_location_list) == 1 + if language_server.language_server.language == Language.PHP_PHPACTOR: + assert len(definition_location_list) >= 1 + else: + assert len(definition_location_list) == 1 definition_location = definition_location_list[0] assert definition_location["uri"].endswith("helper.php") assert definition_location["range"]["start"]["line"] == 2 - assert definition_location["range"]["start"]["character"] == 0 + if language_server.language_server.language != Language.PHP_PHPACTOR: + assert definition_location["range"]["start"]["character"] == 0 - @pytest.mark.parametrize("language_server", [Language.PHP], indirect=True) + @pytest.mark.parametrize("language_server", [Language.PHP, Language.PHP_PHPACTOR], indirect=True) @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) def test_find_definition_simple_variable(self, language_server: SolidLanguageServer, repo_path: Path) -> None: file_path = str(repo_path / "simple_var.php") @@ -66,13 +80,17 @@ class TestPhpLanguageServer: definition_location_list = language_server.request_definition(file_path, 2, 6) # cursor on 'l' in $localVar assert definition_location_list, f"Expected non-empty definition_location_list but got {definition_location_list=}" - assert len(definition_location_list) == 1 + if language_server.language_server.language == Language.PHP_PHPACTOR: + assert len(definition_location_list) >= 1 + else: + assert len(definition_location_list) == 1 definition_location = definition_location_list[0] assert definition_location["uri"].endswith("simple_var.php") assert definition_location["range"]["start"]["line"] == 1 # Definition of $localVar (0-indexed) - assert definition_location["range"]["start"]["character"] == 0 # $localVar (0-indexed) + if language_server.language_server.language != Language.PHP_PHPACTOR: + assert definition_location["range"]["start"]["character"] == 0 # $localVar (0-indexed) - @pytest.mark.parametrize("language_server", [Language.PHP], indirect=True) + @pytest.mark.parametrize("language_server", [Language.PHP, Language.PHP_PHPACTOR], indirect=True) @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) def test_find_references_within_file(self, language_server: SolidLanguageServer, repo_path: Path) -> None: index_php_path = str(repo_path / "index.php") @@ -83,30 +101,44 @@ class TestPhpLanguageServer: # Find references for $greeting from its usage in "echo $greeting;" (line 11, char 6 for 'g') references = language_server.request_references(index_php_path, 11, 6) - assert references - # Intelephense, when asked for references from usage, seems to only return the usage itself. - assert len(references) == 1, "Expected to find 1 reference for $greeting (the usage itself)" + assert references, f"Expected non-empty references for $greeting but got {references=}" - expected_locations = [{"uri_suffix": "index.php", "line": 11, "character": 5}] # Usage: echo $greeting (points to $) - - # Convert actual references to a comparable format and sort - actual_locations = sorted( - [ + if language_server.language_server.language == Language.PHP_PHPACTOR: + actual_locations = [ { "uri_suffix": loc["uri"].split("/")[-1], "line": loc["range"]["start"]["line"], - "character": loc["range"]["start"]["character"], } for loc in references - ], - key=lambda x: (x["uri_suffix"], x["line"], x["character"]), - ) + ] - expected_locations = sorted(expected_locations, key=lambda x: (x["uri_suffix"], x["line"], x["character"])) + # Check that at least one reference points to $greeting usage in index.php + matching = [loc for loc in actual_locations if loc["uri_suffix"] == "index.php" and loc["line"] == 11] + assert matching, f"Expected reference to $greeting on line 11 of index.php, got {actual_locations}" + else: + # Intelephense, when asked for references from usage, seems to only return the usage itself. + assert len(references) == 1, "Expected to find 1 reference for $greeting (the usage itself)" - assert actual_locations == expected_locations + expected_locations = [{"uri_suffix": "index.php", "line": 11, "character": 5}] # Usage: echo $greeting (points to $) - @pytest.mark.parametrize("language_server", [Language.PHP], indirect=True) + # Convert actual references to a comparable format and sort + actual_locations = sorted( + [ + { + "uri_suffix": loc["uri"].split("/")[-1], + "line": loc["range"]["start"]["line"], + "character": loc["range"]["start"]["character"], + } + for loc in references + ], + key=lambda x: (x["uri_suffix"], x["line"], x["character"]), + ) + + expected_locations = sorted(expected_locations, key=lambda x: (x["uri_suffix"], x["line"], x["character"])) + + assert actual_locations == expected_locations + + @pytest.mark.parametrize("language_server", [Language.PHP, Language.PHP_PHPACTOR], indirect=True) @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) def test_find_references_across_files(self, language_server: SolidLanguageServer, repo_path: Path) -> None: helper_php_path = str(repo_path / "helper.php") @@ -116,20 +148,34 @@ class TestPhpLanguageServer: references = language_server.request_references(helper_php_path, 2, len("function ")) assert references, f"Expected non-empty references for helperFunction but got {references=}" - # Intelephense might return 1 (usage) or 2 (usage + definition) references. - # Let's check for at least the usage in index.php - # Definition is in helper.php, line 2, char 0 (based on previous findings) - # Usage is in index.php, line 13, char 0 - actual_locations_comparable = [] - for loc in references: - actual_locations_comparable.append( - { - "uri_suffix": loc["uri"].split("/")[-1], - "line": loc["range"]["start"]["line"], - "character": loc["range"]["start"]["character"], - } - ) + if language_server.language_server.language == Language.PHP_PHPACTOR: + actual_locations_comparable = [] + for loc in references: + actual_locations_comparable.append( + { + "uri_suffix": loc["uri"].split("/")[-1], + "line": loc["range"]["start"]["line"], + } + ) - usage_in_index_php = {"uri_suffix": "index.php", "line": 13, "character": 0} - assert usage_in_index_php in actual_locations_comparable, "Usage of helperFunction in index.php not found" + # Check that helperFunction usage in index.php line 13 is found + matching = [loc for loc in actual_locations_comparable if loc["uri_suffix"] == "index.php" and loc["line"] == 13] + assert matching, f"Usage of helperFunction in index.php (line 13) not found in {actual_locations_comparable}" + else: + # Intelephense might return 1 (usage) or 2 (usage + definition) references. + # Let's check for at least the usage in index.php + # Definition is in helper.php, line 2, char 0 (based on previous findings) + # Usage is in index.php, line 13, char 0 + actual_locations_comparable = [] + for loc in references: + actual_locations_comparable.append( + { + "uri_suffix": loc["uri"].split("/")[-1], + "line": loc["range"]["start"]["line"], + "character": loc["range"]["start"]["character"], + } + ) + + usage_in_index_php = {"uri_suffix": "index.php", "line": 13, "character": 0} + assert usage_in_index_php in actual_locations_comparable, "Usage of helperFunction in index.php not found" diff --git a/test/solidlsp/php/test_php_phpactor.py b/test/solidlsp/php/test_php_phpactor.py deleted file mode 100644 index 65fab2f6..00000000 --- a/test/solidlsp/php/test_php_phpactor.py +++ /dev/null @@ -1,111 +0,0 @@ -from pathlib import Path - -import pytest - -from solidlsp import SolidLanguageServer -from solidlsp.ls_config import Language - - -@pytest.mark.php -class TestPhpPhpactorLanguageServer: - @pytest.mark.parametrize("language_server", [Language.PHP_PHPACTOR], indirect=True) - @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) - def test_ls_is_running(self, language_server: SolidLanguageServer, repo_path: Path) -> None: - """Test that the phpactor language server starts and stops successfully.""" - assert language_server.is_running() - assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve() - - @pytest.mark.parametrize("language_server", [Language.PHP_PHPACTOR], indirect=True) - @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) - def test_find_definition_within_file(self, language_server: SolidLanguageServer, repo_path: Path) -> None: - # In index.php (0-indexed lines): - # Line 9: $greeting = greet($userName); - # Line 11: echo $greeting; - # Find the definition of $greeting from its usage on line 11 (0-indexed), char 6 - definition_location_list = language_server.request_definition(str(repo_path / "index.php"), 11, 6) - - assert definition_location_list, f"Expected non-empty definition_location_list but got {definition_location_list=}" - assert len(definition_location_list) >= 1 - definition_location = definition_location_list[0] - assert definition_location["uri"].endswith("index.php") - assert definition_location["range"]["start"]["line"] == 9 - - @pytest.mark.parametrize("language_server", [Language.PHP_PHPACTOR], indirect=True) - @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) - def test_find_definition_across_files(self, language_server: SolidLanguageServer, repo_path: Path) -> None: - # In index.php (0-indexed lines): - # Line 13: helperFunction(); - # Find the definition of helperFunction which is in helper.php, line 2 (0-indexed) - definition_location_list = language_server.request_definition(str(repo_path / "index.php"), 13, 5) - - assert definition_location_list, f"Expected non-empty definition_location_list but got {definition_location_list=}" - assert len(definition_location_list) >= 1 - definition_location = definition_location_list[0] - assert definition_location["uri"].endswith("helper.php") - assert definition_location["range"]["start"]["line"] == 2 - - @pytest.mark.parametrize("language_server", [Language.PHP_PHPACTOR], indirect=True) - @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) - def test_find_definition_simple_variable(self, language_server: SolidLanguageServer, repo_path: Path) -> None: - file_path = str(repo_path / "simple_var.php") - - # In simple_var.php (0-indexed lines): - # Line 1: $localVar = "test"; - # Line 2: echo $localVar; - # Find definition of $localVar from its usage on line 2, char 6 - definition_location_list = language_server.request_definition(file_path, 2, 6) - - assert definition_location_list, f"Expected non-empty definition_location_list but got {definition_location_list=}" - assert len(definition_location_list) >= 1 - definition_location = definition_location_list[0] - assert definition_location["uri"].endswith("simple_var.php") - assert definition_location["range"]["start"]["line"] == 1 - - @pytest.mark.parametrize("language_server", [Language.PHP_PHPACTOR], indirect=True) - @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) - def test_find_references_within_file(self, language_server: SolidLanguageServer, repo_path: Path) -> None: - index_php_path = str(repo_path / "index.php") - - # In index.php (0-indexed lines): - # Line 9: $greeting = greet($userName); // Definition of $greeting - # Line 11: echo $greeting; // Usage of $greeting - # Find references for $greeting from its usage on line 11, char 6 - references = language_server.request_references(index_php_path, 11, 6) - - assert references, f"Expected non-empty references for $greeting but got {references=}" - - actual_locations = [ - { - "uri_suffix": loc["uri"].split("/")[-1], - "line": loc["range"]["start"]["line"], - } - for loc in references - ] - - # Check that at least one reference points to $greeting usage in index.php - matching = [loc for loc in actual_locations if loc["uri_suffix"] == "index.php" and loc["line"] == 11] - assert matching, f"Expected reference to $greeting on line 11 of index.php, got {actual_locations}" - - @pytest.mark.parametrize("language_server", [Language.PHP_PHPACTOR], indirect=True) - @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) - def test_find_references_across_files(self, language_server: SolidLanguageServer, repo_path: Path) -> None: - helper_php_path = str(repo_path / "helper.php") - - # Find references for helperFunction from its definition in helper.php - # Line 2 (0-indexed): function helperFunction(): void { - references = language_server.request_references(helper_php_path, 2, len("function ")) - - assert references, f"Expected non-empty references for helperFunction but got {references=}" - - actual_locations_comparable = [] - for loc in references: - actual_locations_comparable.append( - { - "uri_suffix": loc["uri"].split("/")[-1], - "line": loc["range"]["start"]["line"], - } - ) - - # Check that helperFunction usage in index.php line 13 is found - matching = [loc for loc in actual_locations_comparable if loc["uri_suffix"] == "index.php" and loc["line"] == 13] - assert matching, f"Usage of helperFunction in index.php (line 13) not found in {actual_locations_comparable}" From 46d3f11427c82be9c318007127abaebe011aaec8 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Sat, 7 Feb 2026 20:23:18 +0100 Subject: [PATCH 048/105] Human > AI (for now) --- src/serena/config/serena_config.py | 15 ++++++++++- src/serena/dashboard.py | 43 +++++++++--------------------- 2 files changed, 26 insertions(+), 32 deletions(-) diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 622fbf8b..5f6b3fe6 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -8,7 +8,7 @@ import shutil from collections.abc import Iterator, Sequence from copy import deepcopy from dataclasses import dataclass, field -from datetime import datetime +from datetime import UTC, datetime from enum import Enum from functools import cached_property from pathlib import Path @@ -511,6 +511,19 @@ class SerenaConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMix CONFIG_FIELDS_WITH_TYPE_CONVERSION = {"projects", "language_backend"} # *** methods *** + @classmethod + def get_config_file_creation_date(cls) -> datetime | None: + """ + :return: the creation date of the configuration file, or None if the configuration file does not exist + """ + config_file_path = cls._determine_config_file_path() + if not os.path.exists(config_file_path): + return None + + # for unix systems st_ctime is the inode change time (change of metadata), + # which is good enough for our purposes + creation_timestamp = os.stat(config_file_path).st_ctime + return datetime.fromtimestamp(creation_timestamp, UTC) @property def config_file_path(self) -> str | None: diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index 0197a8bd..dca3d730 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -2,7 +2,6 @@ import os import socket import threading from collections.abc import Callable -from datetime import UTC, datetime from pathlib import Path from typing import TYPE_CHECKING, Any, Self @@ -11,7 +10,7 @@ from pydantic import BaseModel from sensai.util import logging from serena.analytics import ToolUsageStats -from serena.config.serena_config import LanguageBackend, SerenaPaths +from serena.config.serena_config import LanguageBackend, SerenaConfig, SerenaPaths from serena.constants import SERENA_DASHBOARD_DIR from serena.task_executor import TaskExecutor from serena.util.logging import MemoryLogHandler @@ -143,31 +142,6 @@ class SerenaDashboardAPI: def memory_log_handler(self) -> MemoryLogHandler: return self._memory_log_handler - @staticmethod - def _get_installation_date_id() -> int: - """ - Get the installation date ID (YYYYMMDD format) based on the creation time - of the SERENA_HOME directory. - - :return: Installation date as an integer in YYYYMMDD format - """ - serena_home = SerenaPaths().serena_user_home_dir - - # If SERENA_HOME doesn't exist yet, return today's date - if not os.path.exists(serena_home): - today = datetime.now(UTC) - return int(today.strftime("%Y%m%d")) - - # Get the creation/modification time of the SERENA_HOME directory - stat_info = os.stat(serena_home) - # Use st_ctime on Unix-like systems (change time, closest to creation time) - # or st_mtime if ctime is not reliable - creation_timestamp = stat_info.st_ctime - creation_date = datetime.fromtimestamp(creation_timestamp, UTC) - - # Return as YYYYMMDD integer - return int(creation_date.strftime("%Y%m%d")) - def _setup_routes(self) -> None: # Static files @self._app.route("/dashboard/") @@ -350,18 +324,25 @@ class SerenaDashboardAPI: def _get_unread_news_ids() -> list[int]: all_news_files = (Path(SERENA_DASHBOARD_DIR) / "news").glob("*.html") all_news_ids = [int(f.stem) for f in all_news_files] + """News ids are ints of format YYYYMMDD (publication dates)""" # Filter news items by installation date - installation_date_id = self._get_installation_date_id() + serena_config_creation_date = SerenaConfig.get_config_file_creation_date() + if serena_config_creation_date is None: + # should not normally happen, since config file should exist when the dashboard is started + # We assume a fresh installation in this case + log.error("Serena config file not found when starting the dashboard") + return [] + serena_config_creation_date_int = int(serena_config_creation_date.strftime("%Y%m%d")) # Only include news items published on or after the installation date - all_news_ids = [news_id for news_id in all_news_ids if news_id >= installation_date_id] + post_installation_news_ids = [news_id for news_id in all_news_ids if news_id >= serena_config_creation_date_int] news_snippet_id_file = SerenaPaths().news_snippet_id_file if not os.path.exists(news_snippet_id_file): - return all_news_ids + return post_installation_news_ids with open(news_snippet_id_file, encoding="utf-8") as f: last_read_news_id = int(f.read().strip()) - return [news_id for news_id in all_news_ids if news_id > last_read_news_id] + return [news_id for news_id in post_installation_news_ids if news_id > last_read_news_id] try: unread_news_ids = _get_unread_news_ids() From 271e2006d225f749e04926a1f0c9b3b6e79371e6 Mon Sep 17 00:00:00 2001 From: Bahaa Zahika Date: Mon, 9 Feb 2026 01:08:37 +0200 Subject: [PATCH 049/105] Fix switch_modes tool silently failing to change modes set_modes() applied requested modes directly to self._active_modes, then called _update_active_modes_and_tools() which recreates _active_modes from scratch (global config -> project config -> CLI overrides), discarding the change. Fix: store the requested modes into self._mode_overrides so they survive the rebuild and take highest priority, matching the behavior of CLI --mode flags. Also adds test coverage for set_modes() which was previously untested. Bug was introduced in commit 40a3075 on Jan 26, 2026. --- src/serena/agent.py | 2 +- test/serena/test_set_modes.py | 75 +++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 test/serena/test_set_modes.py diff --git a/src/serena/agent.py b/src/serena/agent.py index a4b21978..1997b7de 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -522,7 +522,7 @@ class SerenaAgent: :param mode_names: List of mode names or paths to use """ - self._active_modes.apply(ModeSelectionDefinition(default_modes=mode_names)) + self._mode_overrides = ModeSelectionDefinition(default_modes=mode_names) self._update_active_modes_and_tools() log.info(f"Set modes to {[mode.name for mode in self.get_active_modes()]}") diff --git a/test/serena/test_set_modes.py b/test/serena/test_set_modes.py new file mode 100644 index 00000000..dadae4ba --- /dev/null +++ b/test/serena/test_set_modes.py @@ -0,0 +1,75 @@ +"""Tests for SerenaAgent.set_modes() to verify that mode switching works correctly.""" + +import logging + +from serena.agent import SerenaAgent +from serena.config.serena_config import ModeSelectionDefinition, SerenaConfig + + +class TestSetModes: + """Test that set_modes correctly changes active modes.""" + + def _create_agent(self, modes: ModeSelectionDefinition | None = None) -> SerenaAgent: + config = SerenaConfig(gui_log_window=False, web_dashboard=False, log_level=logging.ERROR) + return SerenaAgent(serena_config=config, modes=modes) + + def test_set_modes_changes_active_modes(self) -> None: + """Test that calling set_modes actually changes the active modes.""" + agent = self._create_agent(modes=ModeSelectionDefinition(default_modes=["editing", "interactive"])) + + initial_mode_names = sorted(m.name for m in agent.get_active_modes()) + assert "editing" in initial_mode_names + assert "interactive" in initial_mode_names + + # Switch to planning mode + agent.set_modes(["planning", "interactive"]) + + new_mode_names = sorted(m.name for m in agent.get_active_modes()) + assert "planning" in new_mode_names + assert "interactive" in new_mode_names + assert "editing" not in new_mode_names + + def test_set_modes_overrides_config_defaults(self) -> None: + """Test that set_modes takes precedence over config defaults.""" + config = SerenaConfig(gui_log_window=False, web_dashboard=False, log_level=logging.ERROR) + config.default_modes = ["editing", "interactive"] + agent = SerenaAgent(serena_config=config) + + # Verify config defaults are active + initial_mode_names = [m.name for m in agent.get_active_modes()] + assert "editing" in initial_mode_names + + # Switch modes — should override config defaults + agent.set_modes(["planning", "one-shot"]) + + new_mode_names = [m.name for m in agent.get_active_modes()] + assert "planning" in new_mode_names + assert "one-shot" in new_mode_names + assert "editing" not in new_mode_names + + def test_set_modes_persists_after_repeated_calls(self) -> None: + """Test that set_modes result persists (modes don't revert).""" + agent = self._create_agent(modes=ModeSelectionDefinition(default_modes=["editing"])) + + agent.set_modes(["planning"]) + mode_names_1 = [m.name for m in agent.get_active_modes()] + assert "planning" in mode_names_1 + + # Call get_active_modes again — should still be planning + mode_names_2 = [m.name for m in agent.get_active_modes()] + assert mode_names_1 == mode_names_2 + + def test_set_modes_can_switch_back(self) -> None: + """Test that modes can be switched back to original after switching away.""" + agent = self._create_agent(modes=ModeSelectionDefinition(default_modes=["editing", "interactive"])) + + # Switch away + agent.set_modes(["planning", "one-shot"]) + assert "planning" in [m.name for m in agent.get_active_modes()] + + # Switch back + agent.set_modes(["editing", "interactive"]) + mode_names = [m.name for m in agent.get_active_modes()] + assert "editing" in mode_names + assert "interactive" in mode_names + assert "planning" not in mode_names From 3b707283f0cd51a042febab58789336a00fb6b58 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Mon, 9 Feb 2026 02:04:50 +0100 Subject: [PATCH 050/105] Fix log message parameters #1004 --- src/serena/tools/jetbrains_plugin_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/serena/tools/jetbrains_plugin_client.py b/src/serena/tools/jetbrains_plugin_client.py index 08e3289f..6d98a0c8 100644 --- a/src/serena/tools/jetbrains_plugin_client.py +++ b/src/serena/tools/jetbrains_plugin_client.py @@ -96,7 +96,7 @@ class JetBrainsPluginClient(ToStringMixin): except ConnectionError: # expected if no server is running at the port pass except Exception as e: - log.warning("Failed to obtain status from JetBrains plugin service at port %d: %s", e, port, exc_info=True) + log.warning("Failed to obtain status from JetBrains plugin service at port %d: %s", port, e, exc_info=e) @property def _base_url(self) -> str: From 257d0370ee43859ef8547e27d0a3426abfed70e4 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Mon, 9 Feb 2026 12:05:53 +0100 Subject: [PATCH 051/105] Fix nix hover test - wrong position in test --- test/solidlsp/nix/test_nix_basic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/solidlsp/nix/test_nix_basic.py b/test/solidlsp/nix/test_nix_basic.py index d118904f..5c4e3293 100644 --- a/test/solidlsp/nix/test_nix_basic.py +++ b/test/solidlsp/nix/test_nix_basic.py @@ -119,7 +119,7 @@ class TestNixLanguageServer: def test_hover_information(self, language_server: SolidLanguageServer) -> None: """Test hover information for symbols.""" # Get hover info for makeGreeting function - hover_info = language_server.request_hover("default.nix", 9, 5) # Position near makeGreeting + hover_info = language_server.request_hover("default.nix", 12, 5) # Position at makeGreeting assert hover_info is not None, "Should provide hover information" From 23a860cdc09138ca0e0e9f23aeacabb70a29a586 Mon Sep 17 00:00:00 2001 From: Antonio Melillo Date: Mon, 9 Feb 2026 21:44:03 +0100 Subject: [PATCH 052/105] fix: Add Python artifacts to .dockerignore Docker build fails when local .venv directory exists because: 1. COPY . /workspaces/serena/ copies .venv into container 2. RUN uv venv fails with 'virtual environment already exists' This change prevents the issue by excluding Python development artifacts: - .venv (virtual environment) - __pycache__ (Python bytecode cache) - *.pyc (compiled Python files) - .pytest_cache (test cache) These files are not needed for Docker builds as the container creates its own clean environment. Co-authored-by: Cursor --- .dockerignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.dockerignore b/.dockerignore index a7c65ec6..237bd3b5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,3 +7,9 @@ docs/.jupyter_cache docs/_build coverage.xml docker_build_and_run.sh + +# Python artifacts (prevents Docker build conflicts when .venv exists locally) +.venv +__pycache__ +*.pyc +.pytest_cache From af6ce686adbac573e17f45f95f01e33e2a72cc40 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 11 Feb 2026 19:41:45 +0100 Subject: [PATCH 053/105] _postprocess_symbol_collection_response: Apply conversion recursively --- src/serena/tools/jetbrains_plugin_client.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/serena/tools/jetbrains_plugin_client.py b/src/serena/tools/jetbrains_plugin_client.py index 6d98a0c8..5474f051 100644 --- a/src/serena/tools/jetbrains_plugin_client.py +++ b/src/serena/tools/jetbrains_plugin_client.py @@ -252,11 +252,14 @@ class JetBrainsPluginClient(ToStringMixin): else: del symbol[key] - # convert documentation and quick info from HTML to plain text (if present) - symbols = response_dict["symbols"] - for s in symbols: - convert_html("documentation", s) - convert_html("quick_info", s) + def convert_symbol_list(l: list) -> None: + for s in l: + convert_html("documentation", s) + convert_html("quick_info", s) + if "children" in s: + convert_symbol_list(s["children"]) + + convert_symbol_list(response_dict["symbols"]) def find_symbol( self, From d65a99034b1d53ec55d355032ed4e8ced9ff3903 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 11 Feb 2026 21:02:23 +0100 Subject: [PATCH 054/105] Disable Phpactor tests that find references on Windows (takes forever/fails) --- test/solidlsp/php/test_php_basic.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/solidlsp/php/test_php_basic.py b/test/solidlsp/php/test_php_basic.py index 68207dca..5396687d 100644 --- a/test/solidlsp/php/test_php_basic.py +++ b/test/solidlsp/php/test_php_basic.py @@ -1,3 +1,4 @@ +import platform from pathlib import Path import pytest @@ -93,6 +94,9 @@ class TestPhpLanguageServers: @pytest.mark.parametrize("language_server", [Language.PHP, Language.PHP_PHPACTOR], indirect=True) @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) def test_find_references_within_file(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + if language_server.language == Language.PHP_PHPACTOR and platform.system() == "Windows": + pytest.skip("Phpactor references test not working on Windows, skipping for now") + index_php_path = str(repo_path / "index.php") # In index.php (0-indexed lines): @@ -141,6 +145,9 @@ class TestPhpLanguageServers: @pytest.mark.parametrize("language_server", [Language.PHP, Language.PHP_PHPACTOR], indirect=True) @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) def test_find_references_across_files(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + if language_server.language_server.language == Language.PHP_PHPACTOR and platform.system() == "Windows": + pytest.skip("Phpactor references test not working on Windows, skipping for now") + helper_php_path = str(repo_path / "helper.php") # In index.php (0-indexed lines): # Line 13: helperFunction(); // Usage of helperFunction From 3e71411780ae880a0d350e5b8db6624eb693d7a4 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 11 Feb 2026 21:17:27 +0100 Subject: [PATCH 055/105] Improve language requirements/alternatives documentation --- docs/01-about/020_programming-languages.md | 27 ++++++++++++---------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/docs/01-about/020_programming-languages.md b/docs/01-about/020_programming-languages.md index bd8b3643..87040308 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -29,11 +29,13 @@ Some languages require additional installations or setup steps, as noted. * **AL** * **Bash** -* **C#** +* **C#** + (by default, uses the Roslyn language server (language `csharp`); + set language to `csharp_omnisharp` to use OmiSharp instead) * **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. + (by default, uses the clangd language server (language `cpp`) but we also support ccls (language `cpp_ccls`); + 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** @@ -49,7 +51,7 @@ Some languages require additional installations or setup steps, as noted. * **Go** (requires installation of `gopls`) * **Groovy** - (requires local groovy-language-server.jar setup via GROOVY_LS_JAR_PATH or configuration) + (requires local groovy-language-server.jar setup via `GROOVY_LS_JAR_PATH` or configuration) * **Haskell** (automatically locates HLS via ghcup, stack, or system PATH; supports Stack and Cabal projects) * **Java** @@ -59,20 +61,21 @@ Some languages require additional installations or setup steps, as noted. (uses the pre-alpha [official kotlin LS](https://github.com/Kotlin/kotlin-lsp), some issues may appear) * **Lua** * **Markdown** - (must be explicitly specified via `--language markdown` when generating project config, primarily useful for documentation-heavy projects) + (must explicitly enable language `markdown`, primarily useful for documentation-heavy projects) * **Nix** (requires nixd installation) -* **Pascal** - (Free Pascal/Lazarus; automatically downloads pasls binary; set PP and FPCDIR environment variables for source navigation) -* **Perl** +* **Pascal** + (uses Pascal/Lazarus, which is automatically downloaded; set `PP` and `FPCDIR` environment variables for source navigation) +* **Perl** (requires installation of Perl::LanguageServer) -* **PHP** - (by default, uses Intelephense LSP; set `INTELEPHENSE_LICENSE_KEY` environment variable for premium features; specify `php_phpactor` as your language to use the open-source [Phpactor](https://github.com/phpactor/phpactor) implementation, requires PHP 8.1+) +* **PHP** + (by default, uses the Intelephense language server (language `php`), set `INTELEPHENSE_LICENSE_KEY` environment variable for premium features; + we also support [Phpactor](https://github.com/phpactor/phpactor) (language `php_phpactor`), which requires PHP 8.1+) * **Python** * **R** (requires installation of the `languageserver` R package) * **Ruby** - (by default, uses [ruby-lsp](https://github.com/Shopify/ruby-lsp), specify ruby_solargraph as your language to use the previous solargraph based implementation) + (by default, uses [ruby-lsp](https://github.com/Shopify/ruby-lsp) (language `ruby`); use language `ruby_solargraph` to use Solargraph instead.) * **Rust** (requires [rustup](https://rustup.rs/) - uses rust-analyzer from your toolchain) * **Scala** From be155a348ecbf30612d9135e268f6b0273a82214 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 12 Feb 2026 21:23:25 +0100 Subject: [PATCH 056/105] Improve JetBrains usage documentation Relates to #1027 --- docs/02-usage/025_jetbrains_plugin.md | 60 +++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/docs/02-usage/025_jetbrains_plugin.md b/docs/02-usage/025_jetbrains_plugin.md index 9d46778e..6d78abe9 100644 --- a/docs/02-usage/025_jetbrains_plugin.md +++ b/docs/02-usage/025_jetbrains_plugin.md @@ -73,6 +73,66 @@ If you need to work on multiple projects in the same agent session, create a mon containing all the projects and open that folder in both Serena and your IDE. ::: +## Advanced Usage and Configuration + +### Using Serena with Multi-Module Projects + +JetBrains IDEs support *multi-module projects*, where a project can reference other projects as modules. +Serena, however, requires that a project is self-contained within a single root folder. +There has to be a one-to-one relationship between the project root folder and the folder that is open in the IDE. + +Therefore, to get a multi-module setup working with Serena, the recommended approach is to create a **monorepo folder**, +i.e. a folder that contains all the projects as sub-folders, and open that monorepo folder in both Serena and your IDE. + +You do not necessarily need to physically move your projects into a common parent folder; +you can also use symbolic links to achieve the same effect +(i.e. use `mklink` on Windows or `ln` on Linux/macOS to link the project folders into a common parent folder). + +### Using Serena with Windows Subsystem for Linux (WSL) + +JetBrains IDEs have built-in support for WSL, allowing you to run the IDE on Windows while working with code in the WSL environment. +The Serena JetBrains plugin works seamlessly in this setup as well. You have two options: + + * **Windows-centric approach** (keeping things mainly in Windows) + * run both Serena and the IDE on Windows + * keep your project in the Windows file system, access it in WSL via `/mnt/` + (optionally linking the folder where you need it to be in the WSL file system) + + * **WSL-centric approach** (keeping things mainly in WSL) + * run Serena in WSL (while the IDE runs in Windows) + * make sure Serena in WSL can communicate with services running on Windows. + This will normally be the case when using mirrored networking; + otherwise, you can configure `jetbrains_plugin_server_address` in your [serena_config.yml](050_configuration) and, + if necessary, configure the listen address of the Serena plugin in the IDE (Settings / Tools / Serena). + +The WSL-centric approach has the downside that the WSL file system cannot be efficiently monitored by the IDE, +which can result in slower performance, especially for larger projects. If this is a problem, consider + * using the Windows-centric approach instead, or + * disabling the automatic synchronisation in the plugin and manually triggering synchronisation when needed (see below). + +## Serena Plugin Configuration Options + +You can configure plugin options in the IDE under Settings / Tools / Serena. + + * **Listen address** (default: `127.0.0.1`) + the address the plugin's server listens on. + The default will work as long as Serena is running on the same machine (or on a virtual machine using mirrored networking). + But if the Serena MCP server is running on a different machine, configure the listen address to ensure that connections are possible. + You can use `0.0.0.0` to listen on all interfaces (but be aware of the security implications of doing so). + + * **Sync file system before every operation** (default: enabled) + whether to synchronise the file system state before processing requests from Serena. + This is important to ensure that the plugin does not read stale data, but it can have a performance impact, + especially when using slow file systems (e.g. WSL file system while the IDE is running on Windows). + Note, however, that without synchronisation being forced by the Serena plugin, you will have to ensure synchronisation yourself. + Operations that apply changes to files in your project that are *not* made either in the IDE itself or by Serena may not be seen by the IDE. + Normally, the IDE synchronises automatically when it has the focus, using file watchers to achieve this (though this may or may not work reliably for the WSL file system). + Also, if you are working primarily in another application (e.g. AI chat), the IDE may not have the focus frequently. + So when external changes are made to your project, you will have to either give the IDE the focus (if that works) or trigger a sync manually (right-click root folder / Reload from Disk). + Further, note that even an edit made using, for example, Claude Code's internal editing tools would count as an external modification. + Only Serena's editing tools are "JetBrains-aware" and will tell the IDE to update the state of the edited file. + So if you are making AI-based edits using tools other than Serena's tools, do make sure that the lack of synchronisation is not a problem if you decide to disable this option. + ## Usage with Other Editors We realize that not everyone uses a JetBrains IDE as their main code editor. From 58f2727800d95e9999f11afa4cc6ceacccc8d385 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 13 Feb 2026 02:44:15 +0100 Subject: [PATCH 057/105] BashLanguageServer: Fix run command failing in case of spaces in path --- src/solidlsp/language_servers/bash_language_server.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/solidlsp/language_servers/bash_language_server.py b/src/solidlsp/language_servers/bash_language_server.py index 7b231830..d9a1429f 100644 --- a/src/solidlsp/language_servers/bash_language_server.py +++ b/src/solidlsp/language_servers/bash_language_server.py @@ -30,11 +30,11 @@ class BashLanguageServer(SolidLanguageServer): Creates a BashLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ - bash_lsp_executable_path = self._setup_runtime_dependencies(config, solidlsp_settings) + cmd = self._setup_runtime_dependencies(config, solidlsp_settings) super().__init__( config, repository_root_path, - ProcessLaunchInfo(cmd=bash_lsp_executable_path, cwd=repository_root_path), + ProcessLaunchInfo(cmd=cmd, cwd=repository_root_path), "bash", solidlsp_settings, ) @@ -42,7 +42,7 @@ class BashLanguageServer(SolidLanguageServer): self.initialize_searcher_command_available = threading.Event() @classmethod - def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> str: + def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> list[str]: """ Setup runtime dependencies for Bash Language Server and return the command to start the server. """ @@ -80,7 +80,7 @@ class BashLanguageServer(SolidLanguageServer): raise FileNotFoundError( f"bash-language-server executable not found at {bash_executable_path}, something went wrong with the installation." ) - return f"{bash_executable_path} start" + return [bash_executable_path, "start"] @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: From 4809d5882b6cd6bf3b10123e18a8b23255f03338 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 13 Feb 2026 02:55:50 +0100 Subject: [PATCH 058/105] TaploLanguageServer: Use DependencyProvider, fixing run command not working with spaces in path --- src/solidlsp/language_servers/taplo_server.py | 188 +++++++++--------- 1 file changed, 96 insertions(+), 92 deletions(-) diff --git a/src/solidlsp/language_servers/taplo_server.py b/src/solidlsp/language_servers/taplo_server.py index f44e2d8f..ea6de30e 100644 --- a/src/solidlsp/language_servers/taplo_server.py +++ b/src/solidlsp/language_servers/taplo_server.py @@ -17,11 +17,10 @@ from typing import Any # Download timeout in seconds (prevents indefinite hangs) DOWNLOAD_TIMEOUT_SECONDS = 120 -from solidlsp.ls import SolidLanguageServer +from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_utils import PathUtils 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__) @@ -119,115 +118,120 @@ class TaploServer(SolidLanguageServer): Creates a TaploServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ - taplo_executable_path = self._setup_runtime_dependencies(solidlsp_settings) super().__init__( config, repository_root_path, - ProcessLaunchInfo(cmd=f"{taplo_executable_path} lsp stdio", cwd=repository_root_path), + None, "toml", solidlsp_settings, ) - @classmethod - def _setup_runtime_dependencies(cls, solidlsp_settings: SolidLSPSettings) -> str: - """ - Setup runtime dependencies for Taplo and return the command to start the server. - """ - # First check if taplo is already installed system-wide - system_taplo = shutil.which("taplo") - if system_taplo: - log.info(f"Using system-installed Taplo at: {system_taplo}") - return system_taplo + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) - # Setup local installation directory - taplo_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "taplo") - os.makedirs(taplo_dir, exist_ok=True) + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + def _get_or_install_core_dependency(self) -> str: + """ + Setup runtime dependencies for Taplo and return the command to start the server. + """ + # First check if taplo is already installed system-wide + system_taplo = shutil.which("taplo") + if system_taplo: + log.info(f"Using system-installed Taplo at: {system_taplo}") + return system_taplo - _, executable_name = _get_taplo_download_url() - taplo_executable = os.path.join(taplo_dir, executable_name) + # Setup local installation directory + taplo_dir = os.path.join(self._ls_resources_dir, "taplo") + os.makedirs(taplo_dir, exist_ok=True) - if os.path.exists(taplo_executable) and os.access(taplo_executable, os.X_OK): - log.info(f"Using cached Taplo at: {taplo_executable}") - return taplo_executable + _, executable_name = _get_taplo_download_url() + taplo_executable = os.path.join(taplo_dir, executable_name) - # Download and install Taplo - log.info(f"Taplo not found. Downloading version {TAPLO_VERSION}...") - cls._download_taplo(taplo_dir, taplo_executable) + if os.path.exists(taplo_executable) and os.access(taplo_executable, os.X_OK): + log.info(f"Using cached Taplo at: {taplo_executable}") + return taplo_executable - if not os.path.exists(taplo_executable): - raise FileNotFoundError( - f"Taplo executable not found at {taplo_executable}. " - "Installation may have failed. Try installing manually: cargo install taplo-cli --locked" - ) + # Download and install Taplo + log.info(f"Taplo not found. Downloading version {TAPLO_VERSION}...") + self._download_taplo(taplo_dir, taplo_executable) - return taplo_executable - - @classmethod - def _download_taplo(cls, install_dir: str, executable_path: str) -> None: - """Download and extract Taplo binary with SHA256 verification.""" - # TODO: consider using existing download utilities in SolidLSP instead of the custom logic here - download_url, _ = _get_taplo_download_url() - archive_filename = os.path.basename(download_url) - - try: - log.info(f"Downloading Taplo from: {download_url}") - archive_path = os.path.join(install_dir, archive_filename) - - # Download the archive with timeout to prevent indefinite hangs - old_timeout = socket.getdefaulttimeout() - try: - socket.setdefaulttimeout(DOWNLOAD_TIMEOUT_SECONDS) - urllib.request.urlretrieve(download_url, archive_path) - finally: - socket.setdefaulttimeout(old_timeout) - - # Verify SHA256 checksum - expected_hash = TAPLO_SHA256_CHECKSUMS.get(archive_filename) - if expected_hash: - if not _verify_sha256(archive_path, expected_hash): - os.remove(archive_path) - raise RuntimeError( - f"SHA256 checksum verification failed for {archive_filename}. " - "The downloaded file may be corrupted or tampered with. " - "Try installing manually: cargo install taplo-cli --locked" - ) - log.info(f"SHA256 checksum verified for {archive_filename}") - else: - log.warning( - f"No SHA256 checksum available for {archive_filename}. " - "Skipping verification - consider installing manually: cargo install taplo-cli --locked" + if not os.path.exists(taplo_executable): + raise FileNotFoundError( + f"Taplo executable not found at {taplo_executable}. " + "Installation may have failed. Try installing manually: cargo install taplo-cli --locked" ) - # Extract based on format - if archive_path.endswith(".gz") and not archive_path.endswith(".tar.gz"): - # Single file gzip - with gzip.open(archive_path, "rb") as f_in: - with open(executable_path, "wb") as f_out: - f_out.write(f_in.read()) - elif archive_path.endswith(".zip"): - import zipfile + return taplo_executable - with zipfile.ZipFile(archive_path, "r") as zip_ref: - # Security: Validate paths to prevent zip slip vulnerability - for member in zip_ref.namelist(): - member_path = os.path.normpath(os.path.join(install_dir, member)) - if not member_path.startswith(os.path.normpath(install_dir)): - raise RuntimeError(f"Zip slip detected: {member} attempts to escape install directory") - zip_ref.extractall(install_dir) + def _create_launch_command(self, core_path: str) -> list[str] | str: + return [core_path, "lsp", "stdio"] - # Make executable on Unix systems - if os.name != "nt": - os.chmod(executable_path, os.stat(executable_path).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + @classmethod + def _download_taplo(cls, install_dir: str, executable_path: str) -> None: + """Download and extract Taplo binary with SHA256 verification.""" + # TODO: consider using existing download utilities in SolidLSP instead of the custom logic here + download_url, _ = _get_taplo_download_url() + archive_filename = os.path.basename(download_url) - # Clean up archive - os.remove(archive_path) - log.info(f"Taplo installed successfully at: {executable_path}") + try: + log.info(f"Downloading Taplo from: {download_url}") + archive_path = os.path.join(install_dir, archive_filename) - except Exception as e: - log.error(f"Failed to download Taplo: {e}") - raise RuntimeError( - f"Failed to download Taplo from {download_url}. Try installing manually: cargo install taplo-cli --locked" - ) from e + # Download the archive with timeout to prevent indefinite hangs + old_timeout = socket.getdefaulttimeout() + try: + socket.setdefaulttimeout(DOWNLOAD_TIMEOUT_SECONDS) + urllib.request.urlretrieve(download_url, archive_path) + finally: + socket.setdefaulttimeout(old_timeout) + + # Verify SHA256 checksum + expected_hash = TAPLO_SHA256_CHECKSUMS.get(archive_filename) + if expected_hash: + if not _verify_sha256(archive_path, expected_hash): + os.remove(archive_path) + raise RuntimeError( + f"SHA256 checksum verification failed for {archive_filename}. " + "The downloaded file may be corrupted or tampered with. " + "Try installing manually: cargo install taplo-cli --locked" + ) + log.info(f"SHA256 checksum verified for {archive_filename}") + else: + log.warning( + f"No SHA256 checksum available for {archive_filename}. " + "Skipping verification - consider installing manually: cargo install taplo-cli --locked" + ) + + # Extract based on format + if archive_path.endswith(".gz") and not archive_path.endswith(".tar.gz"): + # Single file gzip + with gzip.open(archive_path, "rb") as f_in: + with open(executable_path, "wb") as f_out: + f_out.write(f_in.read()) + elif archive_path.endswith(".zip"): + import zipfile + + with zipfile.ZipFile(archive_path, "r") as zip_ref: + # Security: Validate paths to prevent zip slip vulnerability + for member in zip_ref.namelist(): + member_path = os.path.normpath(os.path.join(install_dir, member)) + if not member_path.startswith(os.path.normpath(install_dir)): + raise RuntimeError(f"Zip slip detected: {member} attempts to escape install directory") + zip_ref.extractall(install_dir) + + # Make executable on Unix systems + if os.name != "nt": + os.chmod(executable_path, os.stat(executable_path).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + # Clean up archive + os.remove(archive_path) + log.info(f"Taplo installed successfully at: {executable_path}") + + except Exception as e: + log.error(f"Failed to download Taplo: {e}") + raise RuntimeError( + f"Failed to download Taplo from {download_url}. Try installing manually: cargo install taplo-cli --locked" + ) from e @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: From c195b2961a94c75d13e3d559a7de52f06e63236e Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 13 Feb 2026 01:37:09 +0100 Subject: [PATCH 059/105] Handle end_col when extracting symbol body Fixes #1029 --- src/solidlsp/ls.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 6286776f..5a0bc625 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -18,6 +18,7 @@ from typing import Self, Union, cast import pathspec from sensai.util.pickle import getstate, load_pickle +from sensai.util.string import ToStringMixin from serena.text_utils import MatchedConsecutiveLines from serena.util.file_system import match_path @@ -130,7 +131,7 @@ class LSPFileBuffer: return self.contents.split("\n") -class SymbolBody: +class SymbolBody(ToStringMixin): """ Representation of the body of a symbol, which allows the extraction of the symbol's text from the lines of the file it is defined in. @@ -147,14 +148,21 @@ class SymbolBody: self._end_line = end_line self._end_col = end_col + def _tostring_excludes(self) -> list[str]: + return ["_lines"] + def get_text(self) -> str: # extract relevant lines symbol_body = "\n".join(self._lines[self._start_line : self._end_line + 1]) - # remove leading indentation + # remove leading content from the first line symbol_body = symbol_body[self._start_col :] - # TODO: handle end_col properly (this was never implemented) + # remove trailing content from the last line + last_line = self._lines[self._end_line] + trailing_length = len(last_line) - self._end_col + if trailing_length > 0: + symbol_body = symbol_body[: -(len(last_line) - self._end_col)] return symbol_body From 754806af3e1a6d62e8b864fe85acff87c61d3aed Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 13 Feb 2026 02:59:35 +0100 Subject: [PATCH 060/105] BashLanguageServer: Use DependencyProvider --- .../language_servers/bash_language_server.py | 93 +++++++++++-------- 1 file changed, 52 insertions(+), 41 deletions(-) diff --git a/src/solidlsp/language_servers/bash_language_server.py b/src/solidlsp/language_servers/bash_language_server.py index d9a1429f..fddce311 100644 --- a/src/solidlsp/language_servers/bash_language_server.py +++ b/src/solidlsp/language_servers/bash_language_server.py @@ -10,10 +10,15 @@ import shutil import threading from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection -from solidlsp.ls import DocumentSymbols, LSPFileBuffer, SolidLanguageServer +from solidlsp.ls import ( + DocumentSymbols, + LanguageServerDependencyProvider, + LanguageServerDependencyProviderSinglePath, + LSPFileBuffer, + 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__) @@ -30,57 +35,63 @@ class BashLanguageServer(SolidLanguageServer): Creates a BashLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ - cmd = self._setup_runtime_dependencies(config, solidlsp_settings) super().__init__( config, repository_root_path, - ProcessLaunchInfo(cmd=cmd, cwd=repository_root_path), + None, "bash", solidlsp_settings, ) self.server_ready = threading.Event() self.initialize_searcher_command_available = threading.Event() - @classmethod - def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> list[str]: - """ - Setup runtime dependencies for Bash Language Server and return the command to start the server. - """ - # Verify both node and npm are installed - is_node_installed = shutil.which("node") is not None - assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." - is_npm_installed = shutil.which("npm") is not None - assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) - deps = RuntimeDependencyCollection( - [ - RuntimeDependency( - id="bash-language-server", - description="bash-language-server package", - command="npm install --prefix ./ bash-language-server@5.6.0", - platform_id="any", - ), - ] - ) + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + def _get_or_install_core_dependency(self) -> str: + """ + Setup runtime dependencies for Bash Language Server and return the command to start the server. + """ + # Verify both node and npm are installed + is_node_installed = shutil.which("node") is not None + assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." + is_npm_installed = shutil.which("npm") is not None + assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." - # Install bash-language-server if not already installed - bash_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "bash-lsp") - bash_executable_path = os.path.join(bash_ls_dir, "node_modules", ".bin", "bash-language-server") - - # Handle Windows executable extension - if os.name == "nt": - bash_executable_path += ".cmd" - - if not os.path.exists(bash_executable_path): - log.info(f"Bash Language Server executable not found at {bash_executable_path}. Installing...") - deps.install(bash_ls_dir) - log.info("Bash language server dependencies installed successfully") - - if not os.path.exists(bash_executable_path): - raise FileNotFoundError( - f"bash-language-server executable not found at {bash_executable_path}, something went wrong with the installation." + deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="bash-language-server", + description="bash-language-server package", + command="npm install --prefix ./ bash-language-server@5.6.0", + platform_id="any", + ), + ] ) - return [bash_executable_path, "start"] + + # Install bash-language-server if not already installed + bash_ls_dir = os.path.join(self._ls_resources_dir, "bash-lsp") + bash_executable_path = os.path.join(bash_ls_dir, "node_modules", ".bin", "bash-language-server") + + # Handle Windows executable extension + if os.name == "nt": + bash_executable_path += ".cmd" + + if not os.path.exists(bash_executable_path): + log.info(f"Bash Language Server executable not found at {bash_executable_path}. Installing...") + deps.install(bash_ls_dir) + log.info("Bash language server dependencies installed successfully") + + if not os.path.exists(bash_executable_path): + raise FileNotFoundError( + f"bash-language-server executable not found at {bash_executable_path}, something went wrong with the installation." + ) + + return bash_executable_path + + def _create_launch_command(self, core_path: str) -> list[str] | str: + return [core_path, "start"] @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: From 1ab7e0aa97ff5d97acb19c3dbdc9723531ab6f44 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 13 Feb 2026 03:02:59 +0100 Subject: [PATCH 061/105] YamlLanguageServer: Use DependencyProvider, fixing run command not working with spaces in path --- .../language_servers/yaml_language_server.py | 87 ++++++++++--------- 1 file changed, 46 insertions(+), 41 deletions(-) diff --git a/src/solidlsp/language_servers/yaml_language_server.py b/src/solidlsp/language_servers/yaml_language_server.py index bb813a2a..d78b80e2 100644 --- a/src/solidlsp/language_servers/yaml_language_server.py +++ b/src/solidlsp/language_servers/yaml_language_server.py @@ -10,10 +10,9 @@ import shutil from typing import Any from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection -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__) @@ -46,55 +45,61 @@ class YamlLanguageServer(SolidLanguageServer): Creates a YamlLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ - yaml_lsp_executable_path = self._setup_runtime_dependencies(config, solidlsp_settings) super().__init__( config, repository_root_path, - ProcessLaunchInfo(cmd=yaml_lsp_executable_path, cwd=repository_root_path), + None, "yaml", solidlsp_settings, ) - @classmethod - def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> str: - """ - Setup runtime dependencies for YAML Language Server and return the command to start the server. - """ - # Verify both node and npm are installed - is_node_installed = shutil.which("node") is not None - assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." - is_npm_installed = shutil.which("npm") is not None - assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) - deps = RuntimeDependencyCollection( - [ - RuntimeDependency( - id="yaml-language-server", - description="yaml-language-server package (Red Hat)", - command="npm install --prefix ./ yaml-language-server@1.19.2", - platform_id="any", - ), - ] - ) + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + def _get_or_install_core_dependency(self) -> str: + """ + Setup runtime dependencies for YAML Language Server and return the command to start the server. + """ + # Verify both node and npm are installed + is_node_installed = shutil.which("node") is not None + assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." + is_npm_installed = shutil.which("npm") is not None + assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." - # Install yaml-language-server if not already installed - yaml_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "yaml-lsp") - yaml_executable_path = os.path.join(yaml_ls_dir, "node_modules", ".bin", "yaml-language-server") - - # Handle Windows executable extension - if os.name == "nt": - yaml_executable_path += ".cmd" - - if not os.path.exists(yaml_executable_path): - log.info(f"YAML Language Server executable not found at {yaml_executable_path}. Installing...") - deps.install(yaml_ls_dir) - log.info("YAML language server dependencies installed successfully") - - if not os.path.exists(yaml_executable_path): - raise FileNotFoundError( - f"yaml-language-server executable not found at {yaml_executable_path}, something went wrong with the installation." + deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="yaml-language-server", + description="yaml-language-server package (Red Hat)", + command="npm install --prefix ./ yaml-language-server@1.19.2", + platform_id="any", + ), + ] ) - return f"{yaml_executable_path} --stdio" + + # Install yaml-language-server if not already installed + yaml_ls_dir = os.path.join(self._ls_resources_dir, "yaml-lsp") + yaml_executable_path = os.path.join(yaml_ls_dir, "node_modules", ".bin", "yaml-language-server") + + # Handle Windows executable extension + if os.name == "nt": + yaml_executable_path += ".cmd" + + if not os.path.exists(yaml_executable_path): + log.info(f"YAML Language Server executable not found at {yaml_executable_path}. Installing...") + deps.install(yaml_ls_dir) + log.info("YAML language server dependencies installed successfully") + + if not os.path.exists(yaml_executable_path): + raise FileNotFoundError( + f"yaml-language-server executable not found at {yaml_executable_path}, something went wrong with the installation." + ) + + return yaml_executable_path + + def _create_launch_command(self, core_path: str) -> list[str]: + return [core_path, "--stdio"] @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: From 8c54806abe42dccdc41884b65db66c66a637d5b6 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 13 Feb 2026 03:10:25 +0100 Subject: [PATCH 062/105] Marksman: Use DependencyProvider, fixing run command not working with spaces in path --- src/solidlsp/language_servers/marksman.py | 125 +++++++++++----------- 1 file changed, 64 insertions(+), 61 deletions(-) diff --git a/src/solidlsp/language_servers/marksman.py b/src/solidlsp/language_servers/marksman.py index 97c840e2..14805f21 100644 --- a/src/solidlsp/language_servers/marksman.py +++ b/src/solidlsp/language_servers/marksman.py @@ -9,10 +9,9 @@ import pathlib 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 from .common import RuntimeDependency, RuntimeDependencyCollection @@ -25,80 +24,84 @@ class Marksman(SolidLanguageServer): Provides Markdown specific instantiation of the LanguageServer class using marksman. """ - marksman_releases = "https://github.com/artempyanykh/marksman/releases/download/2024-12-18" - runtime_dependencies = RuntimeDependencyCollection( - [ - RuntimeDependency( - id="marksman", - url=f"{marksman_releases}/marksman-linux-x64", - platform_id="linux-x64", - archive_type="binary", - binary_name="marksman", - ), - RuntimeDependency( - id="marksman", - url=f"{marksman_releases}/marksman-linux-arm64", - platform_id="linux-arm64", - archive_type="binary", - binary_name="marksman", - ), - RuntimeDependency( - id="marksman", - url=f"{marksman_releases}/marksman-macos", - platform_id="osx-x64", - archive_type="binary", - binary_name="marksman", - ), - RuntimeDependency( - id="marksman", - url=f"{marksman_releases}/marksman-macos", - platform_id="osx-arm64", - archive_type="binary", - binary_name="marksman", - ), - RuntimeDependency( - id="marksman", - url=f"{marksman_releases}/marksman.exe", - platform_id="win-x64", - archive_type="binary", - binary_name="marksman.exe", - ), - ] - ) + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + marksman_releases = "https://github.com/artempyanykh/marksman/releases/download/2024-12-18" + runtime_dependencies = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="marksman", + url=f"{marksman_releases}/marksman-linux-x64", + platform_id="linux-x64", + archive_type="binary", + binary_name="marksman", + ), + RuntimeDependency( + id="marksman", + url=f"{marksman_releases}/marksman-linux-arm64", + platform_id="linux-arm64", + archive_type="binary", + binary_name="marksman", + ), + RuntimeDependency( + id="marksman", + url=f"{marksman_releases}/marksman-macos", + platform_id="osx-x64", + archive_type="binary", + binary_name="marksman", + ), + RuntimeDependency( + id="marksman", + url=f"{marksman_releases}/marksman-macos", + platform_id="osx-arm64", + archive_type="binary", + binary_name="marksman", + ), + RuntimeDependency( + id="marksman", + url=f"{marksman_releases}/marksman.exe", + platform_id="win-x64", + archive_type="binary", + binary_name="marksman.exe", + ), + ] + ) - @classmethod - def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> str: - """Setup runtime dependencies for marksman and return the command to start the server.""" - deps = cls.runtime_dependencies - dependency = deps.get_single_dep_for_current_platform() + def _get_or_install_core_dependency(self) -> str: + """Setup runtime dependencies for marksman and return the command to start the server.""" + deps = self.runtime_dependencies + dependency = deps.get_single_dep_for_current_platform() - marksman_ls_dir = cls.ls_resources_dir(solidlsp_settings) - marksman_executable_path = deps.binary_path(marksman_ls_dir) - if not os.path.exists(marksman_executable_path): - log.info( - f"Downloading marksman from {dependency.url} to {marksman_ls_dir}", - ) - deps.install(marksman_ls_dir) - if not os.path.exists(marksman_executable_path): - raise FileNotFoundError(f"Download failed? Could not find marksman executable at {marksman_executable_path}") - os.chmod(marksman_executable_path, 0o755) - return marksman_executable_path + marksman_ls_dir = self._ls_resources_dir + marksman_executable_path = deps.binary_path(marksman_ls_dir) + if not os.path.exists(marksman_executable_path): + log.info( + f"Downloading marksman from {dependency.url} to {marksman_ls_dir}", + ) + deps.install(marksman_ls_dir) + if not os.path.exists(marksman_executable_path): + raise FileNotFoundError(f"Download failed? Could not find marksman executable at {marksman_executable_path}") + os.chmod(marksman_executable_path, 0o755) + return marksman_executable_path + + def _create_launch_command(self, core_path: str) -> list[str]: + return [core_path, "server"] def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a Marksman instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ - marksman_executable_path = self._setup_runtime_dependencies(config, solidlsp_settings) - super().__init__( config, repository_root_path, - ProcessLaunchInfo(cmd=f"{marksman_executable_path} server", cwd=repository_root_path), + None, "markdown", solidlsp_settings, ) + 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 ["node_modules", ".obsidian", ".vitepress", ".vuepress"] From cd2f6f6717a668d1efbb6125475d20da1422b129 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 13 Feb 2026 03:04:07 +0100 Subject: [PATCH 063/105] Update list of LS supporting ls_path --- docs/02-usage/050_configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index 51204018..88192cb5 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -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`, `rust`, `typescript`. +Currently, this includes the following languages: `bash`, `clojure`, `cpp`, `markdown`, `php`, `php_phpactor`, `python`, `rust`, `toml`, `typescript`, `yaml`. We will add support for more languages over time. #### C# (Roslyn Language Server) From 256cb61416f9adfbfdd8e1b36d29ecaff3c80baf Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 13 Feb 2026 03:20:13 +0100 Subject: [PATCH 064/105] DependencyProviders: Allow only lists for commands to ensure robustness --- .../language_servers/bash_language_server.py | 2 +- .../language_servers/ccls_language_server.py | 2 +- src/solidlsp/language_servers/clojure_lsp.py | 2 +- .../language_servers/csharp_language_server.py | 2 +- src/solidlsp/language_servers/eclipse_jdtls.py | 2 +- .../language_servers/matlab_language_server.py | 4 ++-- src/solidlsp/language_servers/rust_analyzer.py | 2 +- src/solidlsp/language_servers/taplo_server.py | 2 +- src/solidlsp/ls.py | 12 +++++------- 9 files changed, 14 insertions(+), 16 deletions(-) diff --git a/src/solidlsp/language_servers/bash_language_server.py b/src/solidlsp/language_servers/bash_language_server.py index fddce311..b868cebb 100644 --- a/src/solidlsp/language_servers/bash_language_server.py +++ b/src/solidlsp/language_servers/bash_language_server.py @@ -90,7 +90,7 @@ class BashLanguageServer(SolidLanguageServer): return bash_executable_path - def _create_launch_command(self, core_path: str) -> list[str] | str: + def _create_launch_command(self, core_path: str) -> list[str]: return [core_path, "start"] @staticmethod diff --git a/src/solidlsp/language_servers/ccls_language_server.py b/src/solidlsp/language_servers/ccls_language_server.py index f8a0db45..ade8aaf4 100644 --- a/src/solidlsp/language_servers/ccls_language_server.py +++ b/src/solidlsp/language_servers/ccls_language_server.py @@ -91,7 +91,7 @@ class CCLS(SolidLanguageServer): log.info(f"Using system-installed ccls at {ccls_path}") return ccls_path - def _create_launch_command(self, core_path: str) -> list[str] | str: + def _create_launch_command(self, core_path: str) -> list[str]: return [core_path] @staticmethod diff --git a/src/solidlsp/language_servers/clojure_lsp.py b/src/solidlsp/language_servers/clojure_lsp.py index 5ed597c0..505f8228 100644 --- a/src/solidlsp/language_servers/clojure_lsp.py +++ b/src/solidlsp/language_servers/clojure_lsp.py @@ -123,7 +123,7 @@ class ClojureLSP(SolidLanguageServer): os.chmod(clojurelsp_executable_path, 0o755) return clojurelsp_executable_path - def _create_launch_command(self, core_path: str) -> list[str] | str: + def _create_launch_command(self, core_path: str) -> list[str]: return [core_path] @staticmethod diff --git a/src/solidlsp/language_servers/csharp_language_server.py b/src/solidlsp/language_servers/csharp_language_server.py index a5f4f040..ee33aa23 100644 --- a/src/solidlsp/language_servers/csharp_language_server.py +++ b/src/solidlsp/language_servers/csharp_language_server.py @@ -306,7 +306,7 @@ class CSharpLanguageServer(SolidLanguageServer): self._repository_root_path = repository_root_path self._dotnet_path, self._language_server_path = self._ensure_server_installed() - def create_launch_command(self) -> list[str] | str: + def create_launch_command(self) -> list[str]: # Find solution or project file solution_or_project = find_solution_or_project_file(self._repository_root_path) diff --git a/src/solidlsp/language_servers/eclipse_jdtls.py b/src/solidlsp/language_servers/eclipse_jdtls.py index 880a5adb..773776d9 100644 --- a/src/solidlsp/language_servers/eclipse_jdtls.py +++ b/src/solidlsp/language_servers/eclipse_jdtls.py @@ -271,7 +271,7 @@ class EclipseJDTLS(SolidLanguageServer): intellisense_members_path=intellisense_members_path, ) - def create_launch_command(self) -> list[str] | str: + def create_launch_command(self) -> list[str]: # ws_dir is the workspace directory for the EclipseJDTLS server ws_dir = str( PurePath( diff --git a/src/solidlsp/language_servers/matlab_language_server.py b/src/solidlsp/language_servers/matlab_language_server.py index bd16fc03..1b9ef439 100644 --- a/src/solidlsp/language_servers/matlab_language_server.py +++ b/src/solidlsp/language_servers/matlab_language_server.py @@ -38,7 +38,7 @@ import platform import shutil import threading import zipfile -from typing import Any, Union, cast +from typing import Any, cast import requests @@ -339,7 +339,7 @@ class MatlabLanguageServer(SolidLanguageServer): self._matlab_path = matlab_path return matlab_path - def create_launch_command(self) -> Union[str, list[str]]: + def create_launch_command(self) -> list[str]: # Verify node is installed node_path = shutil.which("node") if node_path is None: diff --git a/src/solidlsp/language_servers/rust_analyzer.py b/src/solidlsp/language_servers/rust_analyzer.py index 259acb85..b7c95947 100644 --- a/src/solidlsp/language_servers/rust_analyzer.py +++ b/src/solidlsp/language_servers/rust_analyzer.py @@ -165,7 +165,7 @@ class RustAnalyzer(SolidLanguageServer): def _get_or_install_core_dependency(self) -> str: return self._ensure_rust_analyzer_installed() - def _create_launch_command(self, core_path: str) -> list[str] | str: + def _create_launch_command(self, core_path: str) -> list[str]: return [core_path] def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): diff --git a/src/solidlsp/language_servers/taplo_server.py b/src/solidlsp/language_servers/taplo_server.py index ea6de30e..213af874 100644 --- a/src/solidlsp/language_servers/taplo_server.py +++ b/src/solidlsp/language_servers/taplo_server.py @@ -163,7 +163,7 @@ class TaploServer(SolidLanguageServer): return taplo_executable - def _create_launch_command(self, core_path: str) -> list[str] | str: + def _create_launch_command(self, core_path: str) -> list[str]: return [core_path, "lsp", "stdio"] @classmethod diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 5a0bc625..67d95361 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -241,13 +241,12 @@ class LanguageServerDependencyProvider(ABC): self._ls_resources_dir = ls_resources_dir @abstractmethod - def create_launch_command(self) -> list[str] | str: + def create_launch_command(self) -> list[str]: """ Creates the launch command for this language server, potentially downloading and installing dependencies beforehand. - :return: the launch command as a list containing the executable and its arguments (preferred for robustness) - or the entire command in a single string. + :return: the launch command as a list containing the executable and its arguments """ def create_launch_command_env(self) -> dict[str, str]: @@ -279,7 +278,7 @@ class LanguageServerDependencyProviderSinglePath(LanguageServerDependencyProvide :return: the core dependency's path (e.g. executable, jar, etc.) """ - def create_launch_command(self) -> Union[str, list[str]]: + def create_launch_command(self) -> list[str]: path = self._custom_settings.get("ls_path", None) if path is not None: core_path = path @@ -288,11 +287,10 @@ class LanguageServerDependencyProviderSinglePath(LanguageServerDependencyProvide return self._create_launch_command(core_path) @abstractmethod - def _create_launch_command(self, core_path: str) -> list[str] | str: + def _create_launch_command(self, core_path: str) -> list[str]: """ :param core_path: path to the core dependency - :return: the launch command as a list containing the executable and its arguments (preferred for robustness) - or the entire command in a single string. + :return: the launch command as a list containing the executable and its arguments """ From f2beb3bb16c725051cb64068a1ccdf188366e296 Mon Sep 17 00:00:00 2001 From: takeokunn Date: Fri, 13 Feb 2026 15:37:29 +0900 Subject: [PATCH 065/105] Project: accept SerenaConfig instead of global_ignored_paths Replace `global_ignored_paths: Sequence[str]` with `serena_config: SerenaConfig | None` in Project.__init__ and propagate through RegisteredProject for more general and future-proof interfaces. --- src/serena/cli.py | 8 +++---- src/serena/config/serena_config.py | 24 +++++++++---------- src/serena/project.py | 23 ++++++++++-------- .../config/test_global_ignored_paths.py | 18 +++++++++----- 4 files changed, 41 insertions(+), 32 deletions(-) diff --git a/src/serena/cli.py b/src/serena/cli.py index 3a1b968e..ee36be22 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -567,7 +567,7 @@ class ProjectCommands(AutoRegisteringGroup): serena_config = SerenaConfig.from_config_file() registered_project = serena_config.get_registered_project(str(project_root)) if registered_project is None: - registered_project = RegisteredProject(str(project_root), generated_conf, global_ignored_paths=serena_config.ignored_paths) + registered_project = RegisteredProject(str(project_root), generated_conf, serena_config=serena_config) serena_config.add_registered_project(registered_project) return registered_project @@ -691,7 +691,7 @@ class ProjectCommands(AutoRegisteringGroup): :param project: The path to the project directory, defaults to the current working directory. """ serena_config = SerenaConfig.from_config_file() - proj = Project.load(os.path.abspath(project), global_ignored_paths=serena_config.ignored_paths) + proj = Project.load(os.path.abspath(project), serena_config=serena_config) if os.path.isabs(path): path = os.path.relpath(path, start=proj.project_root) is_ignored = proj.is_ignored_path(path) @@ -714,7 +714,7 @@ class ProjectCommands(AutoRegisteringGroup): :param verbose: if set, prints detailed information about the indexed symbols. """ serena_config = SerenaConfig.from_config_file() - proj = Project.load(os.path.abspath(project), global_ignored_paths=serena_config.ignored_paths) + proj = Project.load(os.path.abspath(project), serena_config=serena_config) if os.path.isabs(file): file = os.path.relpath(file, start=proj.project_root) if proj.is_ignored_path(file, ignore_non_source_files=True): @@ -752,7 +752,7 @@ class ProjectCommands(AutoRegisteringGroup): logging.configure(level=logging.INFO) project_path = os.path.abspath(project) serena_config = SerenaConfig.from_config_file() - proj = Project.load(project_path, global_ignored_paths=serena_config.ignored_paths) + proj = Project.load(project_path, serena_config=serena_config) # Create log file with timestamp timestamp = datetime_tag() diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index ecfb3a2b..79b8dab0 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -402,7 +402,7 @@ class RegisteredProject(ToStringMixin): project_root: str, project_config: "ProjectConfig", project_instance: Optional["Project"] = None, - global_ignored_paths: Sequence[str] = (), + serena_config: "SerenaConfig | None" = None, ) -> None: """ Represents a registered project in the Serena configuration. @@ -410,12 +410,12 @@ class RegisteredProject(ToStringMixin): :param project_root: the root directory of the project :param project_config: the configuration of the project :param project_instance: an existing project instance (if already loaded) - :param global_ignored_paths: global ignored paths from SerenaConfig, merged additively with project-level patterns + :param serena_config: the SerenaConfig instance for global settings """ self.project_root = Path(project_root).resolve() self.project_config = project_config self._project_instance = project_instance - self._global_ignored_paths = global_ignored_paths + self._serena_config = serena_config def _tostring_exclude_private(self) -> bool: return True @@ -425,21 +425,21 @@ class RegisteredProject(ToStringMixin): return self.project_config.project_name @classmethod - def from_project_instance(cls, project_instance: "Project", global_ignored_paths: Sequence[str] = ()) -> "RegisteredProject": + def from_project_instance(cls, project_instance: "Project", serena_config: "SerenaConfig | None" = None) -> "RegisteredProject": return RegisteredProject( project_root=project_instance.project_root, project_config=project_instance.project_config, project_instance=project_instance, - global_ignored_paths=global_ignored_paths, + serena_config=serena_config, ) @classmethod - def from_project_root(cls, project_root: str | Path, global_ignored_paths: Sequence[str] = ()) -> "RegisteredProject": + def from_project_root(cls, project_root: str | Path, serena_config: "SerenaConfig | None" = None) -> "RegisteredProject": project_config = ProjectConfig.load(project_root) return RegisteredProject( project_root=str(project_root), project_config=project_config, - global_ignored_paths=global_ignored_paths, + serena_config=serena_config, ) def matches_root_path(self, path: str | Path) -> bool: @@ -462,7 +462,7 @@ class RegisteredProject(ToStringMixin): self._project_instance = Project( project_root=str(self.project_root), project_config=self.project_config, - global_ignored_paths=self._global_ignored_paths, + serena_config=self._serena_config, ) return self._project_instance @@ -629,7 +629,7 @@ class SerenaConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMix project = RegisteredProject( project_root=str(path), project_config=project_config, - global_ignored_paths=instance.ignored_paths, + serena_config=instance, ) instance.projects.append(project) @@ -720,7 +720,7 @@ class SerenaConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMix if autoregister: config_path = ProjectConfig.path_to_project_yml(project_root_or_name) if os.path.isfile(config_path): - registered_project = RegisteredProject.from_project_root(project_root_or_name, global_ignored_paths=self.ignored_paths) + registered_project = RegisteredProject.from_project_root(project_root_or_name, serena_config=self) self.add_registered_project(registered_project) return registered_project # nothing found @@ -769,9 +769,9 @@ class SerenaConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMix project_root=str(project_root), project_config=project_config, is_newly_created=True, - global_ignored_paths=self.ignored_paths, + serena_config=self, ) - self.add_registered_project(RegisteredProject.from_project_instance(new_project, global_ignored_paths=self.ignored_paths)) + self.add_registered_project(RegisteredProject.from_project_instance(new_project, serena_config=self)) return new_project diff --git a/src/serena/project.py b/src/serena/project.py index ae7bcc0f..c0d4203b 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -2,9 +2,8 @@ import json import logging import os import threading -from collections.abc import Sequence from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any import pathspec from sensai.util.logging import LogTime @@ -19,6 +18,9 @@ from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import FileUtils +if TYPE_CHECKING: + from serena.config.serena_config import SerenaConfig + log = logging.getLogger(__name__) @@ -62,7 +64,7 @@ class Project(ToStringMixin): project_root: str, project_config: ProjectConfig, is_newly_created: bool = False, - global_ignored_paths: Sequence[str] = (), + serena_config: "SerenaConfig | None" = None, ): self.project_root = project_root self.project_config = project_config @@ -79,7 +81,7 @@ class Project(ToStringMixin): f.write(f"/{SolidLanguageServer.CACHE_FOLDER_NAME}\n") # prepare ignore spec asynchronously, ensuring immediate project activation. - self._global_ignored_paths = global_ignored_paths + self._serena_config = serena_config self.__ignored_patterns: list[str] self.__ignore_spec: pathspec.PathSpec self._ignore_spec_available = threading.Event() @@ -89,10 +91,11 @@ class Project(ToStringMixin): with LogTime(f"Gathering ignore spec for project {self.project_config.project_name}", logger=log): # gather ignored paths from the global configuration, project configuration, and gitignore files - ignored_patterns = list(self._global_ignored_paths) + list(self.project_config.ignored_paths) - if len(self._global_ignored_paths) > 0: - log.info(f"Using {len(self._global_ignored_paths)} ignored paths from the global configuration.") - log.debug(f"Global ignored paths: {list(self._global_ignored_paths)}") + global_ignored_paths = self._serena_config.ignored_paths if self._serena_config else [] + ignored_patterns = list(global_ignored_paths) + list(self.project_config.ignored_paths) + if len(global_ignored_paths) > 0: + log.info(f"Using {len(global_ignored_paths)} ignored paths from the global configuration.") + log.debug(f"Global ignored paths: {list(global_ignored_paths)}") if len(self.project_config.ignored_paths) > 0: log.info(f"Using {len(self.project_config.ignored_paths)} ignored paths from the project configuration.") log.debug(f"Project ignored paths: {self.project_config.ignored_paths}") @@ -131,13 +134,13 @@ class Project(ToStringMixin): cls, project_root: str | Path, autogenerate: bool = True, - global_ignored_paths: Sequence[str] = (), + serena_config: "SerenaConfig | None" = None, ) -> "Project": project_root = Path(project_root).resolve() if not project_root.exists(): raise FileNotFoundError(f"Project root not found: {project_root}") project_config = ProjectConfig.load(project_root, autogenerate=autogenerate) - return Project(project_root=str(project_root), project_config=project_config, global_ignored_paths=global_ignored_paths) + return Project(project_root=str(project_root), project_config=project_config, serena_config=serena_config) def save_config(self) -> None: """ diff --git a/test/serena/config/test_global_ignored_paths.py b/test/serena/config/test_global_ignored_paths.py index f699915d..230a950d 100644 --- a/test/serena/config/test_global_ignored_paths.py +++ b/test/serena/config/test_global_ignored_paths.py @@ -20,10 +20,13 @@ def _create_test_project( ignored_paths=project_ignored_paths or [], ignore_all_files_in_gitignore=False, ) + serena_config: SerenaConfig | None = None + if global_ignored_paths: + serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=global_ignored_paths) return Project( project_root=str(project_root), project_config=config, - global_ignored_paths=global_ignored_paths or [], + serena_config=serena_config, ) @@ -138,10 +141,11 @@ class TestRegisteredProjectGlobalIgnoredPaths: ignored_paths=[], ignore_all_files_in_gitignore=False, ) + serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=["node_modules"]) registered = RegisteredProject( project_root=str(self.project_path), project_config=config, - global_ignored_paths=["node_modules"], + serena_config=serena_config, ) project = registered.get_project_instance() assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) @@ -171,7 +175,7 @@ class TestRegisteredProjectGlobalIgnoredPaths: ) registered = RegisteredProject.from_project_root( str(self.project_path), - global_ignored_paths=["node_modules"], + serena_config=SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=["node_modules"]), ) project = registered.get_project_instance() assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) @@ -184,12 +188,13 @@ class TestRegisteredProjectGlobalIgnoredPaths: ignored_paths=[], ignore_all_files_in_gitignore=False, ) + serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=["node_modules"]) project = Project( project_root=str(self.project_path), project_config=config, - global_ignored_paths=["node_modules"], + serena_config=serena_config, ) - registered = RegisteredProject.from_project_instance(project, global_ignored_paths=["node_modules"]) + registered = RegisteredProject.from_project_instance(project, serena_config=serena_config) # The registered project already has a project_instance, so get_project_instance() returns it directly retrieved = registered.get_project_instance() assert retrieved.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) @@ -223,10 +228,11 @@ class TestGlobalIgnoredPathsWithGitignore: ignored_paths=["build"], ignore_all_files_in_gitignore=True, ) + serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=["node_modules"]) project = Project( project_root=str(self.project_path), project_config=config, - global_ignored_paths=["node_modules"], + serena_config=serena_config, ) # Global pattern: node_modules assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) From 43902782fa47f5fd107fca812d7058154ef557d5 Mon Sep 17 00:00:00 2001 From: takeokunn Date: Fri, 13 Feb 2026 15:37:59 +0900 Subject: [PATCH 066/105] Project.load: accept SerenaConfig instead of global_ignored_paths Use `serena_config: SerenaConfig | None` without a default value, requiring callers to explicitly pass the configuration. --- src/serena/project.py | 2 +- test/conftest.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/serena/project.py b/src/serena/project.py index c0d4203b..c176dddb 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -133,8 +133,8 @@ class Project(ToStringMixin): def load( cls, project_root: str | Path, + serena_config: "SerenaConfig | None", autogenerate: bool = True, - serena_config: "SerenaConfig | None" = None, ) -> "Project": project_root = Path(project_root).resolve() if not project_root.exists(): diff --git a/test/conftest.py b/test/conftest.py index d774a328..5aa1ee58 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -109,7 +109,7 @@ def start_default_ls_context(language: Language) -> Iterator[SolidLanguageServer def _create_default_project(language: Language) -> Project: repo_path = str(get_repo_path(language)) - return Project.load(repo_path) + return Project.load(repo_path, serena_config=None) @pytest.fixture(scope="session") From 0025402f37057c7fd41812249bff79bcd01961a9 Mon Sep 17 00:00:00 2001 From: takeokunn Date: Fri, 13 Feb 2026 15:39:52 +0900 Subject: [PATCH 067/105] RegisteredProject: pass SerenaConfig through get_project_instance Remove serena_config storage from RegisteredProject; instead accept it as an explicit parameter in get_project_instance() for cleaner separation of concerns. --- src/serena/cli.py | 4 ++-- src/serena/config/serena_config.py | 20 +++++++------------ .../config/test_global_ignored_paths.py | 13 ++++++------ 3 files changed, 15 insertions(+), 22 deletions(-) diff --git a/src/serena/cli.py b/src/serena/cli.py index ee36be22..33595e70 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -567,7 +567,7 @@ class ProjectCommands(AutoRegisteringGroup): serena_config = SerenaConfig.from_config_file() registered_project = serena_config.get_registered_project(str(project_root)) if registered_project is None: - registered_project = RegisteredProject(str(project_root), generated_conf, serena_config=serena_config) + registered_project = RegisteredProject(str(project_root), generated_conf) serena_config.add_registered_project(registered_project) return registered_project @@ -637,7 +637,7 @@ class ProjectCommands(AutoRegisteringGroup): lvl = logging.getLevelNamesMapping()[log_level.upper()] logging.configure(level=lvl) serena_config = SerenaConfig.from_config_file() - proj = registered_project.get_project_instance() + proj = registered_project.get_project_instance(serena_config=serena_config) click.echo(f"Indexing symbols in {proj} …") ls_mgr = proj.create_language_server_manager( log_level=lvl, ls_timeout=timeout, ls_specific_settings=serena_config.ls_specific_settings diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 79b8dab0..be7faaff 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -402,7 +402,6 @@ class RegisteredProject(ToStringMixin): project_root: str, project_config: "ProjectConfig", project_instance: Optional["Project"] = None, - serena_config: "SerenaConfig | None" = None, ) -> None: """ Represents a registered project in the Serena configuration. @@ -410,12 +409,10 @@ class RegisteredProject(ToStringMixin): :param project_root: the root directory of the project :param project_config: the configuration of the project :param project_instance: an existing project instance (if already loaded) - :param serena_config: the SerenaConfig instance for global settings """ self.project_root = Path(project_root).resolve() self.project_config = project_config self._project_instance = project_instance - self._serena_config = serena_config def _tostring_exclude_private(self) -> bool: return True @@ -425,21 +422,19 @@ class RegisteredProject(ToStringMixin): return self.project_config.project_name @classmethod - def from_project_instance(cls, project_instance: "Project", serena_config: "SerenaConfig | None" = None) -> "RegisteredProject": + def from_project_instance(cls, project_instance: "Project") -> "RegisteredProject": return RegisteredProject( project_root=project_instance.project_root, project_config=project_instance.project_config, project_instance=project_instance, - serena_config=serena_config, ) @classmethod - def from_project_root(cls, project_root: str | Path, serena_config: "SerenaConfig | None" = None) -> "RegisteredProject": + def from_project_root(cls, project_root: str | Path) -> "RegisteredProject": project_config = ProjectConfig.load(project_root) return RegisteredProject( project_root=str(project_root), project_config=project_config, - serena_config=serena_config, ) def matches_root_path(self, path: str | Path) -> bool: @@ -451,7 +446,7 @@ class RegisteredProject(ToStringMixin): """ return self.project_root.samefile(Path(path).resolve()) - def get_project_instance(self) -> "Project": + def get_project_instance(self, serena_config: "SerenaConfig | None") -> "Project": """ Returns the project instance for this registered project, loading it if necessary. """ @@ -462,7 +457,7 @@ class RegisteredProject(ToStringMixin): self._project_instance = Project( project_root=str(self.project_root), project_config=self.project_config, - serena_config=self._serena_config, + serena_config=serena_config, ) return self._project_instance @@ -629,7 +624,6 @@ class SerenaConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMix project = RegisteredProject( project_root=str(path), project_config=project_config, - serena_config=instance, ) instance.projects.append(project) @@ -720,7 +714,7 @@ class SerenaConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMix if autoregister: config_path = ProjectConfig.path_to_project_yml(project_root_or_name) if os.path.isfile(config_path): - registered_project = RegisteredProject.from_project_root(project_root_or_name, serena_config=self) + registered_project = RegisteredProject.from_project_root(project_root_or_name) self.add_registered_project(registered_project) return registered_project # nothing found @@ -731,7 +725,7 @@ class SerenaConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMix if registered_project is None: return None else: - return registered_project.get_project_instance() + return registered_project.get_project_instance(serena_config=self) def add_registered_project(self, registered_project: RegisteredProject) -> None: """ @@ -771,7 +765,7 @@ class SerenaConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMix is_newly_created=True, serena_config=self, ) - self.add_registered_project(RegisteredProject.from_project_instance(new_project, serena_config=self)) + self.add_registered_project(RegisteredProject.from_project_instance(new_project)) return new_project diff --git a/test/serena/config/test_global_ignored_paths.py b/test/serena/config/test_global_ignored_paths.py index 230a950d..23481373 100644 --- a/test/serena/config/test_global_ignored_paths.py +++ b/test/serena/config/test_global_ignored_paths.py @@ -145,9 +145,8 @@ class TestRegisteredProjectGlobalIgnoredPaths: registered = RegisteredProject( project_root=str(self.project_path), project_config=config, - serena_config=serena_config, ) - project = registered.get_project_instance() + project = registered.get_project_instance(serena_config=serena_config) assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) def test_get_project_instance_without_global_ignored_paths(self) -> None: @@ -162,7 +161,7 @@ class TestRegisteredProjectGlobalIgnoredPaths: project_root=str(self.project_path), project_config=config, ) - project = registered.get_project_instance() + project = registered.get_project_instance(serena_config=None) assert not project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) def test_from_project_root_passes_global_ignored_paths(self) -> None: @@ -173,11 +172,11 @@ class TestRegisteredProjectGlobalIgnoredPaths: (serena_dir / "project.yml").write_text( 'project_name: "test_project"\nlanguages: ["python"]\nignored_paths: []\nignore_all_files_in_gitignore: false\n' ) + serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=["node_modules"]) registered = RegisteredProject.from_project_root( str(self.project_path), - serena_config=SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=["node_modules"]), ) - project = registered.get_project_instance() + project = registered.get_project_instance(serena_config=serena_config) assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) def test_from_project_instance_passes_global_ignored_paths(self) -> None: @@ -194,9 +193,9 @@ class TestRegisteredProjectGlobalIgnoredPaths: project_config=config, serena_config=serena_config, ) - registered = RegisteredProject.from_project_instance(project, serena_config=serena_config) + registered = RegisteredProject.from_project_instance(project) # The registered project already has a project_instance, so get_project_instance() returns it directly - retrieved = registered.get_project_instance() + retrieved = registered.get_project_instance(serena_config=serena_config) assert retrieved.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) From 37965666be530980cf28082592cdcf72a1f4e317 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 12 Feb 2026 22:32:20 +0100 Subject: [PATCH 068/105] LSPFileBuffer: Ensure that file contents are never stale by monitoring file modification date For every read access on .contents, check for external file modification and re-read if necessary, invalidating the content hash as well. Fixes #1013 --- src/serena/code_editor.py | 3 +- src/solidlsp/ls.py | 42 ++++++++++++++++++---- test/solidlsp/python/test_python_basic.py | 2 +- test/solidlsp/test_ls_common.py | 44 +++++++++++++++++++++++ 4 files changed, 82 insertions(+), 9 deletions(-) create mode 100644 test/solidlsp/test_ls_common.py diff --git a/src/serena/code_editor.py b/src/serena/code_editor.py index 0ff8db9c..2b848c72 100644 --- a/src/serena/code_editor.py +++ b/src/serena/code_editor.py @@ -81,8 +81,9 @@ class CodeEditor(Generic[TSymbol], ABC): def _save_edited_file(self, edited_file: "CodeEditor.EditedFile") -> None: abs_path = os.path.join(self.project_root, edited_file.relative_path) + new_contents = edited_file.get_contents() with open(abs_path, "w", encoding=self.encoding) as f: - f.write(edited_file.get_contents()) + f.write(new_contents) @abstractmethod def _find_unique_symbol(self, name_path: str, relative_file_path: str) -> TSymbol: diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 6286776f..777ce6e2 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -66,8 +66,8 @@ class LSPFileBuffer: def __init__( self, + abs_path: Path, uri: str, - contents: str, encoding: str, version: int, language_id: str, @@ -75,9 +75,11 @@ class LSPFileBuffer: language_server: "SolidLanguageServer", open_in_ls: bool = True, ) -> None: + self.abs_path = abs_path self.language_server = language_server self.uri = uri - self.contents = contents + self._read_file_modified_date: float | None = None + self._contents: str | None = None self.version = version self.language_id = language_id self.ref_count = ref_count @@ -119,6 +121,34 @@ class LSPFileBuffer: """Ensure that the file is opened in the language server.""" self._open_in_ls() + @property + def contents(self) -> str: + file_modified_date = self.abs_path.stat().st_mtime + + # if contents are cached, check if they are stale (file modification since last read) and invalidate if so + if self._contents is not None: + assert self._read_file_modified_date is not None + if file_modified_date > self._read_file_modified_date: + self._contents = None + + if self._contents is None: + self._read_file_modified_date = file_modified_date + self._contents = FileUtils.read_file(str(self.abs_path), self.encoding) + self._content_hash = None + + return self._contents + + @contents.setter + def contents(self, new_contents: str) -> None: + """ + Sets new contents for the file buffer (in-memory change only). + Persistence of the change to disk must be handled separately. + + :param new_contents: the new contents to set + """ + self._contents = new_contents + self._content_hash = None + @property def content_hash(self) -> str: if self._content_hash is None: @@ -669,8 +699,8 @@ class SolidLanguageServer(ABC): log.error("open_file called before Language Server started") raise SolidLSPException("Language Server not started") - absolute_file_path = str(PurePath(self.repository_root_path, relative_file_path)) - uri = pathlib.Path(absolute_file_path).as_uri() + absolute_file_path = Path(self.repository_root_path, relative_file_path) + uri = absolute_file_path.as_uri() if uri in self.open_file_buffers: fb = self.open_file_buffers[uri] @@ -683,13 +713,11 @@ class SolidLanguageServer(ABC): 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) fb = LSPFileBuffer( + abs_path=absolute_file_path, uri=uri, - contents=contents, encoding=self._encoding, version=version, language_id=language_id, diff --git a/test/solidlsp/python/test_python_basic.py b/test/solidlsp/python/test_python_basic.py index 8dafc964..4b60995b 100644 --- a/test/solidlsp/python/test_python_basic.py +++ b/test/solidlsp/python/test_python_basic.py @@ -16,7 +16,7 @@ from solidlsp.ls_config import Language @pytest.mark.python -class TestLanguageServerBasics: +class TestPythonLanguageServerBasics: """Test basic functionality of the language server.""" @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) diff --git a/test/solidlsp/test_ls_common.py b/test/solidlsp/test_ls_common.py new file mode 100644 index 00000000..f98fd05d --- /dev/null +++ b/test/solidlsp/test_ls_common.py @@ -0,0 +1,44 @@ +import os +import platform +import time + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.conftest import is_ci + + +class TestLanguageServerCommonFunctionality: + """Test common functionality of SolidLanguageServer base implementation (not language-specific behaviour).""" + + @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) + def test_open_file_cache_invalidate(self, language_server: SolidLanguageServer) -> None: + """ + Tests that the file buffer cache is invalidated when the file is changed on disk. + """ + file_path = os.path.join(language_server.repository_root_path, "test_open_file.py") + test_string1 = "# foo" + test_string2 = "# bar" + + with open(file_path, "w") as f: + f.write(test_string1) + + try: + with language_server.open_file(file_path) as fb: + assert fb.contents == test_string1 + + # apply external change to file + with open(file_path, "w") as f: + f.write(test_string2) + + # ensure that mtime is updated for Windows CI (odd FS behaviour, updates lazily) + if is_ci and platform.system() == "Windows": + now = time.time() + os.utime(file_path, (now, now)) + + # check that the file buffer has been invalidated and reloaded + assert fb.contents == test_string2 + + finally: + os.remove(file_path) From 1ab009ccf534edaa622dce84462bba0466e6164e Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 13 Feb 2026 14:51:10 +0100 Subject: [PATCH 069/105] Limit the size of the log buffer Fixes #1034 --- src/serena/constants.py | 3 ++ src/serena/dashboard.py | 5 ++-- src/serena/gui_log_viewer.py | 2 +- src/serena/util/logging.py | 55 +++++++++++++++++++++++++++++------- 4 files changed, 51 insertions(+), 14 deletions(-) diff --git a/src/serena/constants.py b/src/serena/constants.py index 3a9e2bf5..6d90a539 100644 --- a/src/serena/constants.py +++ b/src/serena/constants.py @@ -28,3 +28,6 @@ PROJECT_TEMPLATE_FILE = str(_serena_pkg_path / "resources" / "project.template.y SERENA_CONFIG_TEMPLATE_FILE = str(_serena_pkg_path / "resources" / "serena_config.template.yml") SERENA_LOG_FORMAT = "%(levelname)-5s %(asctime)-15s [%(threadName)s] %(name)s:%(funcName)s:%(lineno)d - %(message)s" + +LOG_MESSAGES_BUFFER_SIZE = 2500 +"""The maximum number of log messages to keep in the buffer (for the dashboard).""" diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index dca3d730..c324ae98 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -363,11 +363,10 @@ class SerenaDashboardAPI: return {"status": "error", "message": str(e)} def _get_log_messages(self, request_log: RequestLog) -> ResponseLog: - all_messages = self._memory_log_handler.get_log_messages() - requested_messages = all_messages[request_log.start_idx :] if request_log.start_idx <= len(all_messages) else [] + messages = self._memory_log_handler.get_log_messages(from_idx=request_log.start_idx) project = self._agent.get_active_project() project_name = project.project_name if project else None - return ResponseLog(messages=requested_messages, max_idx=len(all_messages) - 1, active_project=project_name) + return ResponseLog(messages=messages.messages, max_idx=messages.max_idx, active_project=project_name) def _get_tool_names(self) -> ResponseToolNames: return ResponseToolNames(tool_names=self._tool_names) diff --git a/src/serena/gui_log_viewer.py b/src/serena/gui_log_viewer.py index c9a43e01..4ee16a86 100644 --- a/src/serena/gui_log_viewer.py +++ b/src/serena/gui_log_viewer.py @@ -68,7 +68,7 @@ class GuiLogViewer: } if memory_log_handler is not None: - for msg in memory_log_handler.get_log_messages(): + for msg in memory_log_handler.get_log_messages().messages: self.message_queue.put(msg) memory_log_handler.add_emit_callback(lambda msg: self.message_queue.put(msg)) diff --git a/src/serena/util/logging.py b/src/serena/util/logging.py index dae83c0a..9fc5ec4c 100644 --- a/src/serena/util/logging.py +++ b/src/serena/util/logging.py @@ -1,20 +1,33 @@ import queue import threading from collections.abc import Callable +from dataclasses import dataclass from typing import Optional from sensai.util import logging -from serena.constants import SERENA_LOG_FORMAT +from serena.constants import LOG_MESSAGES_BUFFER_SIZE, SERENA_LOG_FORMAT lg = logging +@dataclass +class LogMessages: + messages: list[str] + """ + the list of log messages, ordered from oldest to newest + """ + max_idx: int + """ + the 0-based index of the last message in `messages` (in the full log history) + """ + + class MemoryLogHandler(logging.Handler): - def __init__(self, level: int = logging.NOTSET) -> None: + def __init__(self, level: int = logging.NOTSET, max_messages: int | None = LOG_MESSAGES_BUFFER_SIZE) -> None: super().__init__(level=level) self.setFormatter(logging.Formatter(SERENA_LOG_FORMAT)) - self._log_buffer = LogBuffer() + self._log_buffer = LogBuffer(max_messages=max_messages) self._log_queue: queue.Queue[str] = queue.Queue() self._stop_event = threading.Event() self._emit_callbacks: list[Callable[[str], None]] = [] @@ -48,26 +61,48 @@ class MemoryLogHandler(logging.Handler): except queue.Empty: continue - def get_log_messages(self) -> list[str]: - return self._log_buffer.get_log_messages() + def get_log_messages(self, from_idx: int = 0) -> LogMessages: + return self._log_buffer.get_log_messages(from_idx=from_idx) class LogBuffer: """ - A thread-safe buffer for storing log messages. + A thread-safe buffer for storing (an optionally limited number of) log messages. """ - def __init__(self) -> None: - self._log_messages: list[str] = [] + def __init__(self, max_messages: int | None = None) -> None: + self._max_messages = max_messages + self._log_messages = [] self._lock = threading.Lock() + self._max_idx = -1 + """ + the 0-based index of the most recently added log message + """ def append(self, msg: str) -> None: with self._lock: self._log_messages.append(msg) + self._max_idx += 1 + if self._max_messages is not None and len(self._log_messages) > self._max_messages: + excess = len(self._log_messages) - self._max_messages + self._log_messages = self._log_messages[excess:] - def get_log_messages(self) -> list[str]: + def get_log_messages(self, from_idx: int = 0) -> LogMessages: + """ + :param from_idx: the 0-based index of the first log message to return. + If from_idx is less than or equal to the index of the oldest message in the buffer, + then all messages in the buffer will be returned. + :return: the list of messages + """ + from_idx = max(from_idx, 0) with self._lock: - return self._log_messages.copy() + first_stored_idx = self._max_idx - len(self._log_messages) + 1 + if from_idx <= first_stored_idx: + messages = self._log_messages.copy() + else: + start_idx = from_idx - first_stored_idx + messages = self._log_messages[start_idx:].copy() + return LogMessages(messages=messages, max_idx=self._max_idx) class SuspendedLoggersContext: From 036a065848618c702577da1cf1911a3559e59624 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 13 Feb 2026 15:15:26 +0100 Subject: [PATCH 070/105] Dashboard: Improve accessibility of logs view 'Overview' and 'Logs' are now tabs instead of menu items --- src/serena/resources/dashboard/dashboard.css | 50 ++++++++++++++++++-- src/serena/resources/dashboard/index.html | 32 +++++++------ 2 files changed, 65 insertions(+), 17 deletions(-) diff --git a/src/serena/resources/dashboard/dashboard.css b/src/serena/resources/dashboard/dashboard.css index 08ea8519..e47fc2c2 100644 --- a/src/serena/resources/dashboard/dashboard.css +++ b/src/serena/resources/dashboard/dashboard.css @@ -213,13 +213,48 @@ body { .header-nav { position: relative; display: flex; + flex-direction: column; align-items: flex-end; - width: 200px; + justify-content: space-between; height: 100%; - gap: 10px; flex-shrink: 0; } +.header-actions { + position: relative; + display: flex; + align-items: center; + gap: 10px; +} + +.header-tabs { + display: flex; + align-items: flex-end; + gap: 0; +} + +.header-tab { + display: flex; + align-items: center; + padding: 8px 20px; + color: var(--text-muted); + text-decoration: none; + font-size: 15px; + font-weight: 500; + border-bottom: 3px solid transparent; + transition: color 0.2s ease, border-color 0.2s ease; + white-space: nowrap; +} + +.header-tab:hover { + color: var(--text-primary); +} + +.header-tab.active { + color: var(--btn-primary); + border-bottom-color: var(--btn-primary); +} + .menu-button { background-color: var(--bg-secondary); color: var(--text-primary); @@ -240,7 +275,8 @@ body { .menu-dropdown { position: absolute; - top: calc(var(--header-height) + 10px); + top: 100%; + margin-top: 6px; right: 0; background-color: var(--bg-secondary); border: 1px solid var(--border-color); @@ -966,6 +1002,14 @@ body { .header-nav { width: 100%; + align-items: center; + } + + .header-tabs { + justify-content: center; + } + + .header-actions { justify-content: center; } diff --git a/src/serena/resources/dashboard/index.html b/src/serena/resources/dashboard/index.html index fab77402..cd5fb4b7 100644 --- a/src/serena/resources/dashboard/index.html +++ b/src/serena/resources/dashboard/index.html @@ -30,20 +30,24 @@ From 66a12eee3c4678b5be702d36b89ab1bb55bd6fa9 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 13 Feb 2026 16:38:40 +0100 Subject: [PATCH 071/105] Fix type annotation --- src/serena/util/logging.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/serena/util/logging.py b/src/serena/util/logging.py index 9fc5ec4c..4ca6e770 100644 --- a/src/serena/util/logging.py +++ b/src/serena/util/logging.py @@ -72,7 +72,7 @@ class LogBuffer: def __init__(self, max_messages: int | None = None) -> None: self._max_messages = max_messages - self._log_messages = [] + self._log_messages: list[str] = [] self._lock = threading.Lock() self._max_idx = -1 """ From 798a31b6a72447c437a1001c025597cf0c91052d Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 13 Feb 2026 18:17:05 +0100 Subject: [PATCH 072/105] Implement generic SymbolDictGrouper (and specialisations for LS & JB) It represents a reasonable compromise between pragmatism and type-safety. Soundness is checked at runtime, but if the grouper is instantiated statically, then we immediately detect misspecification. One example application is implemented in GetSymbolsOverviewTool Provides basis for #1021 --- src/serena/symbol.py | 97 +++++++++++++++++++++++++++-- src/serena/tools/jetbrains_types.py | 5 +- src/serena/tools/symbol_tools.py | 11 +++- 3 files changed, 104 insertions(+), 9 deletions(-) diff --git a/src/serena/symbol.py b/src/serena/symbol.py index e91856c1..9e7c8087 100644 --- a/src/serena/symbol.py +++ b/src/serena/symbol.py @@ -4,7 +4,7 @@ import os from abc import ABC, abstractmethod from collections.abc import Callable, Iterator, Sequence from dataclasses import asdict, dataclass -from typing import TYPE_CHECKING, Any, Self, Union +from typing import TYPE_CHECKING, Any, Generic, Literal, NotRequired, Self, TypedDict, TypeVar, Union from sensai.util.string import ToStringMixin @@ -395,6 +395,17 @@ class LanguageServerSymbol(Symbol, ToStringMixin): traverse(self) return result + class OutputDict(TypedDict): + name: str + name_path: str + location: NotRequired[dict[str, Any]] + body_location: NotRequired[dict[str, Any]] + body: NotRequired[str | None] + kind: NotRequired[str] + children: NotRequired[list["LanguageServerSymbol.OutputDict"]] + + OutputDictKey = Literal["name", "name_path", "location", "body_location", "body", "kind", "children"] + def to_dict( self, kind: bool = False, @@ -404,7 +415,7 @@ class LanguageServerSymbol(Symbol, ToStringMixin): include_children_body: bool = False, include_relative_path: bool = True, child_inclusion_predicate: Callable[[Self], bool] | None = None, - ) -> dict[str, Any]: + ) -> OutputDict: """ Converts the symbol to a dictionary. @@ -422,7 +433,7 @@ class LanguageServerSymbol(Symbol, ToStringMixin): should be included. :return: a dictionary representation of the symbol """ - result: dict[str, Any] = {"name": self.name, "name_path": self.get_name_path()} + result: LanguageServerSymbol.OutputDict = {"name": self.name, "name_path": self.get_name_path()} if kind: result["kind"] = self.kind @@ -441,7 +452,7 @@ class LanguageServerSymbol(Symbol, ToStringMixin): if child_inclusion_predicate is None: child_inclusion_predicate = lambda s: True - def included_children(s: Self) -> list[dict[str, Any]]: + def included_children(s: Self) -> list[LanguageServerSymbol.OutputDict]: children = [] for c in s.iter_children(): if not child_inclusion_predicate(c): @@ -689,7 +700,7 @@ class LanguageServerSymbolRetriever: return [ReferenceInLanguageServerSymbol.from_lsp_reference(r) for r in references] - def get_symbol_overview(self, relative_path: str, depth: int = 0) -> dict[str, list[dict]]: + def get_symbol_overview(self, relative_path: str, depth: int = 0) -> dict[str, list[LanguageServerSymbol.OutputDict]]: """ :param relative_path: the path of the file or directory for which to get the symbol overview :param depth: the depth up to which to include child symbols (0 = only top-level symbols) @@ -775,3 +786,79 @@ class JetBrainsSymbol(Symbol): def is_neighbouring_definition_separated_by_empty_line(self) -> bool: # NOTE: Symbol types cannot really be differentiated, because types are not handled in a language-agnostic way. return False + + +TSymbolDict = TypeVar("TSymbolDict") +GroupedSymbolDict = dict[str, list[dict] | dict[str, dict]] + + +class SymbolDictGrouper(Generic[TSymbolDict], ABC): + """ + A utility class for grouping a list of symbol dictionaries by one or more specified keys. + + If an instance is statically initialised (upon module import), then this establishes a guarantee + that the specified keys are defined in the symbol dictionary type, ensuring at least basic type safety. + The respective ValueError will immediately be apparent. + """ + + def __init__( + self, symbol_dict_type: type[TSymbolDict], children_key: Any, group_keys: list[Any], group_children_keys: list[Any] + ) -> None: + """ + :param symbol_dict_type: the TypedDict type that represents the type of the symbol dictionaries to be grouped + :param children_key: the key in the symbol dictionaries that contains the list of child symbols. This is necessary for recursive grouping. + :param group_keys: keys by which to group the symbol dictionaries. Must be a subset of the keys of `symbol_dict_type`. + :param group_children_keys: keys by which to group the child symbol dictionaries. Must be a subset of the keys of `symbol_dict_type`. + """ + # symbol_dict_type must be a TypedDict type. + # We check whether the type contains all the keys specified in `keys` and raise an error if not. + if not hasattr(symbol_dict_type, "__annotations__"): + raise ValueError(f"symbol_dict_type must be a TypedDict type, got {symbol_dict_type}") + symbol_dict_keys = set(symbol_dict_type.__annotations__.keys()) + for key in group_keys + [children_key] + group_children_keys: + if key not in symbol_dict_keys: + raise ValueError(f"symbol_dict_type {symbol_dict_type} does not contain key '{key}'") + self._children_key = children_key + self._group_keys = group_keys + self._group_children_keys = group_children_keys + + def _group_by(self, l: list[dict], keys: list[str], children_keys: list[str]) -> dict[str, Any]: + key = keys[0] + grouped: dict[str, Any] = {} + for item in l: + key_value = item.pop(key) + if key_value not in grouped: + grouped[key_value] = [] + grouped[key_value].append(item) + if len(keys) > 1: + for k, group in grouped.items(): + grouped[k] = self._group_by(group, keys[1:], children_keys) + else: + if children_keys: + for k, group in grouped.items(): + for item in group: + if self._children_key in item: + children = item[self._children_key] + item[self._children_key] = self._group_by(children, children_keys, children_keys) + return grouped + + def group(self, symbols: list[TSymbolDict]) -> GroupedSymbolDict: + """ + :param symbols: the symbols to group + :return: dictionary with the symbols grouped as defined at construction + """ + return self._group_by(symbols, self._group_keys, self._group_children_keys) # type: ignore + + +class LanguageServerSymbolDictGrouper(SymbolDictGrouper[LanguageServerSymbol.OutputDict]): + def __init__( + self, + group_keys: list[LanguageServerSymbol.OutputDictKey], + group_children_keys: list[LanguageServerSymbol.OutputDictKey], + ) -> None: + super().__init__(LanguageServerSymbol.OutputDict, "children", group_keys, group_children_keys) + + +class JetBrainsSymbolDictGrouper(SymbolDictGrouper[jb.SymbolDTO]): + def __init__(self, group_keys: list[jb.SymbolDTOKey], group_children_keys: list[jb.SymbolDTOKey]) -> None: + super().__init__(jb.SymbolDTO, "children", group_keys, group_children_keys) diff --git a/src/serena/tools/jetbrains_types.py b/src/serena/tools/jetbrains_types.py index 80f02947..fd72bc60 100644 --- a/src/serena/tools/jetbrains_types.py +++ b/src/serena/tools/jetbrains_types.py @@ -1,4 +1,4 @@ -from typing import NotRequired, TypedDict +from typing import Literal, NotRequired, TypedDict class PluginStatusDTO(TypedDict): @@ -30,6 +30,9 @@ class SymbolDTO(TypedDict): num_usages: NotRequired[int] +SymbolDTOKey = Literal["name_path", "relative_path", "type", "body", "quick_info", "documentation", "text_range", "children", "num_usages"] + + class SymbolCollectionResponse(TypedDict): symbols: list[SymbolDTO] diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index ae74bbfd..59f43577 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -8,6 +8,7 @@ from collections.abc import Sequence from copy import copy from typing import Any +from serena.symbol import LanguageServerSymbol, LanguageServerSymbolDictGrouper from serena.tools import ( SUCCESS_RESULT, Tool, @@ -18,7 +19,8 @@ from serena.tools.tools_base import ToolMarkerOptional from solidlsp.ls_types import SymbolKind -def _sanitize_symbol_dict(symbol_dict: dict[str, Any]) -> dict[str, Any]: +# TODO: We have to get rid of this. +def _sanitize_symbol_dict(symbol_dict: LanguageServerSymbol.OutputDict) -> dict[str, Any]: """ Sanitize a symbol dictionary inplace by removing unnecessary information. """ @@ -50,6 +52,8 @@ class GetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead): Gets an overview of the top-level symbols defined in a given file. """ + symbol_dict_grouper = LanguageServerSymbolDictGrouper(["kind"], ["kind"]) + def apply(self, relative_path: str, depth: int = 0, max_answer_chars: int = -1) -> str: """ Use this tool to get a high-level understanding of the code symbols in a file. @@ -65,11 +69,12 @@ class GetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead): :return: a JSON object containing symbols grouped by kind in a compact format. """ result = self.get_symbol_overview(relative_path, depth=depth) - compact_result = self._transform_symbols_to_compact_format(result) + # compact_result = self._transform_symbols_to_compact_format(result) + compact_result = self.symbol_dict_grouper.group(result) result_json_str = self._to_json(compact_result) return self._limit_length(result_json_str, max_answer_chars) - def get_symbol_overview(self, relative_path: str, depth: int = 0) -> list[dict]: + def get_symbol_overview(self, relative_path: str, depth: int = 0) -> list[LanguageServerSymbol.OutputDict]: """ :param relative_path: relative path to a source file :param depth: the depth up to which descendants shall be retrieved From d80c45e7e7b5813bb66e00fd525f77c44abd08d2 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 13 Feb 2026 19:17:04 +0100 Subject: [PATCH 073/105] Refactoring: Move jetbrains_plugin_client and jetbrains_types out of tools package This resolves cyclic import issues --- src/serena/code_editor.py | 2 +- src/serena/{tools => jetbrains}/jetbrains_plugin_client.py | 4 ++-- src/serena/{tools => jetbrains}/jetbrains_types.py | 0 src/serena/symbol.py | 2 +- src/serena/tools/jetbrains_tools.py | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) rename src/serena/{tools => jetbrains}/jetbrains_plugin_client.py (99%) rename src/serena/{tools => jetbrains}/jetbrains_types.py (100%) diff --git a/src/serena/code_editor.py b/src/serena/code_editor.py index 2b848c72..b5075c38 100644 --- a/src/serena/code_editor.py +++ b/src/serena/code_editor.py @@ -6,6 +6,7 @@ from collections.abc import Iterable, Iterator, Reversible from contextlib import contextmanager from typing import TYPE_CHECKING, Generic, Optional, TypeVar, cast +from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient from serena.symbol import JetBrainsSymbol, LanguageServerSymbol, LanguageServerSymbolRetriever, PositionInFile, Symbol from solidlsp import SolidLanguageServer, ls_types from solidlsp.ls import LSPFileBuffer @@ -13,7 +14,6 @@ from solidlsp.ls_utils import PathUtils, TextUtils from .constants import DEFAULT_SOURCE_FILE_ENCODING from .project import Project -from .tools.jetbrains_plugin_client import JetBrainsPluginClient if TYPE_CHECKING: from .agent import SerenaAgent diff --git a/src/serena/tools/jetbrains_plugin_client.py b/src/serena/jetbrains/jetbrains_plugin_client.py similarity index 99% rename from src/serena/tools/jetbrains_plugin_client.py rename to src/serena/jetbrains/jetbrains_plugin_client.py index 5474f051..2607f547 100644 --- a/src/serena/tools/jetbrains_plugin_client.py +++ b/src/serena/jetbrains/jetbrains_plugin_client.py @@ -12,10 +12,10 @@ import requests from requests import Response from sensai.util.string import ToStringMixin -import serena.tools.jetbrains_types as jb +import serena.jetbrains.jetbrains_types as jb +from serena.jetbrains.jetbrains_types import PluginStatusDTO from serena.project import Project from serena.text_utils import render_html -from serena.tools.jetbrains_types import PluginStatusDTO from serena.util.version import Version T = TypeVar("T") diff --git a/src/serena/tools/jetbrains_types.py b/src/serena/jetbrains/jetbrains_types.py similarity index 100% rename from src/serena/tools/jetbrains_types.py rename to src/serena/jetbrains/jetbrains_types.py diff --git a/src/serena/symbol.py b/src/serena/symbol.py index 9e7c8087..46292d7e 100644 --- a/src/serena/symbol.py +++ b/src/serena/symbol.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any, Generic, Literal, NotRequired, Self, Type from sensai.util.string import ToStringMixin -import serena.tools.jetbrains_types as jb +import serena.jetbrains.jetbrains_types as jb from solidlsp import SolidLanguageServer from solidlsp.ls import ReferenceInSymbol as LSPReferenceInSymbol from solidlsp.ls_types import Position, SymbolKind, UnifiedSymbolInformation diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py index 096cd1e2..ed9b53cc 100644 --- a/src/serena/tools/jetbrains_tools.py +++ b/src/serena/tools/jetbrains_tools.py @@ -2,9 +2,9 @@ import logging from collections import defaultdict from typing import Any, Literal -import serena.tools.jetbrains_types as jb +import serena.jetbrains.jetbrains_types as jb +from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient from serena.tools import Tool, ToolMarkerOptional, ToolMarkerSymbolicRead -from serena.tools.jetbrains_plugin_client import JetBrainsPluginClient log = logging.getLogger(__name__) From 27605082697d087fda569bab463820ee402253db Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 13 Feb 2026 19:19:00 +0100 Subject: [PATCH 074/105] Add test for symbol key types --- test/serena/test_symbol.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/test/serena/test_symbol.py b/test/serena/test_symbol.py index 2e5009de..5068bd87 100644 --- a/test/serena/test_symbol.py +++ b/test/serena/test_symbol.py @@ -1,6 +1,7 @@ import pytest -from serena.symbol import LanguageServerSymbolRetriever, NamePathComponent, NamePathMatcher +from serena.jetbrains.jetbrains_types import SymbolDTO, SymbolDTOKey +from serena.symbol import LanguageServerSymbol, LanguageServerSymbolRetriever, NamePathComponent, NamePathMatcher from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language @@ -238,3 +239,24 @@ class TestLanguageServerSymbolRetriever: create_user_method_symbol = symbol_retriever.find("UserService/create_user", within_relative_path="test_repo/services.py")[0] create_user_method_symbol_info = symbol_retriever.request_info_for_symbol(create_user_method_symbol) assert "Create a new user and store it" in create_user_method_symbol_info + + +class TestSymbolDictTypes: + @staticmethod + def check_key_type(dict_type: type, key_type: type): + """ + :param dict_type: a TypedDict type + :param key_type: the corresponding key type (Literal[...]) that the dict should have for keys + """ + dict_type_keys = dict_type.__annotations__.keys() + assert len(dict_type_keys) == len( + key_type.__args__ + ), f"Expected {len(key_type.__args__)} keys in {dict_type}, but got {len(dict_type_keys)}" + for expected_key in key_type.__args__: + assert expected_key in dict_type_keys, f"Expected key '{expected_key}' not found in {dict_type}" + + def test_ls_symbol_dict_type(self): + self.check_key_type(LanguageServerSymbol.OutputDict, LanguageServerSymbol.OutputDictKey) + + def test_jb_symbol_dict_type(self): + self.check_key_type(SymbolDTO, SymbolDTOKey) From a1f95902ca5e54702ba830c0efb2c98283ec8a68 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 13 Feb 2026 20:49:34 +0100 Subject: [PATCH 075/105] Remove _sanitize_symbol_dict, making to_dict fully flexible instead This fixes a bug: _sanitize_symbol_dict was not recursively applied to children, causing children to contain superfluous entries (full location, etc.) Additional change: Improve factorisation of symbol overview computation --- src/serena/symbol.py | 60 ++++++++++++++++---------------- src/serena/tools/symbol_tools.py | 56 +++++++++++++++-------------- 2 files changed, 60 insertions(+), 56 deletions(-) diff --git a/src/serena/symbol.py b/src/serena/symbol.py index 46292d7e..ed0f15fc 100644 --- a/src/serena/symbol.py +++ b/src/serena/symbol.py @@ -396,9 +396,10 @@ class LanguageServerSymbol(Symbol, ToStringMixin): return result class OutputDict(TypedDict): - name: str - name_path: str + name_path: NotRequired[str] + name: NotRequired[str] location: NotRequired[dict[str, Any]] + relative_path: NotRequired[str | None] body_location: NotRequired[dict[str, Any]] body: NotRequired[str | None] kind: NotRequired[str] @@ -408,17 +409,23 @@ class LanguageServerSymbol(Symbol, ToStringMixin): def to_dict( self, + *, + name_path: bool = True, + name: bool = False, kind: bool = False, location: bool = False, depth: int = 0, include_body: bool = False, + body_location: bool = False, include_children_body: bool = False, - include_relative_path: bool = True, + include_relative_path: bool = False, child_inclusion_predicate: Callable[[Self], bool] | None = None, ) -> OutputDict: """ Converts the symbol to a dictionary. + :param name_path: whether to include the name path of the symbol + :param name: whether to include the name of the symbol :param kind: whether to include the kind of the symbol :param location: whether to include the location of the symbol :param depth: the depth up to which to include child symbols (0 = do not include children) @@ -427,19 +434,30 @@ class LanguageServerSymbol(Symbol, ToStringMixin): Note that the body of the children is part of the body of the parent symbol, so there is usually no need to set this to True unless you want process the output and pass the children without passing the parent body to the LM. - :param include_relative_path: whether to include the relative path of the symbol in the location - entry. Relative paths of the symbol's children are always excluded. + :param include_relative_path: whether to include the relative path of the symbol. + If `location` is True, this defines whether to include the path in the location entry. + If `location` is False, this defines whether to include the relative path as a top-level entry. + Relative paths of the symbol's children are always excluded. :param child_inclusion_predicate: an optional predicate that decides whether a child symbol should be included. :return: a dictionary representation of the symbol """ - result: LanguageServerSymbol.OutputDict = {"name": self.name, "name_path": self.get_name_path()} + result: LanguageServerSymbol.OutputDict = {} + + if name_path: + result["name_path"] = self.get_name_path() + if name: + result["name"] = self.name if kind: result["kind"] = self.kind if location: result["location"] = self.location.to_dict(include_relative_path=include_relative_path) + elif include_relative_path: + result["relative_path"] = self.relative_path + + if body_location: body_start_line, body_end_line = self.get_body_line_numbers() result["body_location"] = {"start_line": body_start_line, "end_line": body_end_line} @@ -459,8 +477,11 @@ class LanguageServerSymbol(Symbol, ToStringMixin): continue children.append( c.to_dict( + name_path=name_path, + name=name, kind=kind, location=location, + body_location=body_location, depth=depth - 1, child_inclusion_predicate=child_inclusion_predicate, include_body=include_children_body, @@ -700,36 +721,15 @@ class LanguageServerSymbolRetriever: return [ReferenceInLanguageServerSymbol.from_lsp_reference(r) for r in references] - def get_symbol_overview(self, relative_path: str, depth: int = 0) -> dict[str, list[LanguageServerSymbol.OutputDict]]: + def get_symbol_overview(self, relative_path: str) -> dict[str, list[LanguageServerSymbol]]: """ :param relative_path: the path of the file or directory for which to get the symbol overview - :param depth: the depth up to which to include child symbols (0 = only top-level symbols) - :return: a mapping from file paths to lists of symbol dictionaries. + :return: a mapping from file paths to lists of symbols. For the case where a file is passed, the mapping will contain a single entry. """ lang_server = self.get_language_server(relative_path) path_to_unified_symbols = lang_server.request_overview(relative_path) - - def child_inclusion_predicate(s: LanguageServerSymbol) -> bool: - return not s.is_low_level() - - result = {} - for file_path, unified_symbols in path_to_unified_symbols.items(): - symbols_in_file = [] - for us in unified_symbols: - symbol = LanguageServerSymbol(us) - symbols_in_file.append( - symbol.to_dict( - depth=depth, - kind=True, - include_relative_path=False, - location=False, - child_inclusion_predicate=child_inclusion_predicate, - ) - ) - result[file_path] = symbols_in_file - - return result + return {k: [LanguageServerSymbol(us) for us in v] for k, v in path_to_unified_symbols.items()} class JetBrainsSymbol(Symbol): diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index 59f43577..2c876966 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -5,8 +5,6 @@ Language server-related tools import os from collections import defaultdict from collections.abc import Sequence -from copy import copy -from typing import Any from serena.symbol import LanguageServerSymbol, LanguageServerSymbolDictGrouper from serena.tools import ( @@ -19,23 +17,6 @@ from serena.tools.tools_base import ToolMarkerOptional from solidlsp.ls_types import SymbolKind -# TODO: We have to get rid of this. -def _sanitize_symbol_dict(symbol_dict: LanguageServerSymbol.OutputDict) -> dict[str, Any]: - """ - Sanitize a symbol dictionary inplace by removing unnecessary information. - """ - # We replace the location entry, which repeats line information already included in body_location - # and has unnecessary information on column, by just the relative path. - symbol_dict = copy(symbol_dict) - s_relative_path = symbol_dict.get("location", {}).get("relative_path") - if s_relative_path is not None: - symbol_dict["relative_path"] = s_relative_path - symbol_dict.pop("location", None) - # also remove name, name_path should be enough - symbol_dict.pop("name") - return symbol_dict - - class RestartLanguageServerTool(Tool, ToolMarkerOptional): """Restarts the language server, may be necessary when edits not through Serena happen.""" @@ -69,8 +50,8 @@ class GetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead): :return: a JSON object containing symbols grouped by kind in a compact format. """ result = self.get_symbol_overview(relative_path, depth=depth) - # compact_result = self._transform_symbols_to_compact_format(result) - compact_result = self.symbol_dict_grouper.group(result) + compact_result = self._transform_symbols_to_compact_format(result) + # compact_result = self.symbol_dict_grouper.group(result) result_json_str = self._to_json(compact_result) return self._limit_length(result_json_str, max_answer_chars) @@ -90,10 +71,28 @@ class GetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead): if os.path.isdir(file_path): raise ValueError(f"Expected a file path, but got a directory path: {relative_path}. ") - return symbol_retriever.get_symbol_overview(relative_path, depth=depth)[relative_path] + symbols = symbol_retriever.get_symbol_overview(relative_path)[relative_path] + + def child_inclusion_predicate(s: LanguageServerSymbol) -> bool: + return not s.is_low_level() + + symbol_dicts = [] + for symbol in symbols: + symbol_dicts.append( + symbol.to_dict( + name_path=False, + name=True, + depth=depth, + kind=True, + include_relative_path=False, + location=False, + child_inclusion_predicate=child_inclusion_predicate, + ) + ) + return symbol_dicts @staticmethod - def _transform_symbols_to_compact_format(symbols: list[dict[str, Any]]) -> dict[str, list]: + def _transform_symbols_to_compact_format(symbols: list[LanguageServerSymbol.OutputDict]) -> dict[str, list]: """ Transform symbol overview from verbose format to compact grouped format. @@ -190,7 +189,10 @@ class FindSymbolTool(Tool, ToolMarkerSymbolicRead): substring_matching=substring_matching, within_relative_path=relative_path, ) - symbol_dicts = [_sanitize_symbol_dict(s.to_dict(kind=True, location=True, depth=depth, include_body=include_body)) for s in symbols] + symbol_dicts = [ + dict(s.to_dict(kind=True, include_relative_path=True, body_location=True, depth=depth, include_body=include_body)) + for s in symbols + ] if not include_body and include_info: # we add an info field to the symbol dicts if requested for s, s_dict in zip(symbols, symbol_dicts, strict=True): @@ -243,8 +245,10 @@ class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead): ) reference_dicts = [] for ref in references_in_symbols: - ref_dict = ref.symbol.to_dict(kind=True, location=True, depth=0, include_body=include_body) - ref_dict = _sanitize_symbol_dict(ref_dict) + ref_dict_orig = ref.symbol.to_dict( + kind=True, include_relative_path=True, depth=0, include_body=include_body, body_location=True + ) + ref_dict = dict(ref_dict_orig) if not include_body: ref_relative_path = ref.symbol.location.relative_path assert ref_relative_path is not None, f"Referencing symbol {ref.symbol.name} has no relative path, this is likely a bug." From d41b9ec964425d88277de44ce01a51f44de2a65d Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 13 Feb 2026 22:31:32 +0100 Subject: [PATCH 076/105] Implement dict transformation in symbol overview tools using SymbolDictGrouper, adding support for the necessary transformations of the final dictionaries --- src/serena/symbol.py | 62 ++++++++++++++++++++++++++--- src/serena/tools/jetbrains_tools.py | 36 ++--------------- src/serena/tools/symbol_tools.py | 36 +---------------- 3 files changed, 61 insertions(+), 73 deletions(-) diff --git a/src/serena/symbol.py b/src/serena/symbol.py index ed0f15fc..e1050b46 100644 --- a/src/serena/symbol.py +++ b/src/serena/symbol.py @@ -802,13 +802,19 @@ class SymbolDictGrouper(Generic[TSymbolDict], ABC): """ def __init__( - self, symbol_dict_type: type[TSymbolDict], children_key: Any, group_keys: list[Any], group_children_keys: list[Any] + self, + symbol_dict_type: type[TSymbolDict], + children_key: Any, + group_keys: list[Any], + group_children_keys: list[Any], + collapse_singleton: bool, ) -> None: """ :param symbol_dict_type: the TypedDict type that represents the type of the symbol dictionaries to be grouped :param children_key: the key in the symbol dictionaries that contains the list of child symbols. This is necessary for recursive grouping. :param group_keys: keys by which to group the symbol dictionaries. Must be a subset of the keys of `symbol_dict_type`. :param group_children_keys: keys by which to group the child symbol dictionaries. Must be a subset of the keys of `symbol_dict_type`. + :param collapse_singleton: whether to collapse dictionaries containing a single entry after regrouping to just the entry's value """ # symbol_dict_type must be a TypedDict type. # We check whether the type contains all the keys specified in `keys` and raise an error if not. @@ -821,27 +827,52 @@ class SymbolDictGrouper(Generic[TSymbolDict], ABC): self._children_key = children_key self._group_keys = group_keys self._group_children_keys = group_children_keys + self._collapse_singleton = collapse_singleton def _group_by(self, l: list[dict], keys: list[str], children_keys: list[str]) -> dict[str, Any]: - key = keys[0] + assert len(keys) > 0, "keys must not be empty" + # group by the first key grouped: dict[str, Any] = {} for item in l: - key_value = item.pop(key) + key_value = item.pop(keys[0], "unknown") if key_value not in grouped: grouped[key_value] = [] grouped[key_value].append(item) if len(keys) > 1: + # continue grouping by the remaining keys for k, group in grouped.items(): grouped[k] = self._group_by(group, keys[1:], children_keys) else: + # grouping is complete; now group the children if necessary if children_keys: for k, group in grouped.items(): for item in group: if self._children_key in item: children = item[self._children_key] item[self._children_key] = self._group_by(children, children_keys, children_keys) + # post-process final group items + grouped = {k: [self._transform_item(i) for i in v] for k, v in grouped.items()} return grouped + def _transform_item(self, item: dict) -> dict: + """ + Post-processes a final group item (which has been regrouped, i.e. some keys may have been removed), + collapsing singleton items (and items containing only a single non-children key) + """ + if self._collapse_singleton: + if len(item) == 1: + # {"name": "foo"} -> "foo" + # if there is only a single entry, collapse the dictionary to just the value of that entry + return next(iter(item.values())) + elif len(item) == 2 and self._children_key in item: + # {"name": "foo", "children": {...}} -> {"foo": {...}} + # if there are exactly two entries and one of them is the children key, + # convert to {other_value: children} if there are exactly two entries and one of them is the children key + other_key = next(k for k in item.keys() if k != self._children_key) + new_item = {item[other_key]: item[self._children_key]} + return new_item + return item + def group(self, symbols: list[TSymbolDict]) -> GroupedSymbolDict: """ :param symbols: the symbols to group @@ -855,10 +886,29 @@ class LanguageServerSymbolDictGrouper(SymbolDictGrouper[LanguageServerSymbol.Out self, group_keys: list[LanguageServerSymbol.OutputDictKey], group_children_keys: list[LanguageServerSymbol.OutputDictKey], + collapse_singleton: bool = False, ) -> None: - super().__init__(LanguageServerSymbol.OutputDict, "children", group_keys, group_children_keys) + super().__init__(LanguageServerSymbol.OutputDict, "children", group_keys, group_children_keys, collapse_singleton) class JetBrainsSymbolDictGrouper(SymbolDictGrouper[jb.SymbolDTO]): - def __init__(self, group_keys: list[jb.SymbolDTOKey], group_children_keys: list[jb.SymbolDTOKey]) -> None: - super().__init__(jb.SymbolDTO, "children", group_keys, group_children_keys) + def __init__( + self, + group_keys: list[jb.SymbolDTOKey], + group_children_keys: list[jb.SymbolDTOKey], + collapse_singleton: bool = False, + map_name_path_to_name: bool = False, + ) -> None: + super().__init__(jb.SymbolDTO, "children", group_keys, group_children_keys, collapse_singleton) + self._map_name_path_to_name = map_name_path_to_name + + def _transform_item(self, item: dict) -> dict: + if self._map_name_path_to_name: + # {"name_path: "Class/myMethod"} -> {"name: "myMethod"} + new_item = dict(item) + if "name_path" in item: + name_path = new_item.pop("name_path") + new_item["name"] = name_path.split("/")[-1] + return super()._transform_item(new_item) + else: + return super()._transform_item(item) diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py index ed9b53cc..736fa435 100644 --- a/src/serena/tools/jetbrains_tools.py +++ b/src/serena/tools/jetbrains_tools.py @@ -1,9 +1,9 @@ import logging -from collections import defaultdict from typing import Any, Literal import serena.jetbrains.jetbrains_types as jb from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient +from serena.symbol import JetBrainsSymbolDictGrouper from serena.tools import Tool, ToolMarkerOptional, ToolMarkerSymbolicRead log = logging.getLogger(__name__) @@ -128,37 +128,7 @@ class JetBrainsGetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOp """ USE_COMPACT_FORMAT = True - - @staticmethod - def _transform_symbols_to_compact_format(symbols: list[jb.SymbolDTO]) -> dict[str, list]: - """ - Transform symbol overview from verbose format to compact grouped format. - - Groups symbols by kind and uses names instead of full symbol objects. - For symbols with children, creates nested dictionaries. - - The name_path can be inferred from the hierarchical structure: - - Top-level symbols: name_path = name - - Nested symbols: name_path = parent_name + "/" + name - For example, "convert" under class "ProjectType" has name_path "ProjectType/convert". - """ - result = defaultdict(list) - - for symbol in symbols: - kind = symbol.get("type", "Unknown") - name_path = symbol["name_path"] - name = name_path.split("/")[-1] - children = symbol.get("children", []) - - if children: - # Symbol has children: create nested dict {name: children_dict} - children_dict = JetBrainsGetSymbolsOverviewTool._transform_symbols_to_compact_format(children) - result[kind].append({name: children_dict}) - else: - # Symbol has no children: just add the name - result[kind].append(name) # type: ignore - - return result + symbol_dict_grouper = JetBrainsSymbolDictGrouper(["type"], ["type"], collapse_singleton=True, map_name_path_to_name=True) def apply( self, @@ -187,7 +157,7 @@ class JetBrainsGetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOp ) if self.USE_COMPACT_FORMAT: symbols = symbol_overview["symbols"] - result: dict[str, Any] = {"symbols": self._transform_symbols_to_compact_format(symbols)} + result: dict[str, Any] = {"symbols": self.symbol_dict_grouper.group(symbols)} documentation = symbol_overview.pop("documentation", None) if documentation: result["docstring"] = documentation diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index 2c876966..a22c1eef 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -3,7 +3,6 @@ Language server-related tools """ import os -from collections import defaultdict from collections.abc import Sequence from serena.symbol import LanguageServerSymbol, LanguageServerSymbolDictGrouper @@ -33,7 +32,7 @@ class GetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead): Gets an overview of the top-level symbols defined in a given file. """ - symbol_dict_grouper = LanguageServerSymbolDictGrouper(["kind"], ["kind"]) + symbol_dict_grouper = LanguageServerSymbolDictGrouper(["kind"], ["kind"], collapse_singleton=True) def apply(self, relative_path: str, depth: int = 0, max_answer_chars: int = -1) -> str: """ @@ -50,8 +49,7 @@ class GetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead): :return: a JSON object containing symbols grouped by kind in a compact format. """ result = self.get_symbol_overview(relative_path, depth=depth) - compact_result = self._transform_symbols_to_compact_format(result) - # compact_result = self.symbol_dict_grouper.group(result) + compact_result = self.symbol_dict_grouper.group(result) result_json_str = self._to_json(compact_result) return self._limit_length(result_json_str, max_answer_chars) @@ -91,36 +89,6 @@ class GetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead): ) return symbol_dicts - @staticmethod - def _transform_symbols_to_compact_format(symbols: list[LanguageServerSymbol.OutputDict]) -> dict[str, list]: - """ - Transform symbol overview from verbose format to compact grouped format. - - Groups symbols by kind and uses names instead of full symbol objects. - For symbols with children, creates nested dictionaries. - - The name_path can be inferred from the hierarchical structure: - - Top-level symbols: name_path = name - - Nested symbols: name_path = parent_name + "/" + name - For example, "convert" under class "ProjectType" has name_path "ProjectType/convert". - """ - result = defaultdict(list) - - for symbol in symbols: - kind = symbol.get("kind", "Unknown") - name = symbol.get("name", "unknown") - children = symbol.get("children", []) - - if children: - # Symbol has children: create nested dict {name: children_dict} - children_dict = GetSymbolsOverviewTool._transform_symbols_to_compact_format(children) - result[kind].append({name: children_dict}) - else: - # Symbol has no children: just add the name - result[kind].append(name) - - return result - class FindSymbolTool(Tool, ToolMarkerSymbolicRead): """ From d69f53cd5ac945410785b111581a0ed52565d411 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 13 Feb 2026 23:05:27 +0100 Subject: [PATCH 077/105] Hotfix for serena-jetbrains-plugin/issues/13 --- src/serena/tools/jetbrains_tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py index 096cd1e2..72e0db9c 100644 --- a/src/serena/tools/jetbrains_tools.py +++ b/src/serena/tools/jetbrains_tools.py @@ -116,7 +116,7 @@ class JetBrainsFindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead, ToolMark response_dict = client.find_references( name_path=name_path, relative_path=relative_path, - include_quick_info=include_info, + include_quick_info=False, # TODO: Hotfix for serena-jetbrains-plugin/issues/13; revert once fixed ) result = self._to_json(response_dict) return self._limit_length(result, max_answer_chars) From cbff50b710c215164b5c9167af48e92d3fe76cc9 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 13 Feb 2026 23:01:43 +0100 Subject: [PATCH 078/105] Apply SymbolDictGrouper in FindReferencingSymbolsTool & JetBrainsFindReferencingSymbolsTool Resolves #1021 --- src/serena/symbol.py | 2 +- src/serena/tools/jetbrains_tools.py | 8 ++++++-- src/serena/tools/symbol_tools.py | 11 +++++++++-- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/serena/symbol.py b/src/serena/symbol.py index e1050b46..6223b9d2 100644 --- a/src/serena/symbol.py +++ b/src/serena/symbol.py @@ -405,7 +405,7 @@ class LanguageServerSymbol(Symbol, ToStringMixin): kind: NotRequired[str] children: NotRequired[list["LanguageServerSymbol.OutputDict"]] - OutputDictKey = Literal["name", "name_path", "location", "body_location", "body", "kind", "children"] + OutputDictKey = Literal["name", "name_path", "relative_path", "location", "body_location", "body", "kind", "children"] def to_dict( self, diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py index 736fa435..afd8d579 100644 --- a/src/serena/tools/jetbrains_tools.py +++ b/src/serena/tools/jetbrains_tools.py @@ -91,6 +91,8 @@ class JetBrainsFindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead, ToolMark Finds symbols that reference the given symbol using the JetBrains backend """ + symbol_dict_grouper = JetBrainsSymbolDictGrouper(["relative_path", "type"], ["type"], collapse_singleton=True) + # TODO: (maybe) - add content snippets showing the references like in LS based version? def apply( self, @@ -118,8 +120,10 @@ class JetBrainsFindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead, ToolMark relative_path=relative_path, include_quick_info=include_info, ) - result = self._to_json(response_dict) - return self._limit_length(result, max_answer_chars) + symbol_dicts = response_dict["symbols"] + result = self.symbol_dict_grouper.group(symbol_dicts) + result_json = self._to_json(result) + return self._limit_length(result_json, max_answer_chars) class JetBrainsGetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index a22c1eef..650a00e5 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -176,6 +176,8 @@ class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead): Finds symbols that reference the given symbol using the language server backend """ + symbol_dict_grouper = LanguageServerSymbolDictGrouper(["relative_path", "kind"], ["kind"], collapse_singleton=True) + # noinspection PyDefaultArgument def apply( self, @@ -204,6 +206,7 @@ class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead): parsed_include_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in include_kinds] if include_kinds else None parsed_exclude_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in exclude_kinds] if exclude_kinds else None symbol_retriever = self.create_language_server_symbol_retriever() + references_in_symbols = symbol_retriever.find_referencing_symbols( name_path, relative_file_path=relative_path, @@ -211,6 +214,7 @@ class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead): include_kinds=parsed_include_kinds, exclude_kinds=parsed_exclude_kinds, ) + reference_dicts = [] for ref in references_in_symbols: ref_dict_orig = ref.symbol.to_dict( @@ -227,8 +231,11 @@ class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead): ) ref_dict["content_around_reference"] = content_around_ref.to_display_string() reference_dicts.append(ref_dict) - result = self._to_json(reference_dicts) - return self._limit_length(result, max_answer_chars) + + result = self.symbol_dict_grouper.group(reference_dicts) # type: ignore + + result_json = self._to_json(result) + return self._limit_length(result_json, max_answer_chars) class ReplaceSymbolBodyTool(Tool, ToolMarkerSymbolicEdit): From 82d711aeb5d6c8a52c12d8c5523bf97c9f97559f Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 13 Feb 2026 23:38:07 +0100 Subject: [PATCH 079/105] LanguageServerSymbol.to_dict: Remove include_ prefix from params --- src/serena/symbol.py | 32 ++++++++++++++------------------ src/serena/tools/symbol_tools.py | 11 +++-------- 2 files changed, 17 insertions(+), 26 deletions(-) diff --git a/src/serena/symbol.py b/src/serena/symbol.py index 6223b9d2..43d9b3cf 100644 --- a/src/serena/symbol.py +++ b/src/serena/symbol.py @@ -415,10 +415,10 @@ class LanguageServerSymbol(Symbol, ToStringMixin): kind: bool = False, location: bool = False, depth: int = 0, - include_body: bool = False, + body: bool = False, body_location: bool = False, - include_children_body: bool = False, - include_relative_path: bool = False, + children_body: bool = False, + relative_path: bool = False, child_inclusion_predicate: Callable[[Self], bool] | None = None, ) -> OutputDict: """ @@ -429,12 +429,12 @@ class LanguageServerSymbol(Symbol, ToStringMixin): :param kind: whether to include the kind of the symbol :param location: whether to include the location of the symbol :param depth: the depth up to which to include child symbols (0 = do not include children) - :param include_body: whether to include the body of the top-level symbol. - :param include_children_body: whether to also include the body of the children. + :param body: whether to include the body of the top-level symbol. + :param children_body: whether to also include the body of the children. Note that the body of the children is part of the body of the parent symbol, so there is usually no need to set this to True unless you want process the output and pass the children without passing the parent body to the LM. - :param include_relative_path: whether to include the relative path of the symbol. + :param relative_path: whether to include the relative path of the symbol. If `location` is True, this defines whether to include the path in the location entry. If `location` is False, this defines whether to include the relative path as a top-level entry. Relative paths of the symbol's children are always excluded. @@ -453,19 +453,16 @@ class LanguageServerSymbol(Symbol, ToStringMixin): result["kind"] = self.kind if location: - result["location"] = self.location.to_dict(include_relative_path=include_relative_path) - elif include_relative_path: + result["location"] = self.location.to_dict(include_relative_path=relative_path) + elif relative_path: result["relative_path"] = self.relative_path if body_location: body_start_line, body_end_line = self.get_body_line_numbers() result["body_location"] = {"start_line": body_start_line, "end_line": body_end_line} - if include_body: - body = self.body - if body is None: - log.warning("Requested body for symbol, but it is not present. The symbol might have been loaded with include_body=False.") - result["body"] = body + if body: + result["body"] = self.body if child_inclusion_predicate is None: child_inclusion_predicate = lambda s: True @@ -484,10 +481,10 @@ class LanguageServerSymbol(Symbol, ToStringMixin): body_location=body_location, depth=depth - 1, child_inclusion_predicate=child_inclusion_predicate, - include_body=include_children_body, - include_children_body=include_children_body, + body=children_body, + children_body=children_body, # all children have the same relative path as the parent - include_relative_path=False, + relative_path=False, ) ) return children @@ -636,8 +633,7 @@ class LanguageServerSymbolRetriever: include_rel_path = within_relative_path is not None raise ValueError( f"Found multiple {len(symbol_candidates)} symbols matching '{name_path_pattern}'. " - "They are: \n" - + json.dumps([s.to_dict(kind=True, include_relative_path=include_rel_path) for s in symbol_candidates], indent=2) + "They are: \n" + json.dumps([s.to_dict(kind=True, relative_path=include_rel_path) for s in symbol_candidates], indent=2) ) def find_by_location(self, location: LanguageServerSymbolLocation) -> LanguageServerSymbol | None: diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index 650a00e5..9a673aeb 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -82,7 +82,7 @@ class GetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead): name=True, depth=depth, kind=True, - include_relative_path=False, + relative_path=False, location=False, child_inclusion_predicate=child_inclusion_predicate, ) @@ -157,10 +157,7 @@ class FindSymbolTool(Tool, ToolMarkerSymbolicRead): substring_matching=substring_matching, within_relative_path=relative_path, ) - symbol_dicts = [ - dict(s.to_dict(kind=True, include_relative_path=True, body_location=True, depth=depth, include_body=include_body)) - for s in symbols - ] + symbol_dicts = [dict(s.to_dict(kind=True, relative_path=True, body_location=True, depth=depth, body=include_body)) for s in symbols] if not include_body and include_info: # we add an info field to the symbol dicts if requested for s, s_dict in zip(symbols, symbol_dicts, strict=True): @@ -217,9 +214,7 @@ class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead): reference_dicts = [] for ref in references_in_symbols: - ref_dict_orig = ref.symbol.to_dict( - kind=True, include_relative_path=True, depth=0, include_body=include_body, body_location=True - ) + ref_dict_orig = ref.symbol.to_dict(kind=True, relative_path=True, depth=0, body=include_body, body_location=True) ref_dict = dict(ref_dict_orig) if not include_body: ref_relative_path = ref.symbol.location.relative_path From a0aa3a72051ad994482e79ed5e0346605130bd27 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 13 Feb 2026 23:46:05 +0100 Subject: [PATCH 080/105] Fix duplicate computation of included children --- src/serena/symbol.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/serena/symbol.py b/src/serena/symbol.py index 43d9b3cf..af250b6a 100644 --- a/src/serena/symbol.py +++ b/src/serena/symbol.py @@ -492,7 +492,7 @@ class LanguageServerSymbol(Symbol, ToStringMixin): if depth > 0: children = included_children(self) if len(children) > 0: - result["children"] = included_children(self) + result["children"] = children return result From 63377658d7a6df7082acbd7a27b58d498d15bd84 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 13 Feb 2026 23:51:55 +0100 Subject: [PATCH 081/105] Improve docstrings --- src/serena/symbol.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/serena/symbol.py b/src/serena/symbol.py index af250b6a..a5f395fb 100644 --- a/src/serena/symbol.py +++ b/src/serena/symbol.py @@ -807,19 +807,19 @@ class SymbolDictGrouper(Generic[TSymbolDict], ABC): ) -> None: """ :param symbol_dict_type: the TypedDict type that represents the type of the symbol dictionaries to be grouped - :param children_key: the key in the symbol dictionaries that contains the list of child symbols. This is necessary for recursive grouping. + :param children_key: the key in the symbol dictionaries that contains the list of child symbols (for recursive grouping). :param group_keys: keys by which to group the symbol dictionaries. Must be a subset of the keys of `symbol_dict_type`. :param group_children_keys: keys by which to group the child symbol dictionaries. Must be a subset of the keys of `symbol_dict_type`. :param collapse_singleton: whether to collapse dictionaries containing a single entry after regrouping to just the entry's value """ - # symbol_dict_type must be a TypedDict type. - # We check whether the type contains all the keys specified in `keys` and raise an error if not. + # check whether the type contains all the keys specified in `keys` and raise an error if not. if not hasattr(symbol_dict_type, "__annotations__"): raise ValueError(f"symbol_dict_type must be a TypedDict type, got {symbol_dict_type}") symbol_dict_keys = set(symbol_dict_type.__annotations__.keys()) for key in group_keys + [children_key] + group_children_keys: if key not in symbol_dict_keys: raise ValueError(f"symbol_dict_type {symbol_dict_type} does not contain key '{key}'") + self._children_key = children_key self._group_keys = group_keys self._group_children_keys = group_children_keys @@ -863,7 +863,7 @@ class SymbolDictGrouper(Generic[TSymbolDict], ABC): elif len(item) == 2 and self._children_key in item: # {"name": "foo", "children": {...}} -> {"foo": {...}} # if there are exactly two entries and one of them is the children key, - # convert to {other_value: children} if there are exactly two entries and one of them is the children key + # convert to {other_value: children} other_key = next(k for k in item.keys() if k != self._children_key) new_item = {item[other_key]: item[self._children_key]} return new_item From 2139552b0f0bf1cf1d808344d6bce3e245549eae Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 14 Feb 2026 00:44:22 +0100 Subject: [PATCH 082/105] Fix health check incorrectly checking symbol kind against int instead of SymbolKind.name --- src/serena/cli.py | 5 ++--- src/serena/symbol.py | 12 +++++++++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/serena/cli.py b/src/serena/cli.py index 73aeb6c9..3cc66d14 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -39,6 +39,7 @@ from serena.tools import FindReferencingSymbolsTool, FindSymbolTool, GetSymbolsO from serena.util.dataclass import get_dataclass_default from serena.util.logging import MemoryLogHandler from solidlsp.ls_config import Language +from solidlsp.ls_types import SymbolKind from solidlsp.util.subprocess_util import subprocess_kwargs log = logging.getLogger(__name__) @@ -808,9 +809,7 @@ class ProjectCommands(AutoRegisteringGroup): return # Extract suitable symbol (prefer class or function over variables) - # LSP symbol kinds: 5=class, 12=function, 6=method, 9=constructor - preferred_kinds = [5, 12, 6, 9] # class, function, method, constructor - + preferred_kinds = {SymbolKind.Class.name, SymbolKind.Function.name, SymbolKind.Method.name, SymbolKind.Constructor.name} selected_symbol = None for symbol in overview_data: if symbol.get("kind") in preferred_kinds: diff --git a/src/serena/symbol.py b/src/serena/symbol.py index a5f395fb..dc02f58c 100644 --- a/src/serena/symbol.py +++ b/src/serena/symbol.py @@ -212,14 +212,17 @@ class LanguageServerSymbol(Symbol, ToStringMixin): return [] def _tostring_additional_entries(self) -> dict[str, Any]: - return dict(name=self.name, kind=self.kind, num_children=len(self.symbol_root["children"])) + return dict(name=self.name, kind=self.symbol_kind_name, num_children=len(self.symbol_root["children"])) @property def name(self) -> str: return self.symbol_root["name"] @property - def kind(self) -> str: + def symbol_kind_name(self) -> str: + """ + :return: string representation of the symbol kind (name attribute of the `SymbolKind` enum item) + """ return SymbolKind(self.symbol_kind).name @property @@ -403,6 +406,9 @@ class LanguageServerSymbol(Symbol, ToStringMixin): body_location: NotRequired[dict[str, Any]] body: NotRequired[str | None] kind: NotRequired[str] + """ + string representation of the symbol kind (name attribute of the `SymbolKind` enum item) + """ children: NotRequired[list["LanguageServerSymbol.OutputDict"]] OutputDictKey = Literal["name", "name_path", "relative_path", "location", "body_location", "body", "kind", "children"] @@ -450,7 +456,7 @@ class LanguageServerSymbol(Symbol, ToStringMixin): result["name"] = self.name if kind: - result["kind"] = self.kind + result["kind"] = self.symbol_kind_name if location: result["location"] = self.location.to_dict(include_relative_path=relative_path) From 614b0356216dd837b407b9e053701bd336d0cab1 Mon Sep 17 00:00:00 2001 From: takeokunn Date: Sat, 14 Feb 2026 17:30:59 +0900 Subject: [PATCH 083/105] fix(tests): auto-skip Phpactor tests if PHP is unavailable - Add check in conftest.py to disable PHP_PHPACTOR tests when php binary is not found - Refactor test_php_basic.py to use dynamic PHP server list based on availability - Ensure tests for PHP_PHPACTOR are only parametrized if PHP is present --- test/conftest.py | 5 +++++ test/solidlsp/php/test_php_basic.py | 17 +++++++++++------ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/test/conftest.py b/test/conftest.py index 6d2cf211..822006f2 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -236,6 +236,11 @@ def _determine_disabled_languages() -> list[Language]: if not clangd_tests_enabled: result.append(Language.CPP) + # Disable PHP_PHPACTOR tests if php is not available + php_tests_enabled = _sh.which("php") is not None + if not php_tests_enabled: + result.append(Language.PHP_PHPACTOR) + al_tests_enabled = True if not al_tests_enabled: result.append(Language.AL) diff --git a/test/solidlsp/php/test_php_basic.py b/test/solidlsp/php/test_php_basic.py index 5396687d..fc465da0 100644 --- a/test/solidlsp/php/test_php_basic.py +++ b/test/solidlsp/php/test_php_basic.py @@ -5,18 +5,23 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language +from test.conftest import language_tests_enabled + +_php_servers: list[Language] = [Language.PHP] +if language_tests_enabled(Language.PHP_PHPACTOR): + _php_servers.append(Language.PHP_PHPACTOR) @pytest.mark.php class TestPhpLanguageServers: - @pytest.mark.parametrize("language_server", [Language.PHP, Language.PHP_PHPACTOR], indirect=True) + @pytest.mark.parametrize("language_server", _php_servers, indirect=True) @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) def test_ls_is_running(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test that the language server starts and stops successfully.""" assert language_server.is_running() assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve() - @pytest.mark.parametrize("language_server", [Language.PHP, Language.PHP_PHPACTOR], indirect=True) + @pytest.mark.parametrize("language_server", _php_servers, indirect=True) @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) def test_find_definition_within_file(self, language_server: SolidLanguageServer, repo_path: Path) -> None: @@ -46,7 +51,7 @@ class TestPhpLanguageServers: if language_server.language_server.language != Language.PHP_PHPACTOR: assert definition_location["range"]["start"]["character"] == 0 - @pytest.mark.parametrize("language_server", [Language.PHP, Language.PHP_PHPACTOR], indirect=True) + @pytest.mark.parametrize("language_server", _php_servers, indirect=True) @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) def test_find_definition_across_files(self, language_server: SolidLanguageServer, repo_path: Path) -> None: # Intelephense uses line 12 (0-indexed), Phpactor uses line 13 (0-indexed) @@ -66,7 +71,7 @@ class TestPhpLanguageServers: if language_server.language_server.language != Language.PHP_PHPACTOR: assert definition_location["range"]["start"]["character"] == 0 - @pytest.mark.parametrize("language_server", [Language.PHP, Language.PHP_PHPACTOR], indirect=True) + @pytest.mark.parametrize("language_server", _php_servers, indirect=True) @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) def test_find_definition_simple_variable(self, language_server: SolidLanguageServer, repo_path: Path) -> None: file_path = str(repo_path / "simple_var.php") @@ -91,7 +96,7 @@ class TestPhpLanguageServers: if language_server.language_server.language != Language.PHP_PHPACTOR: assert definition_location["range"]["start"]["character"] == 0 # $localVar (0-indexed) - @pytest.mark.parametrize("language_server", [Language.PHP, Language.PHP_PHPACTOR], indirect=True) + @pytest.mark.parametrize("language_server", _php_servers, indirect=True) @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) def test_find_references_within_file(self, language_server: SolidLanguageServer, repo_path: Path) -> None: if language_server.language == Language.PHP_PHPACTOR and platform.system() == "Windows": @@ -142,7 +147,7 @@ class TestPhpLanguageServers: assert actual_locations == expected_locations - @pytest.mark.parametrize("language_server", [Language.PHP, Language.PHP_PHPACTOR], indirect=True) + @pytest.mark.parametrize("language_server", _php_servers, indirect=True) @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) def test_find_references_across_files(self, language_server: SolidLanguageServer, repo_path: Path) -> None: if language_server.language_server.language == Language.PHP_PHPACTOR and platform.system() == "Windows": From 558caa0ad55c2e1499ba76a55ae26dda2085bbfb Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 14 Feb 2026 13:25:45 +0100 Subject: [PATCH 084/105] Disable Phpactor tests in Windows CI #1039 --- test/conftest.py | 3 +++ test/solidlsp/php/test_php_basic.py | 12 ++++-------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/test/conftest.py b/test/conftest.py index 85d38a05..565d40b7 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,5 +1,6 @@ import logging import os +import platform import shutil as _sh from collections.abc import Iterator from contextlib import contextmanager @@ -209,6 +210,8 @@ is_ci = os.getenv("CI") == "true" or os.getenv("GITHUB_ACTIONS") == "true" Flag indicating whether the tests are running in the GitHub CI environment. """ +is_windows = platform.system() == "Windows" + def _determine_disabled_languages() -> list[Language]: """ diff --git a/test/solidlsp/php/test_php_basic.py b/test/solidlsp/php/test_php_basic.py index fc465da0..d10f1468 100644 --- a/test/solidlsp/php/test_php_basic.py +++ b/test/solidlsp/php/test_php_basic.py @@ -1,17 +1,19 @@ -import platform from pathlib import Path import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language -from test.conftest import language_tests_enabled +from test.conftest import is_ci, is_windows, language_tests_enabled _php_servers: list[Language] = [Language.PHP] if language_tests_enabled(Language.PHP_PHPACTOR): _php_servers.append(Language.PHP_PHPACTOR) +@pytest.mark.skipif( + is_ci and is_windows, reason="Tests are flaky" +) # TODO: Re-enable once we have a solution for running Phpactor tests on Windows CI #1039 @pytest.mark.php class TestPhpLanguageServers: @pytest.mark.parametrize("language_server", _php_servers, indirect=True) @@ -99,9 +101,6 @@ class TestPhpLanguageServers: @pytest.mark.parametrize("language_server", _php_servers, indirect=True) @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) def test_find_references_within_file(self, language_server: SolidLanguageServer, repo_path: Path) -> None: - if language_server.language == Language.PHP_PHPACTOR and platform.system() == "Windows": - pytest.skip("Phpactor references test not working on Windows, skipping for now") - index_php_path = str(repo_path / "index.php") # In index.php (0-indexed lines): @@ -150,9 +149,6 @@ class TestPhpLanguageServers: @pytest.mark.parametrize("language_server", _php_servers, indirect=True) @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) def test_find_references_across_files(self, language_server: SolidLanguageServer, repo_path: Path) -> None: - if language_server.language_server.language == Language.PHP_PHPACTOR and platform.system() == "Windows": - pytest.skip("Phpactor references test not working on Windows, skipping for now") - helper_php_path = str(repo_path / "helper.php") # In index.php (0-indexed lines): # Line 13: helperFunction(); // Usage of helperFunction From 9e95663cd71e387714fbaca91e685b3a61ef09db Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 14 Feb 2026 13:29:50 +0100 Subject: [PATCH 085/105] Disable TestFSharpLanguageServer::test_find_referencing_symbols_across_files in CI #1039 --- test/solidlsp/fsharp/test_fsharp_basic.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/solidlsp/fsharp/test_fsharp_basic.py b/test/solidlsp/fsharp/test_fsharp_basic.py index 15de4ad7..4e4e03c7 100644 --- a/test/solidlsp/fsharp/test_fsharp_basic.py +++ b/test/solidlsp/fsharp/test_fsharp_basic.py @@ -10,6 +10,7 @@ from solidlsp import SolidLanguageServer from solidlsp.language_servers.fsharp_language_server import FSharpLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils +from test.conftest import is_ci @pytest.mark.fsharp @@ -87,6 +88,7 @@ class TestFSharpLanguageServer: for expected in expected_symbols: assert expected in symbol_names, f"{expected} not found in Person.fs symbols" + @pytest.mark.skipif(is_ci, reason="Test is flaky") # TODO: Re-enable if the LS can be made more reliable #1039 @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) def test_find_referencing_symbols_across_files(self, language_server: SolidLanguageServer) -> None: """Test finding references to Calculator functions across files.""" From 2e2384020a009c7d8ce7f3bcd8a89c70f8c7bfa8 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 14 Feb 2026 13:32:21 +0100 Subject: [PATCH 086/105] Disable TestNixLanguageServer::test_hover_information in CI #1039 --- test/solidlsp/nix/test_nix_basic.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/solidlsp/nix/test_nix_basic.py b/test/solidlsp/nix/test_nix_basic.py index 5c4e3293..9bfedbf3 100644 --- a/test/solidlsp/nix/test_nix_basic.py +++ b/test/solidlsp/nix/test_nix_basic.py @@ -10,6 +10,7 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language +from test.conftest import is_ci # Skip all Nix tests on Windows as Nix doesn't support Windows pytestmark = pytest.mark.skipif(platform.system() == "Windows", reason="Nix and nil are not available on Windows") @@ -115,6 +116,7 @@ class TestNixLanguageServer: # Check if we found the inherit (line 67, 0-indexed: 66) assert 66 in ref_lines, f"Should find makeGreeting inherit at line 67, found at lines {[l+1 for l in ref_lines]}" + @pytest.mark.skipif(is_ci, reason="Test is flaky") # TODO: Re-enable if the hover test becomes more stable (#1039) @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) def test_hover_information(self, language_server: SolidLanguageServer) -> None: """Test hover information for symbols.""" From 3ab0720ff49c34f14680a72a44f46756d89798cc Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 14 Feb 2026 13:43:14 +0100 Subject: [PATCH 087/105] Disable TestSerenaAgent::test_find_symbol for FSHARP in CI #1039 --- test/serena/test_serena_agent.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index b43be9e8..63fb03f9 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -15,7 +15,7 @@ from serena.project import Project from serena.tools import SUCCESS_RESULT, FindReferencingSymbolsTool, FindSymbolTool, ReplaceContentTool, ReplaceSymbolBodyTool from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind -from test.conftest import get_repo_path, language_tests_enabled +from test.conftest import get_repo_path, is_ci, language_tests_enabled from test.solidlsp import clojure as clj @@ -124,6 +124,12 @@ class TestSerenaAgent: indirect=["serena_agent"], ) def test_find_symbol(self, serena_agent: SerenaAgent, symbol_name: str, expected_kind: str, expected_file: str): + # skip flaky tests in CI + # TODO: Revisit the flaky tests and re-enable once the LS issues are resolved #1039 + flaky_languages = {Language.FSHARP} + if set(serena_agent.get_active_lsp_languages()).intersection(flaky_languages) and is_ci: + pytest.skip("Test is flaky and thus skipped in CI environment.") + agent = serena_agent find_symbol_tool = agent.get_tool(FindSymbolTool) result = find_symbol_tool.apply_ex(name_path_pattern=symbol_name, include_info=True) From ee992690f2d3cd4e6a5567321253e8ce30416be2 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 14 Feb 2026 17:43:35 +0100 Subject: [PATCH 088/105] Add sleep time to fix flaky test in Windows CI --- test/solidlsp/test_ls_common.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/solidlsp/test_ls_common.py b/test/solidlsp/test_ls_common.py index f98fd05d..ca3e05e0 100644 --- a/test/solidlsp/test_ls_common.py +++ b/test/solidlsp/test_ls_common.py @@ -36,6 +36,7 @@ class TestLanguageServerCommonFunctionality: if is_ci and platform.system() == "Windows": now = time.time() os.utime(file_path, (now, now)) + time.sleep(5) # check that the file buffer has been invalidated and reloaded assert fb.contents == test_string2 From 31d458583fa737a195a172bf5628ebd8e7fa219b Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 14 Feb 2026 18:05:08 +0100 Subject: [PATCH 089/105] Remove 'on CI' suffix from CI task name [skip ci] --- .github/workflows/pytest.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 42dce26b..52268702 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -1,4 +1,4 @@ -name: Tests on CI +name: Tests on: pull_request: From 2024747e7ffcaa3600f630970f14a99828c7b6a5 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 14 Feb 2026 19:10:25 +0100 Subject: [PATCH 090/105] Disable TestSerenaAgent::test_find_symbol_references for TYPESCRIPT in CI #1039 --- test/serena/test_serena_agent.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index 63fb03f9..abd6bc2c 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -199,6 +199,12 @@ class TestSerenaAgent: indirect=["serena_agent"], ) def test_find_symbol_references(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None: + # skip flaky tests in CI + # TODO: Revisit the flaky tests and re-enable once the LS issues are resolved #1039 + flaky_languages = {Language.TYPESCRIPT} + if set(serena_agent.get_active_lsp_languages()).intersection(flaky_languages) and is_ci: + pytest.skip("Test is flaky and thus skipped in CI environment.") + agent = serena_agent # Find the symbol location first From e20af1a328fdedc2d2a68d2a489660c250e95eec Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 14 Feb 2026 19:13:02 +0100 Subject: [PATCH 091/105] Disable TestLanguageServerCommonFunctionality::test_open_file_cache_invalidate in Windows CI #1039 --- test/solidlsp/test_ls_common.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/test/solidlsp/test_ls_common.py b/test/solidlsp/test_ls_common.py index ca3e05e0..d43cf836 100644 --- a/test/solidlsp/test_ls_common.py +++ b/test/solidlsp/test_ls_common.py @@ -1,17 +1,18 @@ import os -import platform -import time import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language -from test.conftest import is_ci +from test.conftest import is_ci, is_windows class TestLanguageServerCommonFunctionality: """Test common functionality of SolidLanguageServer base implementation (not language-specific behaviour).""" + @pytest.mark.skipif( + is_ci and is_windows, reason="This test is flaky in Windows CI (file system does not update modified time reliably)." + ) @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) def test_open_file_cache_invalidate(self, language_server: SolidLanguageServer) -> None: """ @@ -32,12 +33,6 @@ class TestLanguageServerCommonFunctionality: with open(file_path, "w") as f: f.write(test_string2) - # ensure that mtime is updated for Windows CI (odd FS behaviour, updates lazily) - if is_ci and platform.system() == "Windows": - now = time.time() - os.utime(file_path, (now, now)) - time.sleep(5) - # check that the file buffer has been invalidated and reloaded assert fb.contents == test_string2 From 177f0f46cb529aecfeee14bec2b4b7e1e8a19335 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 14 Feb 2026 19:31:36 +0100 Subject: [PATCH 092/105] Enable mypy for test/ (allowing untyped defs) --- pyproject.toml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index be99a021..044a2872 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -114,7 +114,11 @@ warn_redundant_casts = true warn_unreachable = true warn_unused_configs = true warn_unused_ignores = false -exclude = "^build/|^docs/" +exclude = "^build/|^docs/|^test/resources/" + +[[tool.mypy.overrides]] +module = "test.*" +disallow_untyped_defs = false # untyped defs are OK in tests [tool.poe.env] PYDEVD_DISABLE_FILE_VALIDATION = "1" @@ -137,8 +141,9 @@ _black_format = "black src scripts test" _ruff_format = "ruff check --fix src scripts test" lint = ["_black_check", "_ruff_check"] format = ["_ruff_format", "_black_format"] -_mypy = "mypy src/serena src/solidlsp" -type-check = ["_mypy"] +_mypy_core = "mypy src/serena src/solidlsp" +_mypy_test = "mypy --disable-error-code no-untyped-def test" +type-check = ["_mypy_core", "_mypy_test"] # docs _autogen_rst = "python docs/autogen_rst.py" _sphinx_build = "sphinx-build -b html docs docs/_build -W --keep-going" From 7513800a0fa21a07a94781f9185a69cee6f37fad Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 14 Feb 2026 19:48:07 +0100 Subject: [PATCH 093/105] Fix mypy issues in tests --- test/serena/test_serena_agent.py | 1 - test/serena/test_symbol_editing.py | 2 +- test/solidlsp/fsharp/test_fsharp_basic.py | 15 ++++++++------- test/solidlsp/groovy/test_groovy_basic.py | 10 +++++++--- test/solidlsp/lua/test_lua_basic.py | 4 ++-- test/solidlsp/scala/test_scala_language_server.py | 1 + test/solidlsp/swift/test_swift_basic.py | 6 +++--- 7 files changed, 22 insertions(+), 17 deletions(-) diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index abd6bc2c..ff223440 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -461,7 +461,6 @@ class TestSerenaAgent: needle = r'console.log("WebSocketManager initializing\nStatus OK");' repl = r'console.log("WebSocketManager initialized\nAll systems go!");' replace_content_tool = serena_agent.get_tool(ReplaceContentTool) - mode: Literal["literal", "regex"] with project_file_modification_context(serena_agent, relative_path): result = replace_content_tool.apply( needle=re.escape(needle) if mode == "regex" else needle, diff --git a/test/serena/test_symbol_editing.py b/test/serena/test_symbol_editing.py index b885c558..69cd2a77 100644 --- a/test/serena/test_symbol_editing.py +++ b/test/serena/test_symbol_editing.py @@ -218,7 +218,7 @@ class EditingTest(ABC): with open(file_path, encoding="utf-8") as f: return f.read() - def run_test(self, content_after_ground_truth: str | SnapshotAssertion) -> None: + def run_test(self, content_after_ground_truth: SnapshotAssertion) -> None: with self._setup() as symbol_retriever: content_before = self._read_file(self.rel_path) code_editor = LanguageServerCodeEditor(symbol_retriever) diff --git a/test/solidlsp/fsharp/test_fsharp_basic.py b/test/solidlsp/fsharp/test_fsharp_basic.py index 4e4e03c7..3e599ae5 100644 --- a/test/solidlsp/fsharp/test_fsharp_basic.py +++ b/test/solidlsp/fsharp/test_fsharp_basic.py @@ -2,6 +2,7 @@ import os import tempfile import threading from pathlib import Path +from typing import Any from unittest.mock import Mock, patch import pytest @@ -142,14 +143,14 @@ class TestFSharpLanguageServer: file_path = os.path.join("Program.fs") # Use threading for cross-platform timeout (signal.SIGALRM is Unix-only) - result = [None] - exception = [None] + result: dict[str, Any] = dict(value=None) + exception: dict[str, Any] = dict(value=None) def run_completion(): try: - result[0] = language_server.request_completions(file_path, 15, 10) + result["value"] = language_server.request_completions(file_path, 15, 10) except Exception as e: - exception[0] = e + exception["value"] = e thread = threading.Thread(target=run_completion, daemon=True) thread.start() @@ -160,10 +161,10 @@ class TestFSharpLanguageServer: # The important thing is that the language server doesn't crash return - if exception[0]: - raise exception[0] + if exception["value"]: + raise exception["value"] - assert isinstance(result[0], list), "Completions should be a list" + assert isinstance(result["value"], list), "Completions should be a list" @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) def test_diagnostics(self, language_server: SolidLanguageServer) -> None: diff --git a/test/solidlsp/groovy/test_groovy_basic.py b/test/solidlsp/groovy/test_groovy_basic.py index 0ebae30c..8792f1dc 100644 --- a/test/solidlsp/groovy/test_groovy_basic.py +++ b/test/solidlsp/groovy/test_groovy_basic.py @@ -12,13 +12,14 @@ from solidlsp.settings import SolidLSPSettings @pytest.mark.groovy class TestGroovyLanguageServer: + language_server: SolidLanguageServer | None = None + test_repo_path: Path = Path(__file__).parent.parent.parent / "resources" / "repos" / "groovy" / "test_repo" + @classmethod def setup_class(cls): """ Set up test class with Groovy test repository. """ - cls.test_repo_path = Path(__file__).parent.parent.parent / "resources" / "repos" / "groovy" / "test_repo" - if not cls.test_repo_path.exists(): pytest.skip("Groovy test repository not found") @@ -56,10 +57,11 @@ class TestGroovyLanguageServer: """ Clean up language server. """ - if hasattr(cls, "language_server"): + if cls.language_server is not None: cls.language_server.stop() def test_find_symbol(self) -> None: + assert self.language_server is not None symbols = self.language_server.request_full_symbol_tree() assert SymbolUtils.symbol_tree_contains_name(symbols, "Main"), "Main class not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "Utils"), "Utils class not found in symbol tree" @@ -67,6 +69,7 @@ class TestGroovyLanguageServer: assert SymbolUtils.symbol_tree_contains_name(symbols, "ModelUser"), "ModelUser class not found in symbol tree" def test_find_referencing_class_symbols(self) -> None: + assert self.language_server is not None file_path = os.path.join("src", "main", "groovy", "com", "example", "Utils.groovy") refs = self.language_server.request_references(file_path, 3, 6) assert any("Main.groovy" in ref.get("relativePath", "") for ref in refs), "Utils should be referenced from Main.groovy" @@ -93,6 +96,7 @@ class TestGroovyLanguageServer: assert len(model_user_refs) >= 1, f"Model should be referenced from ModelUser.groovy at least 1 time, found {len(model_user_refs)}" def test_overview_methods(self) -> None: + assert self.language_server is not None symbols = self.language_server.request_full_symbol_tree() assert SymbolUtils.symbol_tree_contains_name(symbols, "Main"), "Main missing from overview" assert SymbolUtils.symbol_tree_contains_name(symbols, "Utils"), "Utils missing from overview" diff --git a/test/solidlsp/lua/test_lua_basic.py b/test/solidlsp/lua/test_lua_basic.py index 4d4849cf..633e3c45 100644 --- a/test/solidlsp/lua/test_lua_basic.py +++ b/test/solidlsp/lua/test_lua_basic.py @@ -137,7 +137,7 @@ class TestLuaLanguageServer: assert len(refs) >= 5, f"Should find at least 5 references to calculator.add, found {len(refs)}" # Verify exact reference locations - ref_files = {} + ref_files: dict[str, list[int]] = {} for ref in refs: filename = ref.get("uri", "").split("/")[-1] if filename not in ref_files: @@ -193,7 +193,7 @@ class TestLuaLanguageServer: assert len(refs) >= 1, f"Should find at least 1 reference to utils.trim, found {len(refs)}" # Verify exact reference locations - ref_files = {} + ref_files: dict[str, list[int]] = {} for ref in refs: filename = ref.get("uri", "").split("/")[-1] if filename not in ref_files: diff --git a/test/solidlsp/scala/test_scala_language_server.py b/test/solidlsp/scala/test_scala_language_server.py index 9c498416..bfc6d8f1 100644 --- a/test/solidlsp/scala/test_scala_language_server.py +++ b/test/solidlsp/scala/test_scala_language_server.py @@ -1,3 +1,4 @@ +# type: ignore import os import pytest diff --git a/test/solidlsp/swift/test_swift_basic.py b/test/solidlsp/swift/test_swift_basic.py index 5cd5616c..b0f6dc2a 100644 --- a/test/solidlsp/swift/test_swift_basic.py +++ b/test/solidlsp/swift/test_swift_basic.py @@ -89,7 +89,7 @@ class TestSwiftLanguageServerBasics: # First, let's check if Utils is used anywhere (it might not be in this simple test) # We'll test goto_definition on Utils struct itself symbols = language_server.request_document_symbols(utils_file).get_all_symbols_and_roots() - utils_symbol = next((s for s in symbols[0] if s.get("name") == "Utils"), None) + utils_symbol = next(s for s in symbols[0] if s.get("name") == "Utils") sel_start = utils_symbol["selectionRange"]["start"] definitions = language_server.request_definition(utils_file, sel_start["line"], sel_start["character"]) @@ -106,7 +106,7 @@ class TestSwiftLanguageServerBasics: file_path = os.path.join("src", "main.swift") symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() - calculator_symbol = next((s for s in symbols[0] if s.get("name") == "Calculator"), None) + calculator_symbol = next(s for s in symbols[0] if s.get("name") == "Calculator") sel_start = calculator_symbol["selectionRange"]["start"] references = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) @@ -128,7 +128,7 @@ class TestSwiftLanguageServerBasics: file_path = os.path.join("src", "main.swift") symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() - user_symbol = next((s for s in symbols[0] if s.get("name") == "User"), None) + user_symbol = next(s for s in symbols[0] if s.get("name") == "User") sel_start = user_symbol["selectionRange"]["start"] references = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) From e23800bea45975c1cf4174a74ed55d7223544a42 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 14 Feb 2026 20:26:20 +0100 Subject: [PATCH 094/105] Disable TestSerenaAgent::test_find_symbol for FSHARP in CI #1039 Use apply instead of apply_ex in test to make exceptions visible --- test/serena/test_serena_agent.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index ff223440..67a8bf14 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -126,13 +126,13 @@ class TestSerenaAgent: def test_find_symbol(self, serena_agent: SerenaAgent, symbol_name: str, expected_kind: str, expected_file: str): # skip flaky tests in CI # TODO: Revisit the flaky tests and re-enable once the LS issues are resolved #1039 - flaky_languages = {Language.FSHARP} + flaky_languages = {Language.FSHARP, Language.RUST} if set(serena_agent.get_active_lsp_languages()).intersection(flaky_languages) and is_ci: pytest.skip("Test is flaky and thus skipped in CI environment.") agent = serena_agent find_symbol_tool = agent.get_tool(FindSymbolTool) - result = find_symbol_tool.apply_ex(name_path_pattern=symbol_name, include_info=True) + result = find_symbol_tool.apply(name_path_pattern=symbol_name, include_info=True) symbols = json.loads(result) assert any( From 629bc75b97574bfa1b1d8ddc3802a7b8578129a9 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 14 Feb 2026 21:23:02 +0100 Subject: [PATCH 095/105] Make test_find_symbol_references support both grouped and ungrouped output format --- test/serena/test_serena_agent.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index 67a8bf14..3a3a8144 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -209,7 +209,7 @@ class TestSerenaAgent: # Find the symbol location first find_symbol_tool = agent.get_tool(FindSymbolTool) - result = find_symbol_tool.apply_ex(name_path_pattern=symbol_name, relative_path=def_file) + result = find_symbol_tool.apply(name_path_pattern=symbol_name, relative_path=def_file) time.sleep(1) symbols = json.loads(result) @@ -218,12 +218,26 @@ class TestSerenaAgent: # Now find references find_refs_tool = agent.get_tool(FindReferencingSymbolsTool) - result = find_refs_tool.apply_ex(name_path=def_symbol["name_path"], relative_path=def_symbol["relative_path"]) + result = find_refs_tool.apply(name_path=def_symbol["name_path"], relative_path=def_symbol["relative_path"]) + + def contains_ref_with_relative_path(refs, relative_path): + """ + Checks for reference to relative path, regardless of output format (grouped an ungrouped) + """ + if isinstance(refs, list): + for ref in refs: + if contains_ref_with_relative_path(ref, relative_path): + return True + elif isinstance(refs, dict): + if relative_path in refs: + return True + for value in refs.values(): + if contains_ref_with_relative_path(value, relative_path): + return True + return False refs = json.loads(result) - assert any( - ref["relative_path"] == ref_file for ref in refs - ), f"Expected to find reference to {symbol_name} in {ref_file}. refs={refs}" + assert contains_ref_with_relative_path(refs, ref_file), f"Expected to find reference to {symbol_name} in {ref_file}. refs={refs}" @pytest.mark.parametrize( "serena_agent,name_path,substring_matching,expected_symbol_name,expected_kind,expected_file", From 070b0ae5ac91ef746fdfa6629bb36aa45f06d863 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 14 Feb 2026 21:27:49 +0100 Subject: [PATCH 096/105] Fix mypy issues --- test/serena/test_symbol.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/serena/test_symbol.py b/test/serena/test_symbol.py index 5068bd87..461d2c94 100644 --- a/test/serena/test_symbol.py +++ b/test/serena/test_symbol.py @@ -250,9 +250,9 @@ class TestSymbolDictTypes: """ dict_type_keys = dict_type.__annotations__.keys() assert len(dict_type_keys) == len( - key_type.__args__ - ), f"Expected {len(key_type.__args__)} keys in {dict_type}, but got {len(dict_type_keys)}" - for expected_key in key_type.__args__: + key_type.__args__ # type: ignore + ), f"Expected {len(key_type.__args__)} keys in {dict_type}, but got {len(dict_type_keys)}" # type: ignore + for expected_key in key_type.__args__: # type: ignore assert expected_key in dict_type_keys, f"Expected key '{expected_key}' not found in {dict_type}" def test_ls_symbol_dict_type(self): From 52cc9729d27ffdfba2c6a5543df52b367c250b0c Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sun, 15 Feb 2026 00:57:55 +0100 Subject: [PATCH 097/105] Disable TestFSharpLanguageServer::test_hover_information in CI #1039 --- test/solidlsp/fsharp/test_fsharp_basic.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/solidlsp/fsharp/test_fsharp_basic.py b/test/solidlsp/fsharp/test_fsharp_basic.py index 3e599ae5..1d08d82d 100644 --- a/test/solidlsp/fsharp/test_fsharp_basic.py +++ b/test/solidlsp/fsharp/test_fsharp_basic.py @@ -125,6 +125,7 @@ class TestFSharpLanguageServer: # We should get at least some definitions assert len(definitions) >= 0, "Should get definitions (even if empty for complex cases)" + @pytest.mark.skipif(is_ci, reason="Test is flaky") # TODO: Re-enable if the LS can be made more reliable #1039 @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) def test_hover_information(self, language_server: SolidLanguageServer) -> None: """Test hover information functionality.""" From b7ce7375999623462244bff2c3699707543594d9 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sun, 15 Feb 2026 12:44:31 +0100 Subject: [PATCH 098/105] Disable TestSwiftLanguageServerBasics::test_request_references_calculator_class in CI #1039 --- test/solidlsp/swift/test_swift_basic.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/solidlsp/swift/test_swift_basic.py b/test/solidlsp/swift/test_swift_basic.py index b0f6dc2a..7eae2a16 100644 --- a/test/solidlsp/swift/test_swift_basic.py +++ b/test/solidlsp/swift/test_swift_basic.py @@ -14,6 +14,7 @@ from serena.project import Project from serena.text_utils import LineType from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language +from test.conftest import is_ci # Skip Swift tests on Windows due to complex GitHub Actions configuration WINDOWS_SKIP = platform.system() == "Windows" @@ -99,6 +100,7 @@ class TestSwiftLanguageServerBasics: utils_def = definitions[0] assert utils_def.get("uri", "").endswith("utils.swift"), "Definition should be in utils.swift" + @pytest.mark.skipif(is_ci, reason="Test is flaky in CI") @pytest.mark.parametrize("language_server", [Language.SWIFT], indirect=True) def test_request_references_calculator_class(self, language_server: SolidLanguageServer) -> None: """Test request_references on the Calculator class.""" From 4a0812972fa8b85e807d5aef32378cf853fac573 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sun, 15 Feb 2026 18:41:08 +0100 Subject: [PATCH 099/105] JetBrainsPluginClient: Generalise path matching * Account for additional prefixes * Account for WSL vs. Windows path mismatch Add a test for path matching --- .../jetbrains/jetbrains_plugin_client.py | 66 +++++++++++++------ test/serena/test_jetbrains_plugin_client.py | 20 ++++++ 2 files changed, 67 insertions(+), 19 deletions(-) create mode 100644 test/serena/test_jetbrains_plugin_client.py diff --git a/src/serena/jetbrains/jetbrains_plugin_client.py b/src/serena/jetbrains/jetbrains_plugin_client.py index 2607f547..1ae525fa 100644 --- a/src/serena/jetbrains/jetbrains_plugin_client.py +++ b/src/serena/jetbrains/jetbrains_plugin_client.py @@ -118,43 +118,71 @@ class JetBrainsPluginClient(ToStringMixin): if client.matches(resolved_path): return client + plugin_paths_found = [] for port in range(cls.BASE_PORT, cls.BASE_PORT + 20): client = JetBrainsPluginClient(port) if client.matches(resolved_path): log.info("Found matching %s", client) cls._last_port = port return client + elif client._project_root is not None: + plugin_paths_found.append(client._project_root) + log.warning( + "Searched for Serena JetBrains plugin service for project at %s but found no matching service. " + "Found plugin instances for the following project paths: %s", + resolved_path, + plugin_paths_found, + ) raise ServerNotFoundError("Found no Serena service in a JetBrains IDE instance for the project at " + str(resolved_path)) @staticmethod - def _normalize_wsl_path(path_str: str) -> Path: + def _paths_match(resolved_serena_path: str, plugin_path: str) -> bool: """ - Normalize WSL UNC paths to Linux paths for comparison. + Checks whether the resolved Serena path matches the plugin path, accounting for possible prefixes + in the plugin path, different file system perspectives, and case sensitivity. - When JetBrains IDE runs on Windows with a project opened from WSL, - it returns paths like `//wsl.localhost/Ubuntu-24.04/home/user/project` - or `//wsl$/Ubuntu/home/user/project`. This method converts such paths - to standard Linux format `/home/user/project` for proper matching. + Concrete aspects considered: + - The plugin path may contain prefixes: + - The plugin path may be a WSL UNC path, e.g. `//wsl.localhost/Ubuntu-24.04/home/user/project` + or `//wsl$/Ubuntu/home/user/project` while Serena will just have `/home/user/project` + - Other prefixes like `/workspaces/serena/C:/Users/user/projects/my-app` + - One path may use a different file system perspective (particularly WSL vs Windows-native) but still + point to the same location, e.g. `/mnt/c/` vs `C:/` + - Case sensitivity - :param path_str: Path string that may be a WSL UNC path - :return: Normalized Path object + :param resolved_serena_path: The resolved project root path from Serena's perspective + :param plugin_path: The project root path reported by the plugin (which may be a WSL UNC path) + :return: True if the paths match, False otherwise """ - path_str = str(path_str) - # Match //wsl.localhost//... or //wsl$//... - match = re.match(r"^//wsl(?:\.localhost|\$)/[^/]+(.*)$", path_str, re.IGNORECASE) - if match: - return Path(match.group(1)) - return Path(path_str) + # try to resolve the plugin path, checking for a direct match + # (this is robust against symlinks as long as there are no prefixes) + try: + resolved_plugin_path = str(Path(plugin_path).resolve()) + if resolved_plugin_path == resolved_serena_path: + return True + except: + pass + + def normalise_wsl_mnt(path_str: str) -> str: + # normalise WSL /mnt/c/ to c:/ for comparison + return re.sub(r"/mnt/([a-z])/", r"\1:/", path_str, flags=re.IGNORECASE) + + # standardise paths for comparison: normalise WSL /mnt/ to Windows paths and ignore case + std_serena_path = normalise_wsl_mnt(str(resolved_serena_path)).lower() + std_plugin_path = normalise_wsl_mnt(str(plugin_path)).lower() + + # At this point, the plugin path may still contain prefixes, so we check if the Serena path is a suffix of the plugin path + return std_plugin_path.endswith(std_serena_path) def matches(self, resolved_path: Path) -> bool: + """ + :param resolved_path: the resolved project root path from Serena's perspective + :return: whether this client instance matches the given project path + """ if self._project_root is None: return False - try: - plugin_root = self._normalize_wsl_path(self._project_root) - return plugin_root.resolve() == resolved_path - except ConnectionError: - return False + return self._paths_match(str(resolved_path), self._project_root) def is_version_at_least(self, *version_parts: int) -> bool: if self._plugin_version is None: diff --git a/test/serena/test_jetbrains_plugin_client.py b/test/serena/test_jetbrains_plugin_client.py new file mode 100644 index 00000000..d6590eaf --- /dev/null +++ b/test/serena/test_jetbrains_plugin_client.py @@ -0,0 +1,20 @@ +import pytest + +from serena.constants import REPO_ROOT +from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient + + +class TestSerenaJetBrainsPluginClient: + @pytest.mark.parametrize( + "serena_path, plugin_path", + [ + (REPO_ROOT, REPO_ROOT), + ("/home/user/project", "/home/user/project"), + ("/home/user/project", "//wsl.localhost/Ubuntu-24.04/home/user/project"), + ("/home/user/project", "//wsl$/Ubuntu/home/user/project"), + ("/home/user/project", "//wsl$/Ubuntu/home/user/project"), + ("/mnt/c/Users/user/projects/my-app", "/workspaces/serena/C:/Users/user/projects/my-app"), + ], + ) + def test_path_matching(self, serena_path, plugin_path) -> None: + assert JetBrainsPluginClient._paths_match(serena_path, plugin_path) From 16c2124feb9a3cc242cc1583e70cb13f75cb8603 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sun, 15 Feb 2026 22:38:51 +0100 Subject: [PATCH 100/105] Disable other TestSwiftLanguageServerBasics::test_request_references_* tests in CI #1039 --- test/solidlsp/swift/test_swift_basic.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/solidlsp/swift/test_swift_basic.py b/test/solidlsp/swift/test_swift_basic.py index 7eae2a16..d3934f5f 100644 --- a/test/solidlsp/swift/test_swift_basic.py +++ b/test/solidlsp/swift/test_swift_basic.py @@ -123,6 +123,7 @@ class TestSwiftLanguageServerBasics: line_5_refs = [ref for ref in calculator_refs if ref.get("range", {}).get("start", {}).get("line") == 4] assert len(line_5_refs) > 0, "Calculator should be referenced at line 5" + @pytest.mark.skipif(is_ci, reason="Test is flaky in CI") @pytest.mark.parametrize("language_server", [Language.SWIFT], indirect=True) def test_request_references_user_struct(self, language_server: SolidLanguageServer) -> None: """Test request_references on the User struct.""" @@ -144,6 +145,7 @@ class TestSwiftLanguageServerBasics: line_9_refs = [ref for ref in user_refs if ref.get("range", {}).get("start", {}).get("line") == 8] assert len(line_9_refs) > 0, "User should be referenced at line 9" + @pytest.mark.skipif(is_ci, reason="Test is flaky in CI") @pytest.mark.parametrize("language_server", [Language.SWIFT], indirect=True) def test_request_references_utils_struct(self, language_server: SolidLanguageServer) -> None: """Test request_references on the Utils struct.""" From f1171969897fbde7437675144b3e084f74c66a37 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Tue, 17 Feb 2026 14:05:54 +0100 Subject: [PATCH 101/105] Add section on logs to usage documentation --- docs/02-usage/050_configuration.md | 3 ++- docs/02-usage/065_logs.md | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 docs/02-usage/065_logs.md diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index 88192cb5..e5ae66e8 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -14,7 +14,8 @@ Serena is configured in using a multi-layered approach: * **contexts and modes** for composable configuration, which can be enabled on a case-by-case basis (see below) * **command-line parameters** passed to the `start-mcp-server` server command (overriding/extending configured settings) See [MCP Server Command-Line Arguments](mcp-args) for further information. - + +(global-config)= ## Global Configuration The global configuration file allows you to change general settings and defaults that will apply to all projects unless overridden. diff --git a/docs/02-usage/065_logs.md b/docs/02-usage/065_logs.md new file mode 100644 index 00000000..fb8f4c5b --- /dev/null +++ b/docs/02-usage/065_logs.md @@ -0,0 +1,14 @@ +# Logs + +It can be vital to understand what is happening in Serena, especially when something goes wrong. + +You can access Serena's live logs via + * the [Serena dashboard](060_dashboard) (tab "Logs") + * the [GUI tool](060_dashboard). + +Additionally, logs are persisted in the Serena home directory, which, by default, is located at + * `%USERPROFILE%\.serena\logs` on Windows + * `~/.serena/logs` on Linux and macOS. + +You can adjust the log level via the [global configuration](global-config). +You additionally have the option of enabling full tracing of language server communication (mostly for development purposes). From 547a938832c63f6fe14f221d816ea58f9b933953 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Tue, 17 Feb 2026 14:52:45 +0100 Subject: [PATCH 102/105] Make thinking tools optional (disabled by default) --- src/serena/tools/workflow_tools.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/serena/tools/workflow_tools.py b/src/serena/tools/workflow_tools.py index a3a58104..9528f565 100644 --- a/src/serena/tools/workflow_tools.py +++ b/src/serena/tools/workflow_tools.py @@ -53,7 +53,7 @@ class OnboardingTool(Tool): return self.prompt_factory.create_onboarding_prompt(system=system) -class ThinkAboutCollectedInformationTool(Tool): +class ThinkAboutCollectedInformationTool(Tool, ToolMarkerOptional): """ Thinking tool for pondering the completeness of collected information. """ @@ -67,7 +67,7 @@ class ThinkAboutCollectedInformationTool(Tool): return self.prompt_factory.create_think_about_collected_information() -class ThinkAboutTaskAdherenceTool(Tool): +class ThinkAboutTaskAdherenceTool(Tool, ToolMarkerOptional): """ Thinking tool for determining whether the agent is still on track with the current task. """ @@ -83,7 +83,7 @@ class ThinkAboutTaskAdherenceTool(Tool): return self.prompt_factory.create_think_about_task_adherence() -class ThinkAboutWhetherYouAreDoneTool(Tool): +class ThinkAboutWhetherYouAreDoneTool(Tool, ToolMarkerOptional): """ Thinking tool for determining whether the task is truly completed. """ From 1ea5f0c25fef0c5ec206ad47cbbbc158dcb7dca8 Mon Sep 17 00:00:00 2001 From: TJ Kolleh Date: Thu, 19 Feb 2026 07:30:05 -0500 Subject: [PATCH 103/105] feat(scala): Add stale lock detection for Metals H2 database (#1012) * feat(scala): Add stale lock detection for Metals H2 database Add detection and automatic cleanup of stale Metals H2 database locks to improve multi-instance support. When a Metals process crashes without proper cleanup, it can leave a lock file that prevents proper AUTO_SERVER coordination with new instances. Changes: - Add metals_db_utils.py with detection logic for stale locks - Add StaleLockMode enum (auto-clean/warn/fail) for configurable behavior - Add MetalsStaleLockError exception for fail mode - Integrate stale lock checking into ScalaLanguageServer init - Add configuration options: on_stale_lock, log_multi_instance_notice - Add comprehensive unit tests for detection logic This enhancement allows Serena's Metals to coexist with VS Code or other editors running Metals on the same project, with proper handling of edge cases like crashed processes. * test(scala): Add integration tests for stale lock handling modes Add 7 integration tests for ScalaLanguageServer stale lock detection: - AUTO_CLEAN mode cleans stale locks and proceeds - WARN mode logs warning without cleanup - FAIL mode raises MetalsStaleLockError - ACTIVE_INSTANCE logs info when notice enabled - ACTIVE_INSTANCE silent when notice disabled - NO_DATABASE proceeds silently - NO_LOCK proceeds silently Tests use mocking to avoid requiring Java/Coursier/Metals. * docs(scala): Add multi-instance and stale lock documentation Document Metals multi-instance support via H2 AUTO_SERVER: - Explain how multiple Metals instances share database and Bloop - Document memory considerations (~500MB per JVM) - Add stale lock detection configuration options - Document on_stale_lock modes (auto-clean, warn, fail) - Add YAML configuration examples * docs: Clean up docs Removed memory considerations and additional configuration sections from the Scala setup guide. * refactor(scala): Extract settings helper and add module-level constants - Add DEFAULT_METALS_VERSION, DEFAULT_CLIENT_NAME, DEFAULT_ON_STALE_LOCK, DEFAULT_LOG_MULTI_INSTANCE_NOTICE constants to eliminate duplication - Extract _get_scala_settings() pure function for centralized settings parsing - Remove redundant cleanup_stale_lock import - Add warning log when AUTO_CLEAN mode fails to remove stale lock - Simplify lock path handling with ternary expression This refactoring improves code maintainability by centralizing Scala settings extraction and applying functional programming principles. * chore(scala): fix typo in docstring and comments --- .../scala_setup_guide_for_serena.md | 38 ++- .../language_servers/scala_language_server.py | 151 +++++++++- src/solidlsp/ls_exceptions.py | 22 ++ src/solidlsp/util/metals_db_utils.py | 280 ++++++++++++++++++ test/solidlsp/scala/test_metals_db_utils.py | 268 +++++++++++++++++ .../scala/test_scala_stale_lock_handling.py | 261 ++++++++++++++++ 6 files changed, 1015 insertions(+), 5 deletions(-) create mode 100644 src/solidlsp/util/metals_db_utils.py create mode 100644 test/solidlsp/scala/test_metals_db_utils.py create mode 100644 test/solidlsp/scala/test_scala_stale_lock_handling.py diff --git a/docs/03-special-guides/scala_setup_guide_for_serena.md b/docs/03-special-guides/scala_setup_guide_for_serena.md index 2c4b1cc8..1e4bebca 100644 --- a/docs/03-special-guides/scala_setup_guide_for_serena.md +++ b/docs/03-special-guides/scala_setup_guide_for_serena.md @@ -63,5 +63,41 @@ These instructions cover the setup for projects that use sbt as the build tool, Notes: - Ensure you completed the manual or auto‑import steps so that the build is compiled and indexed; otherwise, code navigation and references may be incomplete until the first successful compile. +--- +## Running Multiple Metals Instances + +Serena can run alongside other Metals instances (e.g., VS Code with Metals extension) on the same project. This is **fully supported** by Metals via H2 AUTO_SERVER mode. + +### How It Works + +Metals uses an H2 database (`.metals/metals.mv.db`) to cache semantic information. When multiple Metals instances run on the same project: + +- **H2 AUTO_SERVER**: The first instance becomes the TCP server; subsequent instances connect as clients +- **Bloop Build Server**: All instances share a single Bloop process (port 8212) +- **Compilation Results**: Shared via Bloop — no duplicate compilation + +### Stale Lock Detection + +If a Metals process crashes without proper cleanup, it may leave a stale lock file (`.metals/metals.mv.db.lock.db`). This can prevent proper AUTO_SERVER coordination, causing new instances to fall back to in-memory database mode (degraded experience). + +Serena automatically detects and handles stale locks based on your configuration: + +```yaml +# ~/.serena/serena_config.yml or .serena/project.yml +ls_specific_settings: + scala: + on_stale_lock: "auto-clean" # auto-clean | warn | fail + log_multi_instance_notice: true # Log info when another Metals detected +``` + +#### Stale Lock Modes + +| Mode | Behavior | +|------|----------| +| `auto-clean` | **(Default, Recommended)** Automatically removes stale lock files and proceeds normally. | +| `warn` | Logs a warning but proceeds. Metals may use in-memory database (slower). | +| `fail` | Raises an error and refuses to start. Useful for debugging lock issues. | + +--- ## Reference -- Metals + sbt: [https://scalameta.org/metals/docs/build-tools/sbt](https://scalameta.org/metals/docs/build-tools/sbt) \ No newline at end of file +- Metals + sbt: [https://scalameta.org/metals/docs/build-tools/sbt](https://scalameta.org/metals/docs/build-tools/sbt) diff --git a/src/solidlsp/language_servers/scala_language_server.py b/src/solidlsp/language_servers/scala_language_server.py index e835c36f..ee500db7 100644 --- a/src/solidlsp/language_servers/scala_language_server.py +++ b/src/solidlsp/language_servers/scala_language_server.py @@ -7,6 +7,7 @@ import os import pathlib import shutil import subprocess +from enum import Enum from overrides import override @@ -23,16 +24,99 @@ if not PlatformUtils.get_platform_id().value.startswith("win"): log = logging.getLogger(__name__) +# Default configuration constants +DEFAULT_METALS_VERSION = "1.6.4" +DEFAULT_CLIENT_NAME = "Serena" +DEFAULT_ON_STALE_LOCK = "auto-clean" +DEFAULT_LOG_MULTI_INSTANCE_NOTICE = True + + +class StaleLockMode(Enum): + """Mode for handling stale Metals H2 database locks.""" + + AUTO_CLEAN = "auto-clean" + """Automatically remove stale lock files (default, recommended).""" + + WARN = "warn" + """Log a warning but proceed; may result in degraded experience.""" + + FAIL = "fail" + """Raise an error and refuse to start.""" + + +def _get_scala_settings(solidlsp_settings: SolidLSPSettings) -> dict[str, object]: + """ + Extract Scala-specific settings with defaults applied. + + Returns a dictionary with keys: + - metals_version: str + - client_name: str + - on_stale_lock: StaleLockMode + - log_multi_instance_notice: bool + """ + from solidlsp.ls_config import Language + + defaults: dict[str, object] = { + "metals_version": DEFAULT_METALS_VERSION, + "client_name": DEFAULT_CLIENT_NAME, + "on_stale_lock": StaleLockMode.AUTO_CLEAN, + "log_multi_instance_notice": DEFAULT_LOG_MULTI_INSTANCE_NOTICE, + } + + if not solidlsp_settings.ls_specific_settings: + return defaults + + scala_settings = solidlsp_settings.get_ls_specific_settings(Language.SCALA) + + # Parse stale lock mode with validation + on_stale_lock_str = scala_settings.get("on_stale_lock", DEFAULT_ON_STALE_LOCK) + try: + on_stale_lock = StaleLockMode(on_stale_lock_str) + except ValueError: + log.warning(f"Invalid on_stale_lock value '{on_stale_lock_str}', using '{DEFAULT_ON_STALE_LOCK}'") + on_stale_lock = StaleLockMode.AUTO_CLEAN + + return { + "metals_version": scala_settings.get("metals_version", DEFAULT_METALS_VERSION), + "client_name": scala_settings.get("client_name", DEFAULT_CLIENT_NAME), + "on_stale_lock": on_stale_lock, + "log_multi_instance_notice": scala_settings.get("log_multi_instance_notice", DEFAULT_LOG_MULTI_INSTANCE_NOTICE), + } + class ScalaLanguageServer(SolidLanguageServer): """ - Provides Scala specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Scala. + Provides Scala specific instantiation of the LanguageServer class. + Contains various configurations and settings specific to Scala. + + Configurable options in ls_specific_settings (in serena_config.yml): + + ls_specific_settings: + scala: + # Stale lock handling: auto-clean | warn | fail + on_stale_lock: 'auto-clean' + # Log notice when another Metals instance is detected + log_multi_instance_notice: true + # Metals version to bootstrap (default: DEFAULT_METALS_VERSION) + metals_version: '1.6.4' + # Client identifier sent to Metals (default: DEFAULT_CLIENT_NAME) + client_name: 'Serena' + + Multi-instance support: + Metals uses H2 AUTO_SERVER mode (enabled by default) to support multiple + concurrent instances sharing the same database. Running Serena's Metals + alongside VS Code's Metals is designed to work. The only issue is stale + locks from crashed processes, which this class can detect and clean up. """ def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ - Creates a ScalaLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. + Creates a ScalaLanguageServer instance. This class is not meant to be instantiated directly. + Use LanguageServer.create() instead. """ + # Check for stale locks before setting up dependencies (fail-fast) + self._check_metals_db_status(repository_root_path, solidlsp_settings) + scala_lsp_executable_path = self._setup_runtime_dependencies(config, solidlsp_settings) super().__init__( config, @@ -42,6 +126,62 @@ class ScalaLanguageServer(SolidLanguageServer): solidlsp_settings, ) + def _check_metals_db_status(self, repository_root_path: str, solidlsp_settings: SolidLSPSettings) -> None: + """ + Check the Metals H2 database status and handle stale locks. + + This method is called before setting up runtime dependencies to fail-fast + if there's a stale lock that the user has configured to fail on. + """ + from pathlib import Path + + from solidlsp.ls_exceptions import MetalsStaleLockError + from solidlsp.util.metals_db_utils import ( + MetalsDbStatus, + check_metals_db_status, + cleanup_stale_lock, + ) + + project_path = Path(repository_root_path) + status, lock_info = check_metals_db_status(project_path) + + # Get settings using the shared helper function + settings = _get_scala_settings(solidlsp_settings) + on_stale_lock: StaleLockMode = settings["on_stale_lock"] # type: ignore[assignment] + log_multi_instance_notice: bool = settings["log_multi_instance_notice"] # type: ignore[assignment] + + if status == MetalsDbStatus.ACTIVE_INSTANCE: + if log_multi_instance_notice and lock_info: + log.info( + f"Another Metals instance detected (PID: {lock_info.pid}). " + "This is fine - Metals supports multiple instances via H2 AUTO_SERVER. " + "Both instances will share the database and Bloop build server." + ) + + elif status == MetalsDbStatus.STALE_LOCK: + lock_path = lock_info.lock_path if lock_info else project_path / ".metals" / "metals.mv.db.lock.db" + lock_path_str = str(lock_path) + + if on_stale_lock == StaleLockMode.AUTO_CLEAN: + log.info(f"Stale Metals lock detected, cleaning up: {lock_path_str}") + cleanup_success = cleanup_stale_lock(lock_path) + if not cleanup_success: + log.warning( + f"Failed to clean up stale lock at {lock_path_str}. " + "Metals may fall back to in-memory database (degraded experience)." + ) + + elif on_stale_lock == StaleLockMode.WARN: + log.warning( + f"Stale Metals lock detected at {lock_path_str}. " + "A previous Metals process may have crashed. " + "Metals will fall back to in-memory database (degraded experience). " + "Consider removing the lock file manually or setting on_stale_lock='auto-clean'." + ) + + elif on_stale_lock == StaleLockMode.FAIL: + raise MetalsStaleLockError(lock_path_str) + @override def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in [ @@ -57,7 +197,10 @@ class ScalaLanguageServer(SolidLanguageServer): """ assert shutil.which("java") is not None, "JDK is not installed or not in PATH." - metals_version = "1.6.4" + # Get settings using the shared helper function + settings = _get_scala_settings(solidlsp_settings) + metals_version: str = settings["metals_version"] # type: ignore[assignment] + client_name: str = settings["client_name"] # type: ignore[assignment] metals_home = os.path.join(cls.ls_resources_dir(solidlsp_settings), "metals-lsp") os.makedirs(metals_home, exist_ok=True) @@ -98,7 +241,7 @@ class ScalaLanguageServer(SolidLanguageServer): "--java-opt", "-Xms100m", "--java-opt", - "-Dmetals.client=Serena", + f"-Dmetals.client={client_name}", artifact, "-o", metals_executable, diff --git a/src/solidlsp/ls_exceptions.py b/src/solidlsp/ls_exceptions.py index 481d188e..1a82f6b6 100644 --- a/src/solidlsp/ls_exceptions.py +++ b/src/solidlsp/ls_exceptions.py @@ -50,3 +50,25 @@ class SolidLSPException(Exception): s += " " s += f"(caused by {self.cause})" return s + + +class MetalsStaleLockError(SolidLSPException): + """ + Raised when a stale Metals H2 database lock is detected and the user + has configured fail-on-stale-lock behavior. + + A stale lock occurs when a previous Metals process crashed without + cleaning up its lock file, which can prevent proper AUTO_SERVER + coordination with new instances. + """ + + def __init__(self, lock_path: str, message: str | None = None) -> None: + self.lock_path = lock_path + if message is None: + message = ( + f"Stale Metals lock file detected at {lock_path}. " + "A previous Metals process may have crashed. " + "To resolve: remove the lock file manually, or set " + "on_stale_lock='auto-clean' in ls_specific_settings.scala." + ) + super().__init__(message) diff --git a/src/solidlsp/util/metals_db_utils.py b/src/solidlsp/util/metals_db_utils.py new file mode 100644 index 00000000..9e91eebf --- /dev/null +++ b/src/solidlsp/util/metals_db_utils.py @@ -0,0 +1,280 @@ +""" +Utilities for detecting and managing Scala Metals H2 database state. + +This module provides functions to detect existing Metals LSP instances by checking +the H2 database lock file, and to clean up stale locks from crashed processes. + +Metals uses H2 AUTO_SERVER mode (enabled by default) to support multiple concurrent +instances sharing the same database. However, if a Metals process crashes without +proper cleanup, it can leave a stale lock file that prevents proper AUTO_SERVER +coordination, causing new instances to fall back to in-memory database mode. +""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + pass + +log = logging.getLogger(__name__) + + +class MetalsDbStatus(Enum): + """Status of the Metals H2 database for a project.""" + + NO_DATABASE = "no_database" + """No .metals directory or database exists (fresh project).""" + + NO_LOCK = "no_lock" + """Database exists but no lock file (safe to start).""" + + ACTIVE_INSTANCE = "active_instance" + """Lock held by a running process (will share via AUTO_SERVER).""" + + STALE_LOCK = "stale_lock" + """Lock held by a dead process (needs cleanup).""" + + +@dataclass +class MetalsLockInfo: + """Information extracted from an H2 database lock file.""" + + pid: int | None + """Process ID that holds the lock, if parseable.""" + + port: int | None + """TCP port for AUTO_SERVER connection, if parseable.""" + + lock_path: Path + """Path to the lock file.""" + + is_stale: bool + """True if the owning process is no longer running.""" + + raw_content: str + """Raw content of the lock file for debugging.""" + + +def parse_h2_lock_file(lock_path: Path) -> MetalsLockInfo | None: + """ + Parse an H2 database lock file to extract connection information. + + The H2 lock file format varies by version but typically contains + server connection information. Common formats include: + - Text format: "server:localhost:9092" or similar + - Binary format with embedded PID + + Args: + lock_path: Path to the .lock.db file + + Returns: + MetalsLockInfo if the file can be parsed, None if file doesn't exist + or is completely unparsable. + + """ + if not lock_path.exists(): + return None + + try: + # Try reading as text first (most common for H2 AUTO_SERVER) + content = lock_path.read_text(encoding="utf-8", errors="replace") + except OSError as e: + log.debug(f"Could not read lock file {lock_path}: {e}") + return None + + pid: int | None = None + port: int | None = None + + # Try to extract port from common H2 lock file formats + # Format 1: "server:localhost:PORT" + server_match = re.search(r"server:[\w.]+:(\d+)", content, re.IGNORECASE) + if server_match: + port = int(server_match.group(1)) + + # Format 2: Look for standalone port numbers (H2 uses ports in 9000+ range typically) + if port is None: + port_match = re.search(r"\b(9\d{3})\b", content) + if port_match: + port = int(port_match.group(1)) + + # Try to extract PID - H2 may embed this in various formats + pid_match = re.search(r"pid[=:]?\s*(\d+)", content, re.IGNORECASE) + if pid_match: + pid = int(pid_match.group(1)) + + # Check if the process is still alive + is_stale = False + if pid is not None: + is_stale = not is_metals_process_alive(pid) + elif port is not None: + # If we have a port but no PID, try to find a Metals process using that port + is_stale = not _is_port_in_use_by_metals(port) + else: + # Can't determine - assume stale if lock exists but we can't parse it + # and no Metals processes are running for this project + log.debug(f"Could not parse PID or port from lock file: {lock_path}") + is_stale = True # Conservative: treat unparsable as stale + + return MetalsLockInfo( + pid=pid, + port=port, + lock_path=lock_path, + is_stale=is_stale, + raw_content=content[:200], # Truncate for logging + ) + + +def is_metals_process_alive(pid: int) -> bool: + """ + Check if a process with the given PID is alive and is a Metals process. + + Args: + pid: Process ID to check + + Returns: + True if the process exists and appears to be a Metals LSP server. + + """ + try: + import psutil + + proc = psutil.Process(pid) + if not proc.is_running(): + return False + + # Check if this is actually a Metals process + cmdline = " ".join(proc.cmdline()).lower() + return _is_metals_cmdline(cmdline) + + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + return False + except Exception as e: + log.debug(f"Error checking process {pid}: {e}") + return False + + +def _is_metals_cmdline(cmdline: str) -> bool: + """Check if a command line string appears to be a Metals LSP server.""" + cmdline_lower = cmdline.lower() + # Metals is a Scala/Java application + if "java" not in cmdline_lower: + return False + # Look for Metals-specific identifiers + return any( + marker in cmdline_lower + for marker in [ + "metals", + "org.scalameta", + "-dmetals.client", + ] + ) + + +def _is_port_in_use_by_metals(port: int) -> bool: + """Check if the given port is in use by a Metals process.""" + try: + import psutil + + for conn in psutil.net_connections(kind="tcp"): + if conn.laddr.port == port and conn.status == "LISTEN": + try: + proc = psutil.Process(conn.pid) + cmdline = " ".join(proc.cmdline()).lower() + if _is_metals_cmdline(cmdline): + return True + except (psutil.NoSuchProcess, psutil.AccessDenied): + pass + return False + except (psutil.AccessDenied, OSError) as e: + # On some systems, net_connections requires elevated privileges + log.debug(f"Could not check port {port}: {e}") + return False + + +def check_metals_db_status(project_path: Path) -> tuple[MetalsDbStatus, MetalsLockInfo | None]: + """ + Check the status of the Metals H2 database for a project. + + This function determines whether it's safe to start a new Metals instance + and whether any cleanup is needed. + + Args: + project_path: Path to the project root directory + + Returns: + A tuple of (status, lock_info) where lock_info is populated for + ACTIVE_INSTANCE and STALE_LOCK statuses. + + """ + metals_dir = project_path / ".metals" + db_path = metals_dir / "metals.mv.db" + lock_path = metals_dir / "metals.mv.db.lock.db" + + if not metals_dir.exists(): + log.debug(f"No .metals directory found at {metals_dir}") + return MetalsDbStatus.NO_DATABASE, None + + if not db_path.exists(): + log.debug(f"No Metals database found at {db_path}") + return MetalsDbStatus.NO_DATABASE, None + + if not lock_path.exists(): + log.debug(f"Metals database exists but no lock file at {lock_path}") + return MetalsDbStatus.NO_LOCK, None + + # Lock file exists - parse it to determine status + lock_info = parse_h2_lock_file(lock_path) + + if lock_info is None: + # Lock file exists but couldn't be read - treat as stale + log.warning(f"Could not read lock file at {lock_path}, treating as stale") + return MetalsDbStatus.STALE_LOCK, MetalsLockInfo( + pid=None, + port=None, + lock_path=lock_path, + is_stale=True, + raw_content="", + ) + + if lock_info.is_stale: + log.debug(f"Stale Metals lock detected: {lock_info}") + return MetalsDbStatus.STALE_LOCK, lock_info + else: + log.debug(f"Active Metals instance detected: {lock_info}") + return MetalsDbStatus.ACTIVE_INSTANCE, lock_info + + +def cleanup_stale_lock(lock_path: Path) -> bool: + """ + Remove a stale H2 database lock file. + + This should only be called when we've verified the owning process is dead. + Removing a lock file from a running process could cause database corruption. + + Args: + lock_path: Path to the .lock.db file to remove + + Returns: + True if cleanup succeeded, False otherwise. + + """ + if not lock_path.exists(): + log.debug(f"Lock file already removed: {lock_path}") + return True + + try: + lock_path.unlink() + log.info(f"Cleaned up stale Metals lock file: {lock_path}") + return True + except PermissionError as e: + log.warning(f"Permission denied removing stale lock file {lock_path}: {e}") + return False + except OSError as e: + log.warning(f"Could not remove stale lock file {lock_path}: {e}") + return False diff --git a/test/solidlsp/scala/test_metals_db_utils.py b/test/solidlsp/scala/test_metals_db_utils.py new file mode 100644 index 00000000..5a31af24 --- /dev/null +++ b/test/solidlsp/scala/test_metals_db_utils.py @@ -0,0 +1,268 @@ +""" +Unit tests for the metals_db_utils module. + +Tests the detection of Metals H2 database status and stale lock handling. +""" + +import os +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from solidlsp.util.metals_db_utils import ( + MetalsDbStatus, + check_metals_db_status, + cleanup_stale_lock, + is_metals_process_alive, + parse_h2_lock_file, +) + + +@pytest.mark.scala +class TestParseH2LockFile: + """Tests for parse_h2_lock_file function.""" + + def test_returns_none_when_file_does_not_exist(self, tmp_path: Path) -> None: + """Should return None when lock file doesn't exist.""" + lock_path = tmp_path / "nonexistent.lock.db" + result = parse_h2_lock_file(lock_path) + assert result is None + + def test_parses_server_format_lock_file(self, tmp_path: Path) -> None: + """Should parse lock file with server:host:port format.""" + lock_path = tmp_path / "metals.mv.db.lock.db" + lock_path.write_text("server:localhost:9092\n") + + result = parse_h2_lock_file(lock_path) + + assert result is not None + assert result.port == 9092 + assert result.lock_path == lock_path + + def test_parses_port_only_format(self, tmp_path: Path) -> None: + """Should extract port from content containing a port number.""" + lock_path = tmp_path / "metals.mv.db.lock.db" + lock_path.write_text("some content 9123 more content\n") + + result = parse_h2_lock_file(lock_path) + + assert result is not None + assert result.port == 9123 + + def test_parses_pid_format(self, tmp_path: Path) -> None: + """Should extract PID from lock file content.""" + lock_path = tmp_path / "metals.mv.db.lock.db" + lock_path.write_text("pid=12345\nserver:localhost:9092\n") + + result = parse_h2_lock_file(lock_path) + + assert result is not None + assert result.pid == 12345 + assert result.port == 9092 + + def test_handles_unreadable_file(self, tmp_path: Path) -> None: + """Should return None for unreadable files.""" + lock_path = tmp_path / "metals.mv.db.lock.db" + lock_path.write_text("content") + # Make file unreadable (Unix only) + if os.name != "nt": + lock_path.chmod(0o000) + try: + result = parse_h2_lock_file(lock_path) + assert result is None + finally: + lock_path.chmod(0o644) + + def test_truncates_raw_content(self, tmp_path: Path) -> None: + """Should truncate raw_content to 200 chars.""" + lock_path = tmp_path / "metals.mv.db.lock.db" + long_content = "x" * 500 + lock_path.write_text(long_content) + + result = parse_h2_lock_file(lock_path) + + assert result is not None + assert len(result.raw_content) == 200 + + +@pytest.mark.scala +class TestIsMetalsProcessAlive: + """Tests for is_metals_process_alive function.""" + + def test_returns_false_for_nonexistent_process(self) -> None: + """Should return False for a PID that doesn't exist.""" + # Use a very high PID that's unlikely to exist + result = is_metals_process_alive(999999999) + assert result is False + + def test_returns_true_for_metals_process(self) -> None: + """Should return True for a running Metals process.""" + import psutil + + with patch.object(psutil, "Process") as mock_process_class: + mock_proc = MagicMock() + mock_proc.is_running.return_value = True + mock_proc.cmdline.return_value = [ + "java", + "-Dmetals.client=vscode", + "-jar", + "metals.jar", + ] + mock_process_class.return_value = mock_proc + + result = is_metals_process_alive(12345) + + assert result is True + + def test_returns_false_for_non_metals_java_process(self) -> None: + """Should return False for a Java process that isn't Metals.""" + import psutil + + with patch.object(psutil, "Process") as mock_process_class: + mock_proc = MagicMock() + mock_proc.is_running.return_value = True + mock_proc.cmdline.return_value = [ + "java", + "-jar", + "some-other-app.jar", + ] + mock_process_class.return_value = mock_proc + + result = is_metals_process_alive(12345) + + assert result is False + + def test_returns_false_for_non_running_process(self) -> None: + """Should return False for a process that's not running.""" + import psutil + + with patch.object(psutil, "Process") as mock_process_class: + mock_proc = MagicMock() + mock_proc.is_running.return_value = False + mock_process_class.return_value = mock_proc + + result = is_metals_process_alive(12345) + + assert result is False + + def test_handles_no_such_process(self) -> None: + """Should return False when process doesn't exist.""" + import psutil + + with patch.object(psutil, "Process") as mock_process_class: + mock_process_class.side_effect = psutil.NoSuchProcess(12345) + + result = is_metals_process_alive(12345) + + assert result is False + + +@pytest.mark.scala +class TestCheckMetalsDbStatus: + """Tests for check_metals_db_status function.""" + + def test_returns_no_database_when_metals_dir_missing(self, tmp_path: Path) -> None: + """Should return NO_DATABASE when .metals directory doesn't exist.""" + status, lock_info = check_metals_db_status(tmp_path) + + assert status == MetalsDbStatus.NO_DATABASE + assert lock_info is None + + def test_returns_no_database_when_db_missing(self, tmp_path: Path) -> None: + """Should return NO_DATABASE when database file doesn't exist.""" + metals_dir = tmp_path / ".metals" + metals_dir.mkdir() + + status, lock_info = check_metals_db_status(tmp_path) + + assert status == MetalsDbStatus.NO_DATABASE + assert lock_info is None + + def test_returns_no_lock_when_lock_file_missing(self, tmp_path: Path) -> None: + """Should return NO_LOCK when database exists but lock doesn't.""" + metals_dir = tmp_path / ".metals" + metals_dir.mkdir() + db_path = metals_dir / "metals.mv.db" + db_path.touch() + + status, lock_info = check_metals_db_status(tmp_path) + + assert status == MetalsDbStatus.NO_LOCK + assert lock_info is None + + def test_returns_active_instance_when_process_alive(self, tmp_path: Path) -> None: + """Should return ACTIVE_INSTANCE when lock holder is running.""" + import solidlsp.util.metals_db_utils as metals_utils + + metals_dir = tmp_path / ".metals" + metals_dir.mkdir() + db_path = metals_dir / "metals.mv.db" + db_path.touch() + lock_path = metals_dir / "metals.mv.db.lock.db" + lock_path.write_text("pid=12345\nserver:localhost:9092\n") + + with patch.object(metals_utils, "is_metals_process_alive", return_value=True): + status, lock_info = check_metals_db_status(tmp_path) + + assert status == MetalsDbStatus.ACTIVE_INSTANCE + assert lock_info is not None + assert lock_info.is_stale is False + + def test_returns_stale_lock_when_process_dead(self, tmp_path: Path) -> None: + """Should return STALE_LOCK when lock holder is not running.""" + import solidlsp.util.metals_db_utils as metals_utils + + metals_dir = tmp_path / ".metals" + metals_dir.mkdir() + db_path = metals_dir / "metals.mv.db" + db_path.touch() + lock_path = metals_dir / "metals.mv.db.lock.db" + lock_path.write_text("pid=12345\nserver:localhost:9092\n") + + with patch.object(metals_utils, "is_metals_process_alive", return_value=False): + status, lock_info = check_metals_db_status(tmp_path) + + assert status == MetalsDbStatus.STALE_LOCK + assert lock_info is not None + assert lock_info.is_stale is True + + +@pytest.mark.scala +class TestCleanupStaleLock: + """Tests for cleanup_stale_lock function.""" + + def test_removes_lock_file(self, tmp_path: Path) -> None: + """Should successfully remove a lock file.""" + lock_path = tmp_path / "metals.mv.db.lock.db" + lock_path.touch() + + result = cleanup_stale_lock(lock_path) + + assert result is True + assert not lock_path.exists() + + def test_returns_true_when_file_already_removed(self, tmp_path: Path) -> None: + """Should return True when file doesn't exist.""" + lock_path = tmp_path / "nonexistent.lock.db" + + result = cleanup_stale_lock(lock_path) + + assert result is True + + def test_returns_false_on_permission_error(self, tmp_path: Path) -> None: + """Should return False when file can't be removed due to permissions.""" + if os.name == "nt": + pytest.skip("Permission test not reliable on Windows") + + lock_path = tmp_path / "metals.mv.db.lock.db" + lock_path.touch() + # Make parent directory read-only + tmp_path.chmod(0o555) + + try: + result = cleanup_stale_lock(lock_path) + assert result is False + assert lock_path.exists() + finally: + tmp_path.chmod(0o755) diff --git a/test/solidlsp/scala/test_scala_stale_lock_handling.py b/test/solidlsp/scala/test_scala_stale_lock_handling.py new file mode 100644 index 00000000..d2819c02 --- /dev/null +++ b/test/solidlsp/scala/test_scala_stale_lock_handling.py @@ -0,0 +1,261 @@ +""" +Tests for ScalaLanguageServer stale lock detection and handling modes. + +These tests verify the ScalaLanguageServer's behavior when detecting stale Metals locks. +They use mocking to avoid requiring an actual Scala project or Metals server. +""" + +import logging +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from _pytest.logging import LogCaptureFixture + +from solidlsp.language_servers.scala_language_server import ScalaLanguageServer +from solidlsp.ls_config import Language +from solidlsp.settings import SolidLSPSettings +from solidlsp.util.metals_db_utils import MetalsDbStatus, MetalsLockInfo + +pytestmark = pytest.mark.scala + + +class TestStaleLockHandling: + """Tests for ScalaLanguageServer stale lock detection and handling modes.""" + + @pytest.fixture + def sample_lock_info(self, tmp_path: Path) -> MetalsLockInfo: + """Create a sample MetalsLockInfo for testing.""" + lock_path = tmp_path / ".metals" / "metals.mv.db.lock.db" + return MetalsLockInfo( + pid=12345, + port=9092, + lock_path=lock_path, + is_stale=True, + raw_content="SERVER:localhost:9092:12345", + ) + + @pytest.fixture + def mock_setup_dependencies(self) -> Any: + """Mock _setup_runtime_dependencies to avoid needing Java/Coursier.""" + return patch.object( + ScalaLanguageServer, + "_setup_runtime_dependencies", + return_value=["/fake/metals"], + ) + + def test_auto_clean_mode_cleans_stale_lock( + self, + tmp_path: Path, + sample_lock_info: MetalsLockInfo, + mock_setup_dependencies: Any, + caplog: LogCaptureFixture, + ) -> None: + """Test AUTO_CLEAN mode removes stale lock and proceeds.""" + cleanup_mock = MagicMock(return_value=True) + + with ( + patch( + "solidlsp.util.metals_db_utils.check_metals_db_status", + return_value=(MetalsDbStatus.STALE_LOCK, sample_lock_info), + ), + patch( + "solidlsp.util.metals_db_utils.cleanup_stale_lock", + cleanup_mock, + ), + mock_setup_dependencies, + patch.object(ScalaLanguageServer, "__init__", lambda self, *args, **kwargs: None), + ): + # Create instance without calling __init__ + ls = object.__new__(ScalaLanguageServer) + settings = SolidLSPSettings(ls_specific_settings={Language.SCALA: {"on_stale_lock": "auto-clean"}}) + + # Call the method under test + ls._check_metals_db_status(str(tmp_path), settings) + + # Verify cleanup was called + cleanup_mock.assert_called_once_with(sample_lock_info.lock_path) + + def test_warn_mode_logs_warning_without_cleanup( + self, + tmp_path: Path, + sample_lock_info: MetalsLockInfo, + mock_setup_dependencies: Any, + caplog: LogCaptureFixture, + ) -> None: + """Test WARN mode logs warning but does not clean up.""" + cleanup_mock = MagicMock(return_value=True) + + with ( + patch( + "solidlsp.util.metals_db_utils.check_metals_db_status", + return_value=(MetalsDbStatus.STALE_LOCK, sample_lock_info), + ), + patch( + "solidlsp.util.metals_db_utils.cleanup_stale_lock", + cleanup_mock, + ), + mock_setup_dependencies, + caplog.at_level(logging.WARNING), + ): + ls = object.__new__(ScalaLanguageServer) + settings = SolidLSPSettings(ls_specific_settings={Language.SCALA: {"on_stale_lock": "warn"}}) + + ls._check_metals_db_status(str(tmp_path), settings) + + # Verify cleanup was NOT called + cleanup_mock.assert_not_called() + + # Verify warning was logged + assert any("Stale Metals lock detected" in record.message for record in caplog.records) + + def test_fail_mode_raises_exception( + self, + tmp_path: Path, + sample_lock_info: MetalsLockInfo, + mock_setup_dependencies: Any, + ) -> None: + """Test FAIL mode raises MetalsStaleLockError.""" + from solidlsp.ls_exceptions import MetalsStaleLockError + + with ( + patch( + "solidlsp.util.metals_db_utils.check_metals_db_status", + return_value=(MetalsDbStatus.STALE_LOCK, sample_lock_info), + ), + mock_setup_dependencies, + pytest.raises(MetalsStaleLockError) as exc_info, + ): + ls = object.__new__(ScalaLanguageServer) + settings = SolidLSPSettings(ls_specific_settings={Language.SCALA: {"on_stale_lock": "fail"}}) + + ls._check_metals_db_status(str(tmp_path), settings) + + assert str(sample_lock_info.lock_path) in str(exc_info.value) + + def test_active_instance_logs_info_when_enabled( + self, + tmp_path: Path, + mock_setup_dependencies: Any, + caplog: LogCaptureFixture, + ) -> None: + """Test ACTIVE_INSTANCE logs info message when log_multi_instance_notice is true.""" + active_lock_info = MetalsLockInfo( + pid=99999, + port=9092, + lock_path=tmp_path / ".metals" / "metals.mv.db.lock.db", + is_stale=False, + raw_content="SERVER:localhost:9092:99999", + ) + + with ( + patch( + "solidlsp.util.metals_db_utils.check_metals_db_status", + return_value=(MetalsDbStatus.ACTIVE_INSTANCE, active_lock_info), + ), + mock_setup_dependencies, + caplog.at_level(logging.INFO), + ): + ls = object.__new__(ScalaLanguageServer) + settings = SolidLSPSettings( + ls_specific_settings={ + Language.SCALA: { + "on_stale_lock": "auto-clean", + "log_multi_instance_notice": True, + } + } + ) + + ls._check_metals_db_status(str(tmp_path), settings) + + # Verify info about multi-instance was logged + assert any("Another Metals instance detected" in record.message for record in caplog.records) + + def test_active_instance_silent_when_notice_disabled( + self, + tmp_path: Path, + mock_setup_dependencies: Any, + caplog: LogCaptureFixture, + ) -> None: + """Test ACTIVE_INSTANCE does not log when log_multi_instance_notice is false.""" + active_lock_info = MetalsLockInfo( + pid=99999, + port=9092, + lock_path=tmp_path / ".metals" / "metals.mv.db.lock.db", + is_stale=False, + raw_content="SERVER:localhost:9092:99999", + ) + + with ( + patch( + "solidlsp.util.metals_db_utils.check_metals_db_status", + return_value=(MetalsDbStatus.ACTIVE_INSTANCE, active_lock_info), + ), + mock_setup_dependencies, + caplog.at_level(logging.INFO), + ): + ls = object.__new__(ScalaLanguageServer) + settings = SolidLSPSettings( + ls_specific_settings={ + Language.SCALA: { + "on_stale_lock": "auto-clean", + "log_multi_instance_notice": False, + } + } + ) + + ls._check_metals_db_status(str(tmp_path), settings) + + # Verify no multi-instance message was logged + assert not any("Another Metals instance detected" in record.message for record in caplog.records) + + def test_no_database_proceeds_silently( + self, + tmp_path: Path, + mock_setup_dependencies: Any, + caplog: LogCaptureFixture, + ) -> None: + """Test NO_DATABASE status proceeds without any special handling.""" + with ( + patch( + "solidlsp.util.metals_db_utils.check_metals_db_status", + return_value=(MetalsDbStatus.NO_DATABASE, None), + ), + mock_setup_dependencies, + caplog.at_level(logging.DEBUG), + ): + ls = object.__new__(ScalaLanguageServer) + settings = SolidLSPSettings(ls_specific_settings={Language.SCALA: {"on_stale_lock": "auto-clean"}}) + + # Should complete without error + ls._check_metals_db_status(str(tmp_path), settings) + + # No stale lock or multi-instance messages + assert not any("Stale" in record.message for record in caplog.records) + assert not any("Another Metals instance" in record.message for record in caplog.records) + + def test_no_lock_proceeds_silently( + self, + tmp_path: Path, + mock_setup_dependencies: Any, + caplog: LogCaptureFixture, + ) -> None: + """Test NO_LOCK status proceeds without any special handling.""" + with ( + patch( + "solidlsp.util.metals_db_utils.check_metals_db_status", + return_value=(MetalsDbStatus.NO_LOCK, None), + ), + mock_setup_dependencies, + caplog.at_level(logging.DEBUG), + ): + ls = object.__new__(ScalaLanguageServer) + settings = SolidLSPSettings(ls_specific_settings={Language.SCALA: {"on_stale_lock": "auto-clean"}}) + + # Should complete without error + ls._check_metals_db_status(str(tmp_path), settings) + + # No stale lock or multi-instance messages + assert not any("Stale" in record.message for record in caplog.records) + assert not any("Another Metals instance" in record.message for record in caplog.records) From c8d35d8d5fb6ac61cb58e79a666fd70b0df75923 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 18 Feb 2026 11:37:24 +0100 Subject: [PATCH 104/105] Improve exception message (ask for language to be reported) --- src/serena/jetbrains/jetbrains_plugin_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/serena/jetbrains/jetbrains_plugin_client.py b/src/serena/jetbrains/jetbrains_plugin_client.py index 1ae525fa..c63b62f9 100644 --- a/src/serena/jetbrains/jetbrains_plugin_client.py +++ b/src/serena/jetbrains/jetbrains_plugin_client.py @@ -49,7 +49,7 @@ class PluginServerError(SerenaClientError): unrecoverable_error_prefix = ( "The request failed due to an error on the Serena JetBrains plugin side. This may be caused " "by a bug or an incompatibility in the plugin. In that case, please report the error below together with " - "your setup (IDE + version, Serena plugin version) at https://github.com/oraios/serena/issues " + "your setup (IDE + version, Serena plugin version, programming language) at https://github.com/oraios/serena/issues " "(if you are an LLM, tell the user about this). The Oraios team will then provide support on this.\n" ) super().__init__(unrecoverable_error_prefix + message) From 3791c1b050e5b2b853569b535b72447a706a9b07 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 18 Feb 2026 11:49:05 +0100 Subject: [PATCH 105/105] CSharpLanguageServer: Make clear that pwsh is required --- docs/01-about/020_programming-languages.md | 2 +- src/solidlsp/language_servers/csharp_language_server.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/01-about/020_programming-languages.md b/docs/01-about/020_programming-languages.md index 87040308..97c7fc40 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -30,7 +30,7 @@ Some languages require additional installations or setup steps, as noted. * **AL** * **Bash** * **C#** - (by default, uses the Roslyn language server (language `csharp`); + (by default, uses the Roslyn language server (language `csharp`), requires `pwsh` ([PowerShell 7+](https://learn.microsoft.com/en-us/powershell/scripting/install/install-powershell-on-windows?view=powershell-7.5)) on Windows; set language to `csharp_omnisharp` to use OmiSharp instead) * **C/C++** (by default, uses the clangd language server (language `cpp`) but we also support ccls (language `cpp_ccls`); diff --git a/src/solidlsp/language_servers/csharp_language_server.py b/src/solidlsp/language_servers/csharp_language_server.py index ee33aa23..261bb283 100644 --- a/src/solidlsp/language_servers/csharp_language_server.py +++ b/src/solidlsp/language_servers/csharp_language_server.py @@ -536,6 +536,7 @@ class CSharpLanguageServer(SolidLanguageServer): ] # Run the install script + log.info("Running .NET install script: %s", cmd) result = subprocess.run(cmd, capture_output=True, text=True, check=True) log.debug(f"Install script output: {result.stdout}") @@ -550,7 +551,12 @@ class CSharpLanguageServer(SolidLanguageServer): 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 install .NET {version} runtime: {e}") from e + message = f"Failed to install .NET {version} runtime: {e}" + if is_windows and isinstance(e, FileNotFoundError): + message += ( + "; pwsh, i.e. PowerShell 7+, is required to install .NET runtime. Make sure pwsh is available on your system." + ) + raise SolidLSPException(message) from e def _get_initialize_params(self) -> InitializeParams: """