From dbd7bf7596a89adf9732f19f2fcf35ba0264f90a Mon Sep 17 00:00:00 2001 From: Devon <80612032+TheSmallKiwi@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:28:50 -0500 Subject: [PATCH] Add HLSL and related languages support through shader-language-server (#1093) Co-Authored-By: Claude Opus 4.6 --- README.md | 2 +- docs/01-about/020_programming-languages.md | 6 +- pyproject.toml | 3 +- .../language_servers/hlsl_language_server.py | 219 ++++++++++++++++++ src/solidlsp/ls_config.py | 27 +++ .../repos/hlsl/test_repo/common.hlsl | 29 +++ .../repos/hlsl/test_repo/compute_test.hlsl | 26 +++ .../repos/hlsl/test_repo/lighting.hlsl | 37 +++ .../hlsl/test_repo/terrain/terrain_sdf.hlsl | 33 +++ test/solidlsp/hlsl/__init__.py | 1 + test/solidlsp/hlsl/test_hlsl_basic.py | 160 +++++++++++++ test/solidlsp/hlsl/test_hlsl_full_index.py | 63 +++++ 12 files changed, 603 insertions(+), 3 deletions(-) create mode 100644 src/solidlsp/language_servers/hlsl_language_server.py create mode 100644 test/resources/repos/hlsl/test_repo/common.hlsl create mode 100644 test/resources/repos/hlsl/test_repo/compute_test.hlsl create mode 100644 test/resources/repos/hlsl/test_repo/lighting.hlsl create mode 100644 test/resources/repos/hlsl/test_repo/terrain/terrain_sdf.hlsl create mode 100644 test/solidlsp/hlsl/__init__.py create mode 100644 test/solidlsp/hlsl/test_hlsl_basic.py create mode 100644 test/solidlsp/hlsl/test_hlsl_full_index.py diff --git a/README.md b/README.md index b03b07c1..a54dfde3 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ that implement the language server protocol (LSP). The underlying language servers are typically open-source projects (like Serena) or at least freely available for use. With Serena's LSP library, we provide **support for over 30 programming languages**, including -AL, Bash, C#, C/C++, Clojure, Dart, Elixir, Elm, Erlang, Fortran, Go, Groovy (partial support), Haskell, Java, Javascript, Julia, Kotlin, Lua, Markdown, MATLAB, Nix, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Swift, TOML, TypeScript, YAML, and Zig. +AL, Bash, C#, C/C++, Clojure, Dart, Elixir, Elm, Erlang, Fortran, GLSL, Go, Groovy (partial support), Haskell, HLSL, Java, Javascript, Julia, Kotlin, Lua, Markdown, MATLAB, Nix, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Swift, TOML, TypeScript, WGSL, YAML, and Zig. > [!IMPORTANT] > Some language servers require additional dependencies to be installed; see the [Language Support](https://oraios.github.io/serena/01-about/020_programming-languages.html) page for details. diff --git a/docs/01-about/020_programming-languages.md b/docs/01-about/020_programming-languages.md index b6b2ac0c..5de2815f 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -52,8 +52,12 @@ Some languages require additional installations or setup steps, as noted. (requires installation of `gopls`) * **Groovy** (requires local groovy-language-server.jar setup via `GROOVY_LS_JAR_PATH` or configuration) -* **Haskell** +* **Haskell** (automatically locates HLS via ghcup, stack, or system PATH; supports Stack and Cabal projects) +* **HLSL / GLSL / WGSL** + (uses [shader-language-server](https://github.com/antaalt/shader-sense) (language `hlsl`); automatically downloaded; + on macOS, requires Rust toolchain for building from source; + note: reference search is not supported by this language server) * **Java** * **JavaScript** * **Julia** diff --git a/pyproject.toml b/pyproject.toml index 587bbf2c..0a6000fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -326,11 +326,12 @@ markers = [ "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)", + "hlsl: language server running for HLSL shaders (uses shader-language-server)", ] [tool.codespell] # Ref: https://github.com/codespell-project/codespell#using-a-config-file skip = '.git*,*.svg,*.lock,*.min.*' check-hidden = true -# ignore-regex = '' +ignore-regex = '\.\w+' ignore-words-list = 'paket' diff --git a/src/solidlsp/language_servers/hlsl_language_server.py b/src/solidlsp/language_servers/hlsl_language_server.py new file mode 100644 index 00000000..f578c3f3 --- /dev/null +++ b/src/solidlsp/language_servers/hlsl_language_server.py @@ -0,0 +1,219 @@ +""" +Shader language server using shader-language-server (antaalt/shader-sense). +Supports HLSL, GLSL, and WGSL shader file formats. +""" + +import logging +import os +import pathlib +import shutil +from typing import Any, cast + +from overrides import override + +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__) + +# GitHub release version to download when not installed locally +_DEFAULT_VERSION = "1.3.0" +_GITHUB_RELEASE_BASE = "https://github.com/antaalt/shader-sense/releases/download" + + +class HlslLanguageServer(SolidLanguageServer): + """ + Shader language server using shader-language-server. + Supports .hlsl, .hlsli, .fx, .fxh, .cginc, .compute, .shader, .glsl, .vert, .frag, .geom, .tesc, .tese, .comp, .wgsl files. + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings) -> None: + super().__init__(config, repository_root_path, None, "hlsl", solidlsp_settings) + + @override + 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 for system-installed binary + system_binary = shutil.which("shader-language-server") + if system_binary: + log.info(f"Using system-installed shader-language-server at {system_binary}") + return system_binary + + # 2. Try to download pre-built binary from GitHub releases + version = self._custom_settings.get("version", _DEFAULT_VERSION) + tag = f"v{version}" + base_url = f"{_GITHUB_RELEASE_BASE}/{tag}" + + # macOS has no pre-built binaries; build from source via cargo install + cargo_install_cmd = f"cargo install shader_language_server --version {version} --root ." + + deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="shader-language-server", + description="shader-language-server for Windows (x64)", + url=f"{base_url}/shader-language-server-x86_64-pc-windows-msvc.zip", + platform_id="win-x64", + archive_type="zip", + binary_name="shader-language-server.exe", + ), + RuntimeDependency( + id="shader-language-server", + description="shader-language-server for Linux (x64)", + url=f"{base_url}/shader-language-server-x86_64-unknown-linux-gnu.zip", + platform_id="linux-x64", + archive_type="zip", + binary_name="shader-language-server", + ), + RuntimeDependency( + id="shader-language-server", + description="shader-language-server for Windows (ARM64)", + url=f"{base_url}/shader-language-server-aarch64-pc-windows-msvc.zip", + platform_id="win-arm64", + archive_type="zip", + binary_name="shader-language-server.exe", + ), + RuntimeDependency( + id="shader-language-server", + description="shader-language-server for macOS (x64) - built from source", + command=cargo_install_cmd, + platform_id="osx-x64", + binary_name="bin/shader-language-server", + ), + RuntimeDependency( + id="shader-language-server", + description="shader-language-server for macOS (ARM64) - built from source", + command=cargo_install_cmd, + platform_id="osx-arm64", + binary_name="bin/shader-language-server", + ), + ] + ) + + try: + dep = deps.get_single_dep_for_current_platform() + except RuntimeError: + dep = None + + if dep is None: + raise FileNotFoundError( + "shader-language-server is not installed and no auto-install is available for your platform.\n" + "Please install it using one of the following methods:\n" + " cargo: cargo install shader_language_server\n" + " GitHub: Download from https://github.com/antaalt/shader-sense/releases\n" + "On macOS, install the Rust toolchain (https://rustup.rs) and Serena will build from source automatically.\n" + "See https://github.com/antaalt/shader-sense for more details." + ) + + install_dir = os.path.join(self._ls_resources_dir, "shader-language-server") + executable_path = deps.binary_path(install_dir) + + if not os.path.exists(executable_path): + log.info(f"shader-language-server not found. Downloading from {dep.url}") + _ = deps.install(install_dir) + + if not os.path.exists(executable_path): + raise FileNotFoundError(f"shader-language-server not found at {executable_path}") + + os.chmod(executable_path, 0o755) + return executable_path + + def _create_launch_command(self, core_path: str) -> list[str]: + return [core_path, "--stdio"] + + @staticmethod + def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + 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}, + "hover": { + "dynamicRegistration": True, + "contentFormat": ["markdown", "plaintext"], + }, + "signatureHelp": { + "dynamicRegistration": True, + "signatureInformation": { + "parameterInformation": {"labelOffsetSupport": True}, + }, + }, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "formatting": {"dynamicRegistration": True}, + "publishDiagnostics": {"relatedInformation": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "configuration": True, + }, + }, + "workspaceFolders": [{"uri": root_uri, "name": os.path.basename(repository_absolute_path)}], + } + return cast(InitializeParams, initialize_params) + + @override + 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"shader-language-server: {message}") + + def on_configuration_request(params: Any) -> list[dict]: + """Respond to workspace/configuration requests. + + shader-language-server requests config with section 'shader-validator'. + Return empty config to use defaults. + """ + items = params.get("items", []) if isinstance(params, dict) else [] + return [{}] * len(items) + + self.server.on_request("client/registerCapability", do_nothing) + self.server.on_request("workspace/configuration", on_configuration_request) + 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 shader-language-server 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) + + capabilities = init_response.get("capabilities", {}) + log.info(f"Initialize response capabilities: {list(capabilities.keys())}") + assert "textDocumentSync" in capabilities, "shader-language-server must support textDocumentSync" + if "documentSymbolProvider" not in capabilities: + log.warning("shader-language-server does not advertise documentSymbolProvider") + if "definitionProvider" not in capabilities: + log.warning("shader-language-server does not advertise definitionProvider") + + self.server.notify.initialized({}) + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + """Ignore Unity-specific directories that contain no user-authored shaders.""" + return super().is_ignored_dirname(dirname) or dirname in {"Library", "Temp", "Logs", "obj", "Packages"} diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 935ce98d..0b36a10a 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. """ + HLSL = "hlsl" + """Shader language server using shader-language-server (antaalt/shader-sense). + Supports .hlsl, .hlsli, .fx, .fxh, .cginc, .compute, .shader, .glsl, .vert, .frag, .geom, .tesc, .tese, .comp, .wgsl files. + Automatically downloads shader-language-server binary. + """ SYSTEMVERILOG = "systemverilog" """SystemVerilog language server using verible-verilog-ls. Supports .sv, .svh, .v, .vh files. @@ -251,6 +256,24 @@ class Language(str, Enum): return FilenameMatcher("*.groovy", "*.gvy") case self.MATLAB: return FilenameMatcher("*.m", "*.mlx", "*.mlapp") + case self.HLSL: + return FilenameMatcher( + "*.hlsl", + "*.hlsli", + "*.fx", + "*.fxh", + "*.cginc", + "*.compute", + "*.shader", + "*.glsl", + "*.vert", + "*.frag", + "*.geom", + "*.tesc", + "*.tese", + "*.comp", + "*.wgsl", + ) case self.SYSTEMVERILOG: return FilenameMatcher("*.sv", "*.svh", "*.v", "*.vh") case _: @@ -434,6 +457,10 @@ class Language(str, Enum): from solidlsp.language_servers.matlab_language_server import MatlabLanguageServer return MatlabLanguageServer + case self.HLSL: + from solidlsp.language_servers.hlsl_language_server import HlslLanguageServer + + return HlslLanguageServer case self.SYSTEMVERILOG: from solidlsp.language_servers.systemverilog_server import SystemVerilogLanguageServer diff --git a/test/resources/repos/hlsl/test_repo/common.hlsl b/test/resources/repos/hlsl/test_repo/common.hlsl new file mode 100644 index 00000000..1da49f84 --- /dev/null +++ b/test/resources/repos/hlsl/test_repo/common.hlsl @@ -0,0 +1,29 @@ +#ifndef COMMON_HLSL +#define COMMON_HLSL + +struct VertexInput +{ + float3 position : POSITION; + float3 normal : NORMAL; + float2 uv : TEXCOORD0; +}; + +struct VertexOutput +{ + float4 clipPos : SV_POSITION; + float3 worldNormal : TEXCOORD0; + float2 uv : TEXCOORD1; +}; + +float3 SafeNormalize(float3 v) +{ + float len = length(v); + return len > 0.0001 ? v / len : float3(0, 0, 0); +} + +float Remap(float value, float fromMin, float fromMax, float toMin, float toMax) +{ + return toMin + (value - fromMin) * (toMax - toMin) / (fromMax - fromMin); +} + +#endif // COMMON_HLSL diff --git a/test/resources/repos/hlsl/test_repo/compute_test.hlsl b/test/resources/repos/hlsl/test_repo/compute_test.hlsl new file mode 100644 index 00000000..f10531ef --- /dev/null +++ b/test/resources/repos/hlsl/test_repo/compute_test.hlsl @@ -0,0 +1,26 @@ +#include "common.hlsl" + +RWTexture2D OutputTexture : register(u0); +Texture2D InputTexture : register(t0); + +cbuffer ComputeParams : register(b0) +{ + uint2 TextureSize; + float BlurRadius; + float _Pad; +}; + +[numthreads(8, 8, 1)] +void CSMain(uint3 id : SV_DispatchThreadID) +{ + if (id.x >= TextureSize.x || id.y >= TextureSize.y) + return; + + float4 color = InputTexture[id.xy]; + float3 remapped = float3( + Remap(color.r, 0.0, 1.0, 0.2, 0.8), + Remap(color.g, 0.0, 1.0, 0.2, 0.8), + Remap(color.b, 0.0, 1.0, 0.2, 0.8) + ); + OutputTexture[id.xy] = float4(remapped, color.a); +} diff --git a/test/resources/repos/hlsl/test_repo/lighting.hlsl b/test/resources/repos/hlsl/test_repo/lighting.hlsl new file mode 100644 index 00000000..2334b639 --- /dev/null +++ b/test/resources/repos/hlsl/test_repo/lighting.hlsl @@ -0,0 +1,37 @@ +#ifndef LIGHTING_HLSL +#define LIGHTING_HLSL + +#include "common.hlsl" + +cbuffer LightingConstants : register(b0) +{ + float4x4 ViewProjection; + float3 LightDirection; + float LightIntensity; + float3 AmbientColor; + float _Padding; +}; + +float3 CalculateDiffuse(float3 normal, float3 lightDir, float3 albedo) +{ + float ndotl = max(dot(normal, -lightDir), 0.0); + return albedo * ndotl; +} + +float3 CalculateSpecular(float3 normal, float3 lightDir, float3 viewDir, float shininess) +{ + float3 halfVec = SafeNormalize(-lightDir + viewDir); + float ndoth = max(dot(normal, halfVec), 0.0); + return pow(ndoth, shininess); +} + +VertexOutput TransformVertex(VertexInput input) +{ + VertexOutput output; + output.clipPos = mul(ViewProjection, float4(input.position, 1.0)); + output.worldNormal = input.normal; + output.uv = input.uv; + return output; +} + +#endif // LIGHTING_HLSL diff --git a/test/resources/repos/hlsl/test_repo/terrain/terrain_sdf.hlsl b/test/resources/repos/hlsl/test_repo/terrain/terrain_sdf.hlsl new file mode 100644 index 00000000..a748a270 --- /dev/null +++ b/test/resources/repos/hlsl/test_repo/terrain/terrain_sdf.hlsl @@ -0,0 +1,33 @@ +#ifndef TERRAIN_SDF_HLSL +#define TERRAIN_SDF_HLSL + +#include "../common.hlsl" + +struct SDFBrickData +{ + float3 center; + float halfExtent; + int resolution; + float maxDistance; +}; + +float3 WorldOffset; + +float SampleSDF(float3 worldPos, SDFBrickData brick) +{ + float3 localPos = worldPos - brick.center; + float dist = length(localPos) - brick.halfExtent; + return dist; +} + +float3 CalculateGradient(float3 worldPos, SDFBrickData brick) +{ + float eps = 0.01; + float3 gradient; + gradient.x = SampleSDF(worldPos + float3(eps, 0, 0), brick) - SampleSDF(worldPos - float3(eps, 0, 0), brick); + gradient.y = SampleSDF(worldPos + float3(0, eps, 0), brick) - SampleSDF(worldPos - float3(0, eps, 0), brick); + gradient.z = SampleSDF(worldPos + float3(0, 0, eps), brick) - SampleSDF(worldPos - float3(0, 0, eps), brick); + return SafeNormalize(gradient); +} + +#endif // TERRAIN_SDF_HLSL diff --git a/test/solidlsp/hlsl/__init__.py b/test/solidlsp/hlsl/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/test/solidlsp/hlsl/__init__.py @@ -0,0 +1 @@ + diff --git a/test/solidlsp/hlsl/test_hlsl_basic.py b/test/solidlsp/hlsl/test_hlsl_basic.py new file mode 100644 index 00000000..7034579d --- /dev/null +++ b/test/solidlsp/hlsl/test_hlsl_basic.py @@ -0,0 +1,160 @@ +""" +Basic tests for HLSL language server integration (shader-language-server). + +This module tests Language.HLSL using shader-language-server from antaalt/shader-sense. +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_exceptions import SolidLSPException +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) + + +# ── Symbol Discovery ───────────────────────────────────────────── + + +@pytest.mark.hlsl +class TestHlslSymbols: + """Tests for document symbol extraction.""" + + @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) + def test_find_struct(self, language_server: SolidLanguageServer) -> None: + """VertexInput struct should appear in common.hlsl symbols.""" + symbol = _find_symbol_by_name(language_server, "common.hlsl", "VertexInput") + assert symbol is not None, "Expected 'VertexInput' struct in document symbols" + + @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) + def test_find_function(self, language_server: SolidLanguageServer) -> None: + """SafeNormalize function should appear in common.hlsl.""" + symbol = _find_symbol_by_name(language_server, "common.hlsl", "SafeNormalize") + assert symbol is not None, "Expected 'SafeNormalize' function in document symbols" + + @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) + def test_find_cbuffer_members(self, language_server: SolidLanguageServer) -> None: + """Cbuffer members should appear as variables in compute_test.hlsl. + + Note: shader-language-server reports cbuffer members as individual + variables (kind 13), not the cbuffer name itself as a symbol. + """ + symbol = _find_symbol_by_name(language_server, "compute_test.hlsl", "TextureSize") + assert symbol is not None, "Expected 'TextureSize' cbuffer member in document symbols" + + @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) + def test_find_compute_kernel(self, language_server: SolidLanguageServer) -> None: + """CSMain kernel should appear in compute_test.hlsl.""" + symbol = _find_symbol_by_name(language_server, "compute_test.hlsl", "CSMain") + assert symbol is not None, "Expected 'CSMain' compute kernel in document symbols" + + @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) + def test_full_symbol_tree(self, language_server: SolidLanguageServer) -> None: + """Full symbol tree should contain symbols from multiple files.""" + symbols = language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "VertexInput"), "VertexInput not in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "CalculateDiffuse"), "CalculateDiffuse not in symbol tree" + + +# ── Go-to-Definition ───────────────────────────────────────────── + + +@pytest.mark.hlsl +class TestHlslDefinition: + """Tests for go-to-definition capability.""" + + @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) + def test_goto_definition_cross_file(self, language_server: SolidLanguageServer) -> None: + """Navigating to SafeNormalize call in lighting.hlsl should resolve to common.hlsl. + + lighting.hlsl line 22 (0-indexed): " float3 halfVec = SafeNormalize(-lightDir + viewDir);" + SafeNormalize starts at column 21. + """ + definitions = language_server.request_definition("lighting.hlsl", 22, 21) + assert len(definitions) >= 1, f"Expected at least 1 definition, got {len(definitions)}" + def_paths = [d.get("relativePath", d.get("uri", "")) for d in definitions] + assert any("common.hlsl" in p for p in def_paths), f"Expected definition in common.hlsl, got: {def_paths}" + + @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) + def test_goto_definition_cross_file_remap(self, language_server: SolidLanguageServer) -> None: + """Navigating to Remap call in compute_test.hlsl should resolve to common.hlsl. + + compute_test.hlsl line 20 (0-indexed): " Remap(color.r, 0.0, 1.0, 0.2, 0.8)," + Remap starts at column 8. + """ + definitions = language_server.request_definition("compute_test.hlsl", 20, 8) + assert len(definitions) >= 1, f"Expected at least 1 definition, got {len(definitions)}" + def_paths = [d.get("relativePath", d.get("uri", "")) for d in definitions] + assert any("common.hlsl" in p for p in def_paths), f"Expected definition in common.hlsl, got: {def_paths}" + + +# ── References ──────────────────────────────────────────────────── + + +@pytest.mark.hlsl +class TestHlslReferences: + """Tests for find-references capability. + + shader-language-server does not advertise referencesProvider, so + request_references is expected to return an empty list. + """ + + @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) + def test_references_not_supported(self, language_server: SolidLanguageServer) -> None: + """References request should raise because shader-language-server does not support it. + + common.hlsl line 17 (0-indexed): "float3 SafeNormalize(float3 v)" + SafeNormalize starts at column 7. + """ + with pytest.raises(SolidLSPException, match="Method not found"): + language_server.request_references("common.hlsl", 17, 7) + + +# ── Hover ───────────────────────────────────────────────────────── + + +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.hlsl +class TestHlslHover: + """Tests for hover information.""" + + @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) + def test_hover_on_function(self, language_server: SolidLanguageServer) -> None: + """Hovering over SafeNormalize definition should return info. + + common.hlsl line 17 (0-indexed): "float3 SafeNormalize(float3 v)" + SafeNormalize starts at column 7. + """ + hover_info = language_server.request_hover("common.hlsl", 17, 7) + assert hover_info is not None, "Hover should return information for SafeNormalize" + 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" + + @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) + def test_hover_on_struct(self, language_server: SolidLanguageServer) -> None: + """Hovering over VertexInput should return struct info. + + common.hlsl line 3 (0-indexed): "struct VertexInput" + VertexInput starts at column 7. + """ + hover_info = language_server.request_hover("common.hlsl", 3, 7) + assert hover_info is not None, "Hover should return information for VertexInput" + assert "contents" in hover_info, "Hover should have contents" diff --git a/test/solidlsp/hlsl/test_hlsl_full_index.py b/test/solidlsp/hlsl/test_hlsl_full_index.py new file mode 100644 index 00000000..89b156d4 --- /dev/null +++ b/test/solidlsp/hlsl/test_hlsl_full_index.py @@ -0,0 +1,63 @@ +""" +Regression tests for HLSL full symbol tree indexing. + +These tests verify that request_full_symbol_tree() correctly indexes all files, +including .hlsl includes in subdirectories. This catches bugs where files are +silently dropped during workspace-wide indexing. +""" + +from typing import Any + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_types import SymbolKind +from solidlsp.ls_utils import SymbolUtils + + +def _collect_file_names(symbols: list[dict[str, Any]]) -> set[str]: + """Recursively collect the names of all File-kind symbols in the tree.""" + names: set[str] = set() + for sym in symbols: + if sym.get("kind") == SymbolKind.File: + names.add(sym["name"]) + if "children" in sym: + names.update(_collect_file_names(sym["children"])) + return names + + +EXPECTED_FILES = {"common", "lighting", "compute_test", "terrain_sdf"} + +TERRAIN_SDF_UNIQUE_SYMBOLS = {"SampleSDF", "CalculateGradient", "SDFBrickData"} + + +@pytest.mark.hlsl +class TestHlslFullIndex: + """Tests for full symbol tree indexing completeness.""" + + @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) + def test_all_files_indexed_in_symbol_tree(self, language_server: SolidLanguageServer) -> None: + """Every .hlsl file in the test repo must appear as a File symbol in the tree.""" + symbols = language_server.request_full_symbol_tree() + file_names = _collect_file_names(symbols) + missing = EXPECTED_FILES - file_names + assert not missing, f"Files missing from full symbol tree: {missing}. Found: {file_names}" + + @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) + def test_subdirectory_file_symbols_present(self, language_server: SolidLanguageServer) -> None: + """Symbols unique to terrain/terrain_sdf.hlsl must appear in the full tree.""" + symbols = language_server.request_full_symbol_tree() + for name in TERRAIN_SDF_UNIQUE_SYMBOLS: + assert SymbolUtils.symbol_tree_contains_name( + symbols, name + ), f"Expected '{name}' from terrain/terrain_sdf.hlsl in full symbol tree" + + @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) + def test_include_file_document_symbols_directly(self, language_server: SolidLanguageServer) -> None: + """request_document_symbols on terrain/terrain_sdf.hlsl should return its symbols.""" + doc_symbols = language_server.request_document_symbols("terrain/terrain_sdf.hlsl") + all_symbols = doc_symbols.get_all_symbols_and_roots() + symbol_names = {s.get("name") for s in all_symbols[0]} + for name in TERRAIN_SDF_UNIQUE_SYMBOLS: + assert name in symbol_names, f"Expected '{name}' in document symbols for terrain/terrain_sdf.hlsl, got: {symbol_names}"