diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index e292d5d7..42dce26b 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -58,16 +58,19 @@ 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: | if [ ! -d ".venv" ]; then uv venv fi - - name: Install dependencies + - 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 - name: Check formatting shell: bash run: uv run poe lint @@ -110,6 +113,24 @@ 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 + elif [[ "${{ runner.os }}" == "Windows" ]]; then + choco install ccls -y + fi + # Verify installation + if command -v ccls &> /dev/null; then + echo "ccls installed: $(ccls --version 2>&1 | head -1)" + else + echo "ERROR: ccls installation failed" + exit 1 + fi - name: Setup Java (for JVM based languages) uses: actions/setup-java@v4 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index b2cdfcca..49475679 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,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/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 diff --git a/docs/01-about/020_programming-languages.md b/docs/01-about/020_programming-languages.md index c17fb0a6..bd8b3643 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -31,7 +31,9 @@ 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. + See the [C/C++ Setup Guide](../03-special-guides/cpp_setup) for details. * **Clojure** * **Dart** * **Elixir** 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/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index f06df078..51204018 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -173,6 +173,11 @@ You can change this location by setting the `SERENA_HOME` environment variable t (ls-specific-settings)= ### Language Server-Specific Settings +:::{note} +**Advanced Users Only**: The settings described in this section are intended for advanced users who need to fine-tune language server behavior. +Most users will not need to adjust these settings. +::: + Under the key `ls_specific_settings` in `serena_config.yml`, you can you pass per-language, language server-specific configuration. @@ -204,9 +209,53 @@ 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. +#### 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. 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/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/serena/cli.py b/src/serena/cli.py index ecb5dcca..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) @@ -762,8 +763,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 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": """ 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 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: """ 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 new file mode 100644 index 00000000..f8a0db45 --- /dev/null +++ b/src/solidlsp/language_servers/ccls_language_server.py @@ -0,0 +1,154 @@ +""" +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". + +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:** +- Chocolatey: ``choco install ccls`` + +For alternative installation methods and build-from-source instructions, 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 CCLS(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: choco install ccls\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.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..f0929f79 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,8 +301,9 @@ class ClangdLanguageServer(SolidLanguageServer): } self.server.notify.initialized({}) - - self.completions_available.set() - # 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() 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..a5f4f040 100644 --- a/src/solidlsp/language_servers/csharp_language_server.py +++ b/src/solidlsp/language_servers/csharp_language_server.py @@ -1,26 +1,24 @@ """ -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 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 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 Hover, UnifiedSymbolInformation from solidlsp.ls_utils import PathUtils from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams, InitializeResult from solidlsp.settings import SolidLSPSettings @@ -33,111 +31,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", - ), - 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", + extract_path="tools/net10.0/linux-arm64", ), ] @@ -188,22 +144,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): @@ -212,8 +172,9 @@ class CSharpLanguageServer(SolidLanguageServer): Use LanguageServer.create() instead. """ super().__init__(config, repository_root_path, None, "csharp", solidlsp_settings) - - self.initialization_complete = threading.Event() + # 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) @@ -222,6 +183,116 @@ 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 + for symbol in symbols.iter_symbols(): + self._normalize_symbol_name(symbol, relative_file_path) + + 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. + 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, @@ -263,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( @@ -276,30 +359,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.""" @@ -311,26 +392,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 @@ -356,43 +435,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}" @@ -405,82 +469,88 @@ 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: + 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: """ @@ -538,6 +608,8 @@ class CSharpLanguageServer(SolidLanguageServer): ) def _start_server(self) -> None: + indexing_complete = threading.Event() + def do_nothing(params: dict) -> None: return @@ -645,7 +717,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 +771,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/phpactor.py b/src/solidlsp/language_servers/phpactor.py index 570fc895..2d10ca1c 100644 --- a/src/solidlsp/language_servers/phpactor.py +++ b/src/solidlsp/language_servers/phpactor.py @@ -159,4 +159,3 @@ class PhpactorServer(SolidLanguageServer): 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/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 f345380a..259acb85 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"] @@ -715,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..01ec205e 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,11 +301,10 @@ 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: - return 1 + return 2 @override def _get_preferred_definition(self, definitions: list[ls_types.Location]) -> ls_types.Location: 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..64a237a6 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) -> None: + """ + 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) -> None: + 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: @@ -409,7 +455,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: @@ -611,11 +656,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 +673,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: @@ -862,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: @@ -1005,7 +1054,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} @@ -1130,7 +1178,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 +1193,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: diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 37a9d79b..812e16c4 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" @@ -129,6 +130,7 @@ class Language(str, Enum): self.YAML, self.TOML, self.GROOVY, + self.CPP_CCLS, } def __str__(self) -> str: @@ -175,7 +177,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") @@ -309,6 +311,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 CCLS + + return CCLS case self.PHP: from solidlsp.language_servers.intelephense import Intelephense diff --git a/src/solidlsp/ls_utils.py b/src/solidlsp/ls_utils.py index 0325101e..ae0363fd 100644 --- a/src/solidlsp/ls_utils.py +++ b/src/solidlsp/ls_utils.py @@ -414,6 +414,9 @@ 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. + """ for symbol in roots: if symbol["name"] == name: return True diff --git a/test/conftest.py b/test/conftest.py index 74dff913..6d2cf211 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,5 +1,6 @@ import logging import os +import shutil as _sh from collections.abc import Iterator from contextlib import contextmanager from pathlib import Path @@ -35,6 +36,7 @@ class LanguageParamRequest: _LANGUAGE_REPO_ALIASES: dict[Language, Language] = { + Language.CPP_CCLS: Language.CPP, Language.PHP_PHPACTOR: Language.PHP, Language.PYTHON_JEDI: Language.PYTHON, Language.RUBY_SOLARGRAPH: Language.RUBY, @@ -60,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, @@ -220,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..21c02f62 --- /dev/null +++ b/test/resources/repos/cpp/test_repo/compile_commands.json @@ -0,0 +1,12 @@ +[ + { + "directory": ".", + "command": "g++ -std=c++17 -I . -c a.cpp", + "file": "a.cpp" + }, + { + "directory": ".", + "command": "g++ -std=c++17 -I . -c b.cpp", + "file": "b.cpp" + } +] \ No newline at end of file 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) 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..715ef6c9 --- /dev/null +++ b/test/solidlsp/cpp/test_cpp_basic.py @@ -0,0 +1,144 @@ +""" +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 pathlib +import shutil + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_utils import SymbolUtils + + +def _ccls_available() -> bool: + return shutil.which("ccls") is not None + + +_cpp_servers: list[Language] = [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"]) + 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: + 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"]) + 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) diff --git a/test/solidlsp/csharp/test_csharp_basic.py b/test/solidlsp/csharp/test_csharp_basic.py index 38f52058..1638f73d 100644 --- a/test/solidlsp/csharp/test_csharp_basic.py +++ b/test/solidlsp/csharp/test_csharp_basic.py @@ -56,6 +56,7 @@ class TestCSharpLanguageServer: # Handle nested symbol structure symbol_list = symbols[0] if symbols and isinstance(symbols[0], list) else symbols for sym in symbol_list: + # Symbol names are normalized to base form (e.g., "Add" not "Add(int, int) : int") if sym.get("name") == "Add": add_symbol = sym break @@ -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 (names are normalized to base form) 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 "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: @@ -102,6 +103,7 @@ class TestCSharpLanguageServer: subtract_symbol = None for sym in symbol_list: + # Symbol names are normalized to base form (e.g., "Subtract" not "Subtract(int, int) : int") if sym.get("name") == "Subtract": subtract_symbol = sym break @@ -112,21 +114,66 @@ 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) 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 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 (line 6, column 23 - on "Name") + # Source: public string Name { get; set; } + hover_info = language_server.request_hover(file_path, 6, 23) + + # 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"] + assert isinstance(contents, dict), "Hover contents should be a dict" + assert "value" in contents, "Hover contents should have value" + hover_text = contents["value"] + + # 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 (line 22, column 21 - on "IsAdult") + # Source: public bool IsAdult() + hover_method = language_server.request_hover(file_path, 22, 21) + + # 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"] + 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 class TestCSharpSolutionProjectOpening: 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..f59db6e5 --- /dev/null +++ b/test/solidlsp/csharp/test_csharp_dotnet_install.py @@ -0,0 +1,290 @@ +"""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.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_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" + + 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.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_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" + + 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" + + @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}" 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" 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)