From 5d417beb80130424a76d0e8079862d24c5efa556 Mon Sep 17 00:00:00 2001 From: "Hyun-Gyu (Ethan) Kim" Date: Mon, 23 Feb 2026 05:08:32 +0900 Subject: [PATCH] Add SystemVerilog language server support using slang-langserver (#990) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add SystemVerilog language server support using slang-langserver - Add Language.SYSTEMVERILOG enum with .sv, .svh, .v, .vh file support - Create SystemVerilogLanguageServer class with auto-download for Linux, macOS, Windows - Add pytest marker and basic test cases for symbol retrieval Co-Authored-By: Claude Opus 4.5 * Fix ruff lint errors by updating configuration - Add UP042, PLW0108, PLC0207 to ruff ignore list (style preferences) - Update test repo pyproject.toml to use lint.select and lint.isort sections Co-Authored-By: Claude Opus 4.5 * Replace slang-langserver with verible-verilog-ls for SystemVerilog slang v7.0 release URLs return 404 as the project stopped providing pre-built binaries after v3.0. Migrate to Verible (CHIPS Alliance) which actively maintains binary releases for all major platforms. Changes: - Update RuntimeDependency URLs to Verible v0.0-4051-g9fdb4057 - Add Linux ARM64 platform support - Update fallback and error messages Co-Authored-By: Claude Opus 4.5 * Add SystemVerilog language server support using slang-langserver - Add Language.SYSTEMVERILOG enum with .sv, .svh, .v, .vh file support - Create SystemVerilogLanguageServer class with auto-download for Linux, macOS, Windows - Add pytest marker and basic test cases for symbol retrieval Co-Authored-By: Claude Opus 4.5 * Replace slang-langserver with verible-verilog-ls for SystemVerilog slang v7.0 release URLs return 404 as the project stopped providing pre-built binaries after v3.0. Migrate to Verible (CHIPS Alliance) which actively maintains binary releases for all major platforms. Changes: - Update RuntimeDependency URLs to Verible v0.0-4051-g9fdb4057 - Add Linux ARM64 platform support - Update fallback and error messages Co-Authored-By: Claude Opus 4.5 * Fix Verible binary paths for macOS and Windows The archive directory names differ per platform: - Linux: verible-v0.0-4051-g9fdb4057/bin/ - macOS: verible-v0.0-4051-g9fdb4057-macOS/bin/ - Windows: verible-v0.0-4051-g9fdb4057-win64/ (no bin/ subdirectory) Co-Authored-By: Claude Opus 4.5 * Update docs and comments: slang-langserver → verible-verilog-ls - Update pyproject.toml marker description - Update ls_config.py docstring - Update test file docstrings - Remove unused _slang_available() function Co-Authored-By: Claude Opus 4.5 * Add verible-verilog-ls to CI and improve SystemVerilog LS - Add verible-verilog-ls installation step to pytest.yml for Linux/macOS/Windows (GitHub Releases, follows ZLS pattern) - Remove unused threading import and server_ready Event - Add capability validation (assert textDocumentSync, warn on missing documentSymbolProvider/definitionProvider) - Add window/logMessage notification handler - Add codeAction, formatting, documentHighlight capabilities - Improve error message with conda/Homebrew/GitHub install methods Co-Authored-By: Claude Opus 4.6 * Add verible detection tests and remove redundant ALU tests Add comprehensive unit tests for verible-verilog-ls binary detection logic (PATH lookup, version logging, graceful degradation, download fallback). Remove duplicate ALU symbol tests that tested the same LSP operations already covered by counter tests. Co-Authored-By: Claude Opus 4.6 * Fix SystemVerilog LSP convention deviations from code review - Add client/registerCapability handler (all other 31 LSPs have it) - Add locale: en to initialize params (project convention) - Change log.debug to log.info for window/logMessage handler - Fix dead code in test_find_references_cross_file skip/assert logic - Move repeated imports to module top level in detection tests - Remove unused IS_WINDOWS/IS_UNIX variables and sys import - Fix hardcoded line number comment to semantic description Co-Authored-By: Claude Opus 4.6 * Fix Windows CI verible path: flat zip structure has no bin/ subdir The Windows verible zip extracts to verible-VERSION-win64/ with executables directly in the root (no bin/ subdirectory), unlike Linux/macOS tar.gz which have bin/ subdirectory. Co-Authored-By: Claude Opus 4.6 * Fix macOS CI verible path: tar.gz extracts with -macOS suffix Each platform has different directory naming in verible releases: - Linux: verible-VERSION/bin/ (no suffix) - macOS: verible-VERSION-macOS/bin/ (has -macOS suffix) - Windows: verible-VERSION-win64/ (flat, no bin/ subdir) Co-Authored-By: Claude Opus 4.6 * Refactor: read Verible version from settings instead of hardcoding Extract hardcoded version string into _custom_settings.get("verible_version") with the current version as default. This allows version override via project.yml without changing source code. Also introduce DEFAULT_VERIBLE_VERSION constant in detection tests for consistency. Co-Authored-By: Claude Opus 4.6 * Add rename tests and strengthen existing SystemVerilog tests - Add TestSystemVerilogRename: within-file, cross-file, module rename - Strengthen all existing tests with exact position/value verification - Remove all pytest.skip, replace with assert (review feedback) - Remove isinstance check in _find_symbol_by_name (review feedback) - Extract _extract_hover_text and _extract_changes helpers - Remove test_diagnostics (requires shared ls.py fix, separate PR) Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.5 --- .github/workflows/pytest.yml | 30 ++ pyproject.toml | 4 + .../language_servers/systemverilog_server.py | 206 +++++++++++ src/solidlsp/ls_config.py | 11 + .../repos/systemverilog/test_repo/alu.sv | 39 ++ .../repos/systemverilog/test_repo/counter.sv | 19 + .../repos/systemverilog/test_repo/top.sv | 33 ++ .../repos/systemverilog/test_repo/types.svh | 16 + .../repos/toml/test_repo/pyproject.toml | 4 +- test/solidlsp/systemverilog/__init__.py | 0 .../systemverilog/test_systemverilog_basic.py | 264 ++++++++++++++ .../test_systemverilog_detection.py | 332 ++++++++++++++++++ 12 files changed, 957 insertions(+), 1 deletion(-) create mode 100644 src/solidlsp/language_servers/systemverilog_server.py create mode 100644 test/resources/repos/systemverilog/test_repo/alu.sv create mode 100644 test/resources/repos/systemverilog/test_repo/counter.sv create mode 100644 test/resources/repos/systemverilog/test_repo/top.sv create mode 100644 test/resources/repos/systemverilog/test_repo/types.svh create mode 100644 test/solidlsp/systemverilog/__init__.py create mode 100644 test/solidlsp/systemverilog/test_systemverilog_basic.py create mode 100644 test/solidlsp/systemverilog/test_systemverilog_detection.py diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 52268702..058ecbea 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -271,6 +271,36 @@ jobs: echo "$HOME/bin" >> $GITHUB_PATH rm zls.zip fi + - name: Install verible-verilog-ls (SystemVerilog Language Server) + shell: bash + run: | + VERIBLE_VERSION="v0.0-4051-g9fdb4057" + + if [[ "${{ runner.os }}" == "Linux" ]]; then + wget https://github.com/chipsalliance/verible/releases/download/${VERIBLE_VERSION}/verible-${VERIBLE_VERSION}-linux-static-x86_64.tar.gz + tar -xzf verible-${VERIBLE_VERSION}-linux-static-x86_64.tar.gz + sudo mv verible-${VERIBLE_VERSION}/bin/verible-verilog-ls /usr/local/bin/ + rm -rf verible-${VERIBLE_VERSION} verible-${VERIBLE_VERSION}-linux-static-x86_64.tar.gz + elif [[ "${{ runner.os }}" == "macOS" ]]; then + wget https://github.com/chipsalliance/verible/releases/download/${VERIBLE_VERSION}/verible-${VERIBLE_VERSION}-macOS.tar.gz + tar -xzf verible-${VERIBLE_VERSION}-macOS.tar.gz + sudo mv verible-${VERIBLE_VERSION}-macOS/bin/verible-verilog-ls /usr/local/bin/ + rm -rf verible-${VERIBLE_VERSION}-macOS verible-${VERIBLE_VERSION}-macOS.tar.gz + elif [[ "${{ runner.os }}" == "Windows" ]]; then + curl -L -o verible.zip https://github.com/chipsalliance/verible/releases/download/${VERIBLE_VERSION}/verible-${VERIBLE_VERSION}-win64.zip + unzip -o verible.zip + mkdir -p "$HOME/bin" + mv verible-${VERIBLE_VERSION}-win64/verible-verilog-ls.exe "$HOME/bin/" + echo "$HOME/bin" >> $GITHUB_PATH + rm -rf verible-${VERIBLE_VERSION}-win64 verible.zip + fi + + # Verify installation + if command -v verible-verilog-ls &> /dev/null; then + echo "verible-verilog-ls installed successfully" + else + echo "WARNING: verible-verilog-ls not found in PATH" + fi - name: Install Lua Language Server shell: bash run: | diff --git a/pyproject.toml b/pyproject.toml index 044a2872..587bbf2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -268,6 +268,9 @@ ignore = [ "UP037", # can incorrectly (!) convert quoted type to unquoted type, causing an error "UP045", # imposes T | None instead of Optional[T] "UP031", # forbids % operator to format strings + "UP042", # wants str,Enum -> StrEnum (breaking change) + "PLW0108", # unnecessary lambda (style preference) + "PLC0207", # split vs rsplit optimization (style preference) ] unfixable = ["F841", "F601", "F602", "B018"] extend-fixable = ["F401", "B905", "W291"] @@ -322,6 +325,7 @@ markers = [ "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+)", + "systemverilog: language server running for SystemVerilog (uses verible-verilog-ls)", ] [tool.codespell] diff --git a/src/solidlsp/language_servers/systemverilog_server.py b/src/solidlsp/language_servers/systemverilog_server.py new file mode 100644 index 00000000..d2848189 --- /dev/null +++ b/src/solidlsp/language_servers/systemverilog_server.py @@ -0,0 +1,206 @@ +""" +SystemVerilog language server using verible-verilog-ls. +""" + +import logging +import os +import pathlib +import shutil +import subprocess +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 + +from .common import RuntimeDependency, RuntimeDependencyCollection + +log = logging.getLogger(__name__) + + +class SystemVerilogLanguageServer(SolidLanguageServer): + """ + SystemVerilog language server using verible-verilog-ls. + Supports .sv, .svh, .v, .vh files. + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings) -> None: + super().__init__(config, repository_root_path, None, "systemverilog", solidlsp_settings) + + 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: + # 1. Check PATH first for system-installed verible + system_verible = shutil.which("verible-verilog-ls") + if system_verible: + # Log version information + try: + result = subprocess.run( + [system_verible, "--version"], + capture_output=True, + text=True, + check=False, + timeout=5, + ) + if result.returncode == 0: + version_info = result.stdout.strip().split("\n")[0] + log.info(f"Using system-installed verible-verilog-ls: {version_info}") + else: + log.info(f"Using system-installed verible-verilog-ls at {system_verible}") + except Exception: + log.info(f"Using system-installed verible-verilog-ls at {system_verible}") + return system_verible + + # 2. Not found in PATH, try to download + verible_version = self._custom_settings.get("verible_version", "v0.0-4051-g9fdb4057") + base_url = f"https://github.com/chipsalliance/verible/releases/download/{verible_version}" + + deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="verible-ls", + description="verible-verilog-ls for Linux (x64)", + url=f"{base_url}/verible-{verible_version}-linux-static-x86_64.tar.gz", + platform_id="linux-x64", + archive_type="gztar", + binary_name=f"verible-{verible_version}/bin/verible-verilog-ls", + ), + RuntimeDependency( + id="verible-ls", + description="verible-verilog-ls for Linux (arm64)", + url=f"{base_url}/verible-{verible_version}-linux-static-arm64.tar.gz", + platform_id="linux-arm64", + archive_type="gztar", + binary_name=f"verible-{verible_version}/bin/verible-verilog-ls", + ), + RuntimeDependency( + id="verible-ls", + description="verible-verilog-ls for macOS", + url=f"{base_url}/verible-{verible_version}-macOS.tar.gz", + platform_id="osx-x64", + archive_type="gztar", + binary_name=f"verible-{verible_version}/bin/verible-verilog-ls", + ), + RuntimeDependency( + id="verible-ls", + description="verible-verilog-ls for macOS", + url=f"{base_url}/verible-{verible_version}-macOS.tar.gz", + platform_id="osx-arm64", + archive_type="gztar", + binary_name=f"verible-{verible_version}/bin/verible-verilog-ls", + ), + RuntimeDependency( + id="verible-ls", + description="verible-verilog-ls for Windows (x64)", + url=f"{base_url}/verible-{verible_version}-win64.zip", + platform_id="win-x64", + archive_type="zip", + binary_name=f"verible-{verible_version}/bin/verible-verilog-ls.exe", + ), + ] + ) + + try: + dep = deps.get_single_dep_for_current_platform() + except RuntimeError: + dep = None + + if dep is None: + raise FileNotFoundError( + "verible-verilog-ls is not installed on your system.\n" + + "Please install verible using one of the following methods:\n" + + " conda: conda install -c conda-forge verible\n" + + " Homebrew: brew install verible\n" + + " GitHub: Download from https://github.com/chipsalliance/verible/releases\n" + + "See https://github.com/chipsalliance/verible for more details." + ) + + verible_ls_dir = os.path.join(self._ls_resources_dir, "verible-ls") + executable_path = deps.binary_path(verible_ls_dir) + + if not os.path.exists(executable_path): + log.info(f"verible-verilog-ls not found. Downloading from {dep.url}") + _ = deps.install(verible_ls_dir) + + if not os.path.exists(executable_path): + raise FileNotFoundError(f"verible-verilog-ls not found at {executable_path}") + + os.chmod(executable_path, 0o755) + return executable_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: + root_uri = pathlib.Path(repository_absolute_path).as_uri() + initialize_params = { + "processId": os.getpid(), + "rootPath": repository_absolute_path, + "rootUri": root_uri, + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "completion": { + "dynamicRegistration": True, + "completionItem": {"snippetSupport": True}, + }, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "hover": { + "dynamicRegistration": True, + "contentFormat": ["markdown", "plaintext"], + }, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "codeAction": {"dynamicRegistration": True}, + "formatting": {"dynamicRegistration": True}, + "documentHighlight": {"dynamicRegistration": True}, + "publishDiagnostics": {"relatedInformation": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + }, + }, + "workspaceFolders": [{"uri": root_uri, "name": os.path.basename(repository_absolute_path)}], + } + return cast(InitializeParams, initialize_params) + + def _start_server(self) -> None: + def do_nothing(params: Any) -> None: + return + + def on_log_message(params: Any) -> None: + message = params.get("message", "") if isinstance(params, dict) else str(params) + log.info(f"verible-verilog-ls: {message}") + + self.server.on_request("client/registerCapability", do_nothing) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("window/logMessage", on_log_message) + + log.info("Starting verible-verilog-ls process") + self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + log.info("Sending initialize request") + init_response = self.server.send.initialize(initialize_params) + + # Validate server capabilities (follows Gopls/Bash pattern) + capabilities = init_response.get("capabilities", {}) + log.info(f"Initialize response capabilities: {list(capabilities.keys())}") + assert "textDocumentSync" in capabilities, "verible-verilog-ls must support textDocumentSync" + if "documentSymbolProvider" not in capabilities: + log.warning("verible-verilog-ls does not advertise documentSymbolProvider") + if "definitionProvider" not in capabilities: + log.warning("verible-verilog-ls does not advertise definitionProvider") + + self.server.notify.initialized({}) diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 812e16c4..935ce98d 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -105,6 +105,11 @@ class Language(str, Enum): """TOML language server using Taplo. Supports TOML validation, formatting, and schema support. """ + SYSTEMVERILOG = "systemverilog" + """SystemVerilog language server using verible-verilog-ls. + Supports .sv, .svh, .v, .vh files. + Automatically downloads verible binary. + """ @classmethod def iter_all(cls, include_experimental: bool = False) -> Iterable[Self]: @@ -246,6 +251,8 @@ class Language(str, Enum): return FilenameMatcher("*.groovy", "*.gvy") case self.MATLAB: return FilenameMatcher("*.m", "*.mlx", "*.mlapp") + case self.SYSTEMVERILOG: + return FilenameMatcher("*.sv", "*.svh", "*.v", "*.vh") case _: raise ValueError(f"Unhandled language: {self}") @@ -427,6 +434,10 @@ class Language(str, Enum): from solidlsp.language_servers.matlab_language_server import MatlabLanguageServer return MatlabLanguageServer + case self.SYSTEMVERILOG: + from solidlsp.language_servers.systemverilog_server import SystemVerilogLanguageServer + + return SystemVerilogLanguageServer case _: raise ValueError(f"Unhandled language: {self}") diff --git a/test/resources/repos/systemverilog/test_repo/alu.sv b/test/resources/repos/systemverilog/test_repo/alu.sv new file mode 100644 index 00000000..1e416b3a --- /dev/null +++ b/test/resources/repos/systemverilog/test_repo/alu.sv @@ -0,0 +1,39 @@ +// ALU module for testing SystemVerilog LSP +module alu #( + parameter DATA_WIDTH = 32 +) ( + input logic [DATA_WIDTH-1:0] a, + input logic [DATA_WIDTH-1:0] b, + input logic [2:0] op, + output logic [DATA_WIDTH-1:0] result, + output logic zero +); + + typedef enum logic [2:0] { + ALU_ADD = 3'b000, + ALU_SUB = 3'b001, + ALU_AND = 3'b010, + ALU_OR = 3'b011, + ALU_XOR = 3'b100, + ALU_SLL = 3'b101, + ALU_SRL = 3'b110, + ALU_SRA = 3'b111 + } alu_op_t; + + always_comb begin + case (op) + ALU_ADD: result = a + b; + ALU_SUB: result = a - b; + ALU_AND: result = a & b; + ALU_OR: result = a | b; + ALU_XOR: result = a ^ b; + ALU_SLL: result = a << b[4:0]; + ALU_SRL: result = a >> b[4:0]; + ALU_SRA: result = $signed(a) >>> b[4:0]; + default: result = '0; + endcase + end + + assign zero = (result == '0); + +endmodule diff --git a/test/resources/repos/systemverilog/test_repo/counter.sv b/test/resources/repos/systemverilog/test_repo/counter.sv new file mode 100644 index 00000000..2af8d1c2 --- /dev/null +++ b/test/resources/repos/systemverilog/test_repo/counter.sv @@ -0,0 +1,19 @@ +// Simple counter module for testing SystemVerilog LSP +module counter #( + parameter WIDTH = 8 +) ( + input logic clk, + input logic rst_n, + input logic enable, + output logic [WIDTH-1:0] count +); + + // Counter logic + always_ff @(posedge clk or negedge rst_n) begin + if (!rst_n) + count <= '0; + else if (enable) + count <= count + 1'b1; + end + +endmodule diff --git a/test/resources/repos/systemverilog/test_repo/top.sv b/test/resources/repos/systemverilog/test_repo/top.sv new file mode 100644 index 00000000..5684ca0c --- /dev/null +++ b/test/resources/repos/systemverilog/test_repo/top.sv @@ -0,0 +1,33 @@ +// Top module that instantiates counter and alu for cross-file testing +`include "types.svh" + +module top ( + input logic clk, + input logic rst_n, + input logic enable, + input word_t a, + input word_t b, + input logic [2:0] op, + output byte_t count, + output word_t alu_result, + output logic alu_zero +); + + // Instantiate counter module + counter #(.WIDTH(8)) u_counter ( + .clk(clk), + .rst_n(rst_n), + .enable(enable), + .count(count) + ); + + // Instantiate ALU module + alu #(.DATA_WIDTH(32)) u_alu ( + .a(a), + .b(b), + .op(op), + .result(alu_result), + .zero(alu_zero) + ); + +endmodule diff --git a/test/resources/repos/systemverilog/test_repo/types.svh b/test/resources/repos/systemverilog/test_repo/types.svh new file mode 100644 index 00000000..29482a70 --- /dev/null +++ b/test/resources/repos/systemverilog/test_repo/types.svh @@ -0,0 +1,16 @@ +// Common types header for testing SystemVerilog LSP +`ifndef TYPES_SVH +`define TYPES_SVH + +typedef logic [7:0] byte_t; +typedef logic [15:0] halfword_t; +typedef logic [31:0] word_t; +typedef logic [63:0] doubleword_t; + +typedef struct packed { + logic valid; + logic [31:0] data; + logic [3:0] tag; +} tagged_data_t; + +`endif // TYPES_SVH diff --git a/test/resources/repos/toml/test_repo/pyproject.toml b/test/resources/repos/toml/test_repo/pyproject.toml index 05028558..37887c26 100644 --- a/test/resources/repos/toml/test_repo/pyproject.toml +++ b/test/resources/repos/toml/test_repo/pyproject.toml @@ -27,9 +27,11 @@ build-backend = "hatchling.build" [tool.ruff] line-length = 88 target-version = "py311" + +[tool.ruff.lint] select = ["E", "F", "I", "N", "W", "B", "S"] -[tool.ruff.isort] +[tool.ruff.lint.isort] known-first-party = ["test_project"] [tool.mypy] diff --git a/test/solidlsp/systemverilog/__init__.py b/test/solidlsp/systemverilog/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/solidlsp/systemverilog/test_systemverilog_basic.py b/test/solidlsp/systemverilog/test_systemverilog_basic.py new file mode 100644 index 00000000..edd72d2f --- /dev/null +++ b/test/solidlsp/systemverilog/test_systemverilog_basic.py @@ -0,0 +1,264 @@ +""" +Basic tests for SystemVerilog language server integration (verible-verilog-ls). + +This module tests Language.SYSTEMVERILOG using verible-verilog-ls. +Tests are skipped if the language server is not available. +""" + +from typing import Any + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_utils import SymbolUtils + + +def _find_symbol_by_name(language_server: SolidLanguageServer, file_path: str, name: str) -> dict[str, Any] | None: + """Find a top-level symbol by name in a file's document symbols.""" + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + return next((s for s in symbols[0] if s.get("name") == name), None) + + +def _get_symbol_selection_start(language_server: SolidLanguageServer, file_path: str, name: str) -> tuple[int, int]: + """Get the (line, character) of a symbol's selectionRange start.""" + symbol = _find_symbol_by_name(language_server, file_path, name) + assert symbol is not None, f"Could not find symbol '{name}' in {file_path}" + assert "selectionRange" in symbol, f"Symbol '{name}' has no selectionRange in {file_path}" + sel_start = symbol["selectionRange"]["start"] + return sel_start["line"], sel_start["character"] + + +@pytest.mark.systemverilog +class TestSystemVerilogSymbols: + """Tests for document symbol extraction.""" + + @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True) + def test_find_symbol(self, language_server: SolidLanguageServer) -> None: + """Test that symbol tree contains expected modules.""" + symbols = language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "counter"), "Module 'counter' not found in symbol tree" + + @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True) + def test_get_document_symbols(self, language_server: SolidLanguageServer) -> None: + """Test document symbols for counter.sv.""" + symbol = _find_symbol_by_name(language_server, "counter.sv", "counter") + assert symbol is not None, "Expected 'counter' in document symbols" + + @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True) + def test_find_top_module(self, language_server: SolidLanguageServer) -> None: + """Test that top module is found (cross-file instantiation test).""" + symbols = language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "top"), "Module 'top' not found in symbol tree" + + +@pytest.mark.systemverilog +class TestSystemVerilogDefinition: + """Tests for go-to-definition functionality.""" + + @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True) + def test_goto_definition(self, language_server: SolidLanguageServer) -> None: + """Test go to definition from signal usage to its declaration. + + Navigating from 'count' usage in always_ff (line 13) should jump + to the output port declaration (line 7, char 29). + """ + # counter.sv line 13 (0-indexed): " count <= '0;" + # 'count' at char 12 + definitions = language_server.request_definition("counter.sv", 13, 12) + assert len(definitions) >= 1, f"Expected at least 1 definition, got {len(definitions)}" + def_in_counter = [d for d in definitions if "counter.sv" in (d.get("relativePath") or "")] + assert len(def_in_counter) >= 1, f"Expected definition in counter.sv, got: {[d.get('relativePath') for d in definitions]}" + assert ( + def_in_counter[0]["range"]["start"]["line"] == 7 + ), f"Expected definition at line 7 (output port count), got line {def_in_counter[0]['range']['start']['line']}" + + @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True) + def test_goto_definition_cross_file(self, language_server: SolidLanguageServer) -> None: + """Test go to definition from module instantiation in top.sv to counter.sv. + + This is the key cross-file test: navigating from an instantiation + (counter in top.sv) to its definition (counter.sv). + """ + # top.sv line 17 (0-indexed: 16): " counter #(.WIDTH(8)) u_counter (" + # "counter" starts at column 4 + definitions = language_server.request_definition("top.sv", 16, 4) + assert len(definitions) >= 1, f"Expected at least 1 definition, got {len(definitions)}" + def_paths = [d.get("relativePath", "") for d in definitions] + assert any("counter.sv" in p for p in def_paths), f"Expected definition in counter.sv, got: {def_paths}" + counter_defs = [d for d in definitions if "counter.sv" in (d.get("relativePath") or "")] + assert ( + counter_defs[0]["range"]["start"]["line"] == 1 + ), f"Expected definition at line 1 (module counter), got line {counter_defs[0]['range']['start']['line']}" + + +@pytest.mark.systemverilog +class TestSystemVerilogReferences: + """Tests for find-references functionality.""" + + @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True) + def test_find_references(self, language_server: SolidLanguageServer) -> None: + """Test finding within-file references to a port signal. + + The 'count' output port is declared on line 7 and used in the + always_ff block on lines 13 and 15 (twice), giving 3 within-file + references — all inside counter.sv. + """ + # counter.sv line 8 (0-indexed: 7): " output logic [WIDTH-1:0] count" + # 'count' starts at char 29 + references = language_server.request_references("counter.sv", 7, 29) + assert len(references) >= 1, f"Expected at least 1 reference, got {len(references)}" + ref_paths = [r.get("relativePath", "") for r in references] + refs_in_counter = [r for r in references if "counter.sv" in (r.get("relativePath") or "")] + assert len(refs_in_counter) >= 1, f"Expected within-file references in counter.sv, got paths: {ref_paths}" + ref_lines = sorted(r["range"]["start"]["line"] for r in refs_in_counter) + # Line 13: count <= '0; Line 15: count <= count + 1'b1; (two refs) + assert 13 in ref_lines, f"Expected reference at line 13 (count <= '0), got lines: {ref_lines}" + assert 15 in ref_lines, f"Expected reference at line 15 (count <= count + 1'b1), got lines: {ref_lines}" + + @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True) + def test_find_references_cross_file(self, language_server: SolidLanguageServer) -> None: + """Test that references to counter include its instantiation in top.sv. + + Similar to Rust (lib.rs → main.rs) and C# (Program.cs → Models/Person.cs), + this verifies that cross-file references are found. + """ + line, char = _get_symbol_selection_start(language_server, "counter.sv", "counter") + references = language_server.request_references("counter.sv", line, char) + ref_paths = [ref.get("relativePath", "") for ref in references] + assert any("top.sv" in p for p in ref_paths), f"Expected reference from top.sv, got: {ref_paths}" + refs_in_top = [r for r in references if "top.sv" in (r.get("relativePath") or "")] + # top.sv line 17 (0-indexed: 16): " counter #(.WIDTH(8)) u_counter (" + assert ( + refs_in_top[0]["range"]["start"]["line"] == 16 + ), f"Expected cross-file reference at line 16 (counter instantiation), got line {refs_in_top[0]['range']['start']['line']}" + + +def _extract_hover_text(hover_info: dict[str, Any]) -> str: + """Extract the text content from an LSP hover response.""" + contents = hover_info["contents"] + if isinstance(contents, dict): + return contents.get("value", "") + elif isinstance(contents, str): + return contents + return str(contents) + + +@pytest.mark.systemverilog +class TestSystemVerilogHover: + """Tests for hover information.""" + + @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True) + def test_hover(self, language_server: SolidLanguageServer) -> None: + """Test hover information (experimental in verible, requires --lsp_enable_hover).""" + line, char = _get_symbol_selection_start(language_server, "counter.sv", "counter") + hover_info = language_server.request_hover("counter.sv", line, char) + assert hover_info is not None, "Hover should return information for counter module" + assert "contents" in hover_info, "Hover should have contents" + hover_text = _extract_hover_text(hover_info) + assert len(hover_text) > 0, "Hover text should not be empty" + assert "counter" in hover_text.lower(), f"Hover should mention 'counter', got: {hover_text}" + assert "module" in hover_text.lower(), f"Hover should identify 'counter' as a module, got: {hover_text}" + + @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True) + def test_hover_includes_type_information(self, language_server: SolidLanguageServer) -> None: + """Test that hover includes type information for a port signal. + + Hovering on 'count' output port should return its name and type + (logic [WIDTH-1:0]), distinct from module-level hover. + """ + # counter.sv line 8 (0-indexed: 7): " output logic [WIDTH-1:0] count" + # 'count' starts at char 29 + hover_info = language_server.request_hover("counter.sv", 7, 29) + assert hover_info is not None, "Hover should return information for 'count' port" + assert "contents" in hover_info, "Hover should have contents" + hover_text = _extract_hover_text(hover_info) + assert "count" in hover_text.lower(), f"Hover should mention 'count', got: {hover_text}" + assert "logic" in hover_text.lower(), f"Hover should include type 'logic', got: {hover_text}" + + +def _extract_changes(workspace_edit: dict[str, Any]) -> dict[str, list[dict[str, Any]]]: + """Extract file URI → edits mapping from a WorkspaceEdit, handling both formats.""" + changes = workspace_edit.get("changes", {}) + if not changes: + doc_changes = workspace_edit.get("documentChanges", []) + assert len(doc_changes) > 0, "WorkspaceEdit should have 'changes' or 'documentChanges'" + changes = {dc["textDocument"]["uri"]: dc["edits"] for dc in doc_changes if "textDocument" in dc and "edits" in dc} + return changes + + +@pytest.mark.systemverilog +class TestSystemVerilogRename: + """Tests for rename functionality.""" + + @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True) + def test_rename_signal_within_file(self, language_server: SolidLanguageServer) -> None: + """Test renaming a port signal from its declaration updates within-file occurrences. + + The 'count' output port (line 7, char 29) is used in the always_ff + block on lines 13 and 15. Renaming from the declaration site produces + edits for all occurrences within counter.sv. + """ + workspace_edit = language_server.request_rename_symbol_edit("counter.sv", 7, 29, "cnt") + assert workspace_edit is not None, "Rename should be supported for port signal 'count'" + + changes = _extract_changes(workspace_edit) + counter_edits = [edits for uri, edits in changes.items() if "counter.sv" in uri] + assert len(counter_edits) >= 1, f"Should have edits for counter.sv, got: {list(changes.keys())}" + + edits = counter_edits[0] + assert len(edits) >= 2, f"Expected at least 2 edits (declaration + usage), got {len(edits)}" + edit_lines = sorted(e["range"]["start"]["line"] for e in edits) + assert 7 in edit_lines, f"Expected edit at line 7 (port declaration), got lines: {edit_lines}" + assert 13 in edit_lines, f"Expected edit at line 13 (count <= '0), got lines: {edit_lines}" + assert 15 in edit_lines, f"Expected edit at line 15 (count <= count + 1'b1), got lines: {edit_lines}" + for edit in edits: + assert edit["newText"] == "cnt", f"Expected newText 'cnt', got {edit['newText']}" + + @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True) + def test_rename_signal_cross_file(self, language_server: SolidLanguageServer) -> None: + """Test renaming a port signal from a usage site includes cross-file edits. + + Renaming 'count' from usage in always_ff (line 13, char 12) should + produce edits in counter.sv (declaration + usages) and also in top.sv + where the port is connected (.count(count) at line 20). + """ + workspace_edit = language_server.request_rename_symbol_edit("counter.sv", 13, 12, "cnt") + assert workspace_edit is not None, "Rename should be supported for signal 'count' from usage site" + + changes = _extract_changes(workspace_edit) + counter_uris = [uri for uri in changes if "counter.sv" in uri] + top_uris = [uri for uri in changes if "top.sv" in uri] + assert len(counter_uris) >= 1, f"Expected edits in counter.sv, got: {list(changes.keys())}" + assert len(top_uris) >= 1, f"Expected cross-file edits in top.sv, got: {list(changes.keys())}" + + for uri, edits in changes.items(): + for edit in edits: + assert edit["newText"] == "cnt", f"Expected 'cnt' in {uri}, got {edit['newText']}" + + @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True) + def test_rename_module_name(self, language_server: SolidLanguageServer) -> None: + """Test renaming a module name at its declaration. + + The 'counter' module declaration (line 1, char 7) is renamed to + 'my_counter'. Verible renames the identifier at the definition site. + """ + line, char = _get_symbol_selection_start(language_server, "counter.sv", "counter") + workspace_edit = language_server.request_rename_symbol_edit("counter.sv", line, char, "my_counter") + assert workspace_edit is not None, "Rename should be supported for module 'counter'" + + changes = _extract_changes(workspace_edit) + assert len(changes) > 0, "WorkspaceEdit should have changes" + counter_edits = [edits for uri, edits in changes.items() if "counter.sv" in uri] + assert len(counter_edits) >= 1, f"Should have edits for counter.sv, got: {list(changes.keys())}" + + edits = counter_edits[0] + edit_lines = sorted(e["range"]["start"]["line"] for e in edits) + assert 1 in edit_lines, f"Expected edit at line 1 (module declaration), got lines: {edit_lines}" + decl_edits = [e for e in edits if e["range"]["start"]["line"] == 1] + assert ( + decl_edits[0]["range"]["start"]["character"] == 7 + ), f"Expected edit at char 7, got char {decl_edits[0]['range']['start']['character']}" + for uri, file_edits in changes.items(): + for edit in file_edits: + assert edit["newText"] == "my_counter", f"Expected 'my_counter', got {edit['newText']}" diff --git a/test/solidlsp/systemverilog/test_systemverilog_detection.py b/test/solidlsp/systemverilog/test_systemverilog_detection.py new file mode 100644 index 00000000..34a96a3b --- /dev/null +++ b/test/solidlsp/systemverilog/test_systemverilog_detection.py @@ -0,0 +1,332 @@ +""" +Tests for verible-verilog-ls detection logic. + +These tests describe the expected behavior of SystemVerilogLanguageServer.DependencyProvider._get_or_install_core_dependency(): + +1. System PATH should be checked FIRST (prefers user-installed verible) +2. Runtime download should be fallback when not in PATH +3. Version information should be logged when available +4. Version check failures should be handled gracefully +5. Helpful error messages when verible is not available on unsupported platforms + +WHY these tests matter: +- Users install verible via conda, Homebrew, system packages, or GitHub releases +- Detection failing means Serena is unusable for SystemVerilog, even when verible is correctly installed +- Without these tests, the detection logic can silently break for users with system installations +- Version logging helps debug compatibility issues +""" + +import os +import shutil +import subprocess +import tempfile +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from solidlsp.language_servers.systemverilog_server import SystemVerilogLanguageServer +from solidlsp.settings import SolidLSPSettings + +DEFAULT_VERIBLE_VERSION = "v0.0-4051-g9fdb4057" + + +class TestVeribleVerilogLsDetection: + """Unit tests for verible-verilog-ls binary detection logic.""" + + @pytest.mark.systemverilog + def test_detect_from_path_returns_system_verible(self): + """ + GIVEN verible-verilog-ls is in system PATH + WHEN _get_or_install_core_dependency is called + THEN it returns the system path without downloading + + WHY: Users with system-installed verible (via conda, Homebrew, apt) + should use that version instead of downloading. This is faster and + respects user's environment management. + """ + with tempfile.TemporaryDirectory() as temp_dir: + custom_settings = SolidLSPSettings.CustomLSSettings({}) + provider = SystemVerilogLanguageServer.DependencyProvider(custom_settings, temp_dir) + + with patch("shutil.which") as mock_which: + mock_which.return_value = "/usr/local/bin/verible-verilog-ls" + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock( + returncode=0, + stdout="Verible v0.0-4051-g9fdb4057 (2024-01-01)\nCommit: 9fdb4057", + stderr="", + ) + result = provider._get_or_install_core_dependency() + + assert result == "/usr/local/bin/verible-verilog-ls" + mock_which.assert_called_once_with("verible-verilog-ls") + mock_run.assert_called_once() + assert mock_run.call_args[0][0] == ["/usr/local/bin/verible-verilog-ls", "--version"] + + @pytest.mark.systemverilog + def test_detect_from_path_logs_version(self): + """ + GIVEN verible-verilog-ls is in PATH with version output + WHEN detected + THEN version info is logged + + WHY: Version information helps debug compatibility issues. + Users and developers need to know which verible version is being used. + """ + with tempfile.TemporaryDirectory() as temp_dir: + custom_settings = SolidLSPSettings.CustomLSSettings({}) + provider = SystemVerilogLanguageServer.DependencyProvider(custom_settings, temp_dir) + + with patch("shutil.which", return_value="/usr/bin/verible-verilog-ls"): + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="Verible v0.0-4051-g9fdb4057", stderr="") + with patch("solidlsp.language_servers.systemverilog_server.log") as mock_log: + result = provider._get_or_install_core_dependency() + + # Verify version check was called + assert mock_run.call_args[0][0] == ["/usr/bin/verible-verilog-ls", "--version"] + # Verify version was logged + assert mock_log.info.called + log_message = mock_log.info.call_args[0][0] + assert "Verible v0.0-4051" in log_message + assert result == "/usr/bin/verible-verilog-ls" + + @pytest.mark.systemverilog + def test_detect_from_path_handles_version_failure_gracefully(self): + """ + GIVEN verible-verilog-ls is in PATH but --version fails (returncode=1) + WHEN detected + THEN it still returns the system path (graceful degradation) + + WHY: Some verible builds might not support --version or have different flags. + Detection should not fail just because version check fails - the binary + might still work fine for LSP operations. + """ + with tempfile.TemporaryDirectory() as temp_dir: + custom_settings = SolidLSPSettings.CustomLSSettings({}) + provider = SystemVerilogLanguageServer.DependencyProvider(custom_settings, temp_dir) + + with patch("shutil.which", return_value="/custom/bin/verible-verilog-ls"): + with patch("subprocess.run") as mock_run: + # Version check fails + mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="Unknown option: --version") + result = provider._get_or_install_core_dependency() + + # Should still return the path despite version check failure + assert result == "/custom/bin/verible-verilog-ls" + + @pytest.mark.systemverilog + def test_detect_from_path_handles_version_timeout_gracefully(self): + """ + GIVEN verible-verilog-ls is in PATH but --version times out + WHEN detected + THEN it still returns the system path (graceful degradation) + + WHY: Version check has a timeout to avoid hanging. If it times out, + we should still use the detected binary. + """ + with tempfile.TemporaryDirectory() as temp_dir: + custom_settings = SolidLSPSettings.CustomLSSettings({}) + provider = SystemVerilogLanguageServer.DependencyProvider(custom_settings, temp_dir) + + with patch("shutil.which", return_value="/opt/verible/bin/verible-verilog-ls"): + with patch("subprocess.run") as mock_run: + # Version check times out + mock_run.side_effect = subprocess.TimeoutExpired(cmd=["verible-verilog-ls", "--version"], timeout=5) + result = provider._get_or_install_core_dependency() + + # Should still return the path despite timeout + assert result == "/opt/verible/bin/verible-verilog-ls" + + @pytest.mark.systemverilog + def test_error_message_when_not_found_anywhere(self): + """ + GIVEN verible is NOT in PATH AND platform is unsupported + WHEN _get_or_install_core_dependency is called + THEN raises FileNotFoundError with helpful installation instructions + + WHY: Users need clear guidance on how to install verible when it's missing. + Error message should mention conda, Homebrew, and GitHub releases. + """ + with tempfile.TemporaryDirectory() as temp_dir: + custom_settings = SolidLSPSettings.CustomLSSettings({}) + provider = SystemVerilogLanguageServer.DependencyProvider(custom_settings, temp_dir) + + with patch("shutil.which", return_value=None): + # Mock RuntimeDependencyCollection to raise RuntimeError for unsupported platform + with patch("solidlsp.language_servers.systemverilog_server.RuntimeDependencyCollection") as mock_deps_class: + mock_deps = Mock() + mock_deps.get_single_dep_for_current_platform.side_effect = RuntimeError("Unsupported platform") + mock_deps_class.return_value = mock_deps + + with pytest.raises(FileNotFoundError) as exc_info: + provider._get_or_install_core_dependency() + + error_message = str(exc_info.value) + # Error should mention installation methods + assert "conda" in error_message.lower() + assert "Homebrew" in error_message or "brew" in error_message.lower() + assert "GitHub" in error_message or "github.com" in error_message.lower() + assert "verible" in error_message.lower() + + @pytest.mark.systemverilog + def test_downloads_when_not_in_path(self): + """ + GIVEN verible is NOT in PATH AND platform IS supported AND binary exists after download + WHEN _get_or_install_core_dependency is called + THEN returns the downloaded executable path + + WHY: When verible is not installed system-wide and platform is supported, + Serena should auto-download it. This enables zero-setup experience. + """ + with tempfile.TemporaryDirectory() as temp_dir: + custom_settings = SolidLSPSettings.CustomLSSettings({}) + provider = SystemVerilogLanguageServer.DependencyProvider(custom_settings, temp_dir) + + expected_path = os.path.join(temp_dir, "verible-ls", f"verible-{DEFAULT_VERIBLE_VERSION}", "bin", "verible-verilog-ls") + + with patch("shutil.which", return_value=None): + with patch("solidlsp.language_servers.systemverilog_server.RuntimeDependencyCollection") as mock_deps_class: + # Create mock dependency and collection + mock_dep = Mock() + mock_dep.url = "https://github.com/chipsalliance/verible/releases/download/v0.0-4051/verible.tar.gz" + + mock_deps = Mock() + mock_deps.get_single_dep_for_current_platform.return_value = mock_dep + mock_deps.binary_path.return_value = expected_path + mock_deps.install.return_value = expected_path + + mock_deps_class.return_value = mock_deps + + with patch("os.path.exists") as mock_exists: + # Before download: binary doesn't exist yet → after download: binary exists + mock_exists.side_effect = [False, True] + + with patch("os.chmod"): + result = provider._get_or_install_core_dependency() + + assert result == expected_path + mock_deps.install.assert_called_once() + + @pytest.mark.systemverilog + def test_detection_prefers_path_over_download(self): + """ + GIVEN verible is in PATH AND download would also work + WHEN _get_or_install_core_dependency is called + THEN PATH version is used (download never attempted) + + WHY: System-installed verible should always take precedence. + This respects user's environment and avoids unnecessary downloads. + """ + with tempfile.TemporaryDirectory() as temp_dir: + custom_settings = SolidLSPSettings.CustomLSSettings({}) + provider = SystemVerilogLanguageServer.DependencyProvider(custom_settings, temp_dir) + + with patch("shutil.which", return_value="/usr/bin/verible-verilog-ls"): + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="Verible v0.0-4051", stderr="") + + with patch("solidlsp.language_servers.systemverilog_server.RuntimeDependencyCollection") as mock_deps_class: + result = provider._get_or_install_core_dependency() + + # RuntimeDependencyCollection should never be instantiated + mock_deps_class.assert_not_called() + + assert result == "/usr/bin/verible-verilog-ls" + + @pytest.mark.systemverilog + def test_download_fails_if_binary_not_found_after_install(self): + """ + GIVEN verible is NOT in PATH AND platform IS supported + WHEN download completes BUT binary still doesn't exist at expected path + THEN raises FileNotFoundError + + WHY: If download/extraction fails silently, we should catch it and report clearly. + """ + with tempfile.TemporaryDirectory() as temp_dir: + custom_settings = SolidLSPSettings.CustomLSSettings({}) + provider = SystemVerilogLanguageServer.DependencyProvider(custom_settings, temp_dir) + + expected_path = os.path.join(temp_dir, "verible-ls", f"verible-{DEFAULT_VERIBLE_VERSION}", "bin", "verible-verilog-ls") + + with patch("shutil.which", return_value=None): + with patch("solidlsp.language_servers.systemverilog_server.RuntimeDependencyCollection") as mock_deps_class: + mock_dep = Mock() + mock_deps = Mock() + mock_deps.get_single_dep_for_current_platform.return_value = mock_dep + mock_deps.binary_path.return_value = expected_path + mock_deps.install.return_value = expected_path + mock_deps_class.return_value = mock_deps + + # Binary never appears after install + with patch("os.path.exists", return_value=False): + with pytest.raises(FileNotFoundError) as exc_info: + provider._get_or_install_core_dependency() + + error_message = str(exc_info.value) + assert "verible-verilog-ls not found" in error_message + assert expected_path in error_message + + @pytest.mark.systemverilog + def test_uses_already_downloaded_binary_without_reinstalling(self): + """ + GIVEN verible is NOT in PATH AND platform IS supported + AND binary already exists at download location + WHEN _get_or_install_core_dependency is called + THEN returns existing path without downloading again + + WHY: Avoid redundant downloads if verible was already downloaded in previous session. + This speeds up subsequent runs. + """ + with tempfile.TemporaryDirectory() as temp_dir: + custom_settings = SolidLSPSettings.CustomLSSettings({}) + provider = SystemVerilogLanguageServer.DependencyProvider(custom_settings, temp_dir) + + expected_path = os.path.join(temp_dir, "verible-ls", f"verible-{DEFAULT_VERIBLE_VERSION}", "bin", "verible-verilog-ls") + + with patch("shutil.which", return_value=None): + with patch("solidlsp.language_servers.systemverilog_server.RuntimeDependencyCollection") as mock_deps_class: + mock_dep = Mock() + mock_deps = Mock() + mock_deps.get_single_dep_for_current_platform.return_value = mock_dep + mock_deps.binary_path.return_value = expected_path + mock_deps_class.return_value = mock_deps + + # Binary already exists + with patch("os.path.exists", return_value=True): + with patch("os.chmod"): + result = provider._get_or_install_core_dependency() + + # Should NOT call install since binary already exists + mock_deps.install.assert_not_called() + assert result == expected_path + + +class TestVeribleVerilogLsDetectionIntegration: + """ + Integration tests that verify detection works on the current system. + These tests are skipped if verible-verilog-ls is not installed. + """ + + @pytest.mark.systemverilog + def test_integration_finds_installed_verible(self): + """ + GIVEN verible-verilog-ls is installed on this system (via any method) + WHEN _get_or_install_core_dependency is called + THEN it returns a valid executable path + + This test verifies the detection logic works end-to-end on the current system. + """ + # Skip if verible-verilog-ls is not installed + if not shutil.which("verible-verilog-ls"): + pytest.skip("verible-verilog-ls not installed on this system") + + with tempfile.TemporaryDirectory() as temp_dir: + custom_settings = SolidLSPSettings.CustomLSSettings({}) + provider = SystemVerilogLanguageServer.DependencyProvider(custom_settings, temp_dir) + + result = provider._get_or_install_core_dependency() + + assert result is not None + assert os.path.isfile(result) + assert os.access(result, os.X_OK)