From 7e6dbdc9ba91b02a89f6a7268d4cadd904871559 Mon Sep 17 00:00:00 2001 From: Dominic Rubas Date: Fri, 22 Aug 2025 23:34:46 +0200 Subject: [PATCH] Feat: Add Support for Zig, Nix, Lua (#498) Extensive tests for each of the three languages were added --- .github/workflows/pytest.yml | 89 +++++ .gitignore | 3 + CHANGELOG.md | 10 +- README.md | 3 + pyproject.toml | 3 + src/solidlsp/language_servers/lua_ls.py | 297 +++++++++++++++ src/solidlsp/language_servers/nixd_ls.py | 322 ++++++++++++++++ src/solidlsp/language_servers/zls.py | 252 +++++++++++++ src/solidlsp/ls.py | 15 + src/solidlsp/ls_config.py | 9 + test/resources/repos/lua/test_repo/.gitignore | 13 + test/resources/repos/lua/test_repo/main.lua | 114 ++++++ .../repos/lua/test_repo/src/calculator.lua | 76 ++++ .../repos/lua/test_repo/src/utils.lua | 120 ++++++ .../lua/test_repo/tests/test_calculator.lua | 93 +++++ test/resources/repos/nix/test_repo/.gitignore | 11 + .../resources/repos/nix/test_repo/default.nix | 182 ++++++++++ test/resources/repos/nix/test_repo/flake.nix | 146 ++++++++ .../repos/nix/test_repo/lib/utils.nix | 165 +++++++++ .../repos/nix/test_repo/modules/example.nix | 145 ++++++++ .../repos/nix/test_repo/scripts/hello.sh | 3 + test/resources/repos/zig/test_repo/.gitignore | 5 + test/resources/repos/zig/test_repo/build.zig | 31 ++ .../repos/zig/test_repo/src/calculator.zig | 66 ++++ .../repos/zig/test_repo/src/main.zig | 32 ++ .../repos/zig/test_repo/src/math_utils.zig | 65 ++++ test/resources/repos/zig/test_repo/zls.json | 12 + test/solidlsp/lua/test_lua_basic.py | 255 +++++++++++++ test/solidlsp/nix/test_nix_basic.py | 218 +++++++++++ test/solidlsp/zig/test_zig_basic.py | 343 ++++++++++++++++++ 30 files changed, 3094 insertions(+), 4 deletions(-) create mode 100644 src/solidlsp/language_servers/lua_ls.py create mode 100644 src/solidlsp/language_servers/nixd_ls.py create mode 100644 src/solidlsp/language_servers/zls.py create mode 100644 test/resources/repos/lua/test_repo/.gitignore create mode 100644 test/resources/repos/lua/test_repo/main.lua create mode 100644 test/resources/repos/lua/test_repo/src/calculator.lua create mode 100644 test/resources/repos/lua/test_repo/src/utils.lua create mode 100644 test/resources/repos/lua/test_repo/tests/test_calculator.lua create mode 100644 test/resources/repos/nix/test_repo/.gitignore create mode 100644 test/resources/repos/nix/test_repo/default.nix create mode 100644 test/resources/repos/nix/test_repo/flake.nix create mode 100644 test/resources/repos/nix/test_repo/lib/utils.nix create mode 100644 test/resources/repos/nix/test_repo/modules/example.nix create mode 100644 test/resources/repos/nix/test_repo/scripts/hello.sh create mode 100644 test/resources/repos/zig/test_repo/.gitignore create mode 100644 test/resources/repos/zig/test_repo/build.zig create mode 100644 test/resources/repos/zig/test_repo/src/calculator.zig create mode 100644 test/resources/repos/zig/test_repo/src/main.zig create mode 100644 test/resources/repos/zig/test_repo/src/math_utils.zig create mode 100644 test/resources/repos/zig/test_repo/zls.json create mode 100644 test/solidlsp/lua/test_lua_basic.py create mode 100644 test/solidlsp/nix/test_nix_basic.py create mode 100644 test/solidlsp/zig/test_zig_basic.py diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index cfb46229..a6eff435 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -111,6 +111,95 @@ jobs: - name: Install Ruby language server shell: bash run: gem install solargraph + - name: Install Zig + uses: goto-bus-stop/setup-zig@v2 + with: + version: 0.14.1 + - name: Install ZLS (Zig Language Server) + shell: bash + run: | + if [[ "${{ runner.os }}" == "Linux" ]]; then + wget https://github.com/zigtools/zls/releases/download/0.14.0/zls-x86_64-linux.tar.xz + tar -xf zls-x86_64-linux.tar.xz + sudo mv zls /usr/local/bin/ + rm zls-x86_64-linux.tar.xz + elif [[ "${{ runner.os }}" == "macOS" ]]; then + wget https://github.com/zigtools/zls/releases/download/0.14.0/zls-x86_64-macos.tar.xz + tar -xf zls-x86_64-macos.tar.xz + sudo mv zls /usr/local/bin/ + rm zls-x86_64-macos.tar.xz + elif [[ "${{ runner.os }}" == "Windows" ]]; then + curl -L -o zls.zip https://github.com/zigtools/zls/releases/download/0.14.0/zls-x86_64-windows.zip + unzip -o zls.zip + mkdir -p "$HOME/bin" + mv zls.exe "$HOME/bin/" + echo "$HOME/bin" >> $GITHUB_PATH + rm zls.zip + fi + - name: Install Lua Language Server + shell: bash + run: | + LUA_LS_VERSION="3.15.0" + LUA_LS_DIR="$HOME/.serena/language_servers/lua" + mkdir -p "$LUA_LS_DIR" + + if [[ "${{ runner.os }}" == "Linux" ]]; then + if [[ "$(uname -m)" == "x86_64" ]]; then + wget https://github.com/LuaLS/lua-language-server/releases/download/${LUA_LS_VERSION}/lua-language-server-${LUA_LS_VERSION}-linux-x64.tar.gz + tar -xzf lua-language-server-${LUA_LS_VERSION}-linux-x64.tar.gz -C "$LUA_LS_DIR" + else + wget https://github.com/LuaLS/lua-language-server/releases/download/${LUA_LS_VERSION}/lua-language-server-${LUA_LS_VERSION}-linux-arm64.tar.gz + tar -xzf lua-language-server-${LUA_LS_VERSION}-linux-arm64.tar.gz -C "$LUA_LS_DIR" + fi + chmod +x "$LUA_LS_DIR/bin/lua-language-server" + # Create wrapper script instead of symlink to ensure supporting files are found + echo '#!/bin/bash' | sudo tee /usr/local/bin/lua-language-server > /dev/null + echo 'cd "${HOME}/.serena/language_servers/lua/bin"' | sudo tee -a /usr/local/bin/lua-language-server > /dev/null + echo 'exec ./lua-language-server "$@"' | sudo tee -a /usr/local/bin/lua-language-server > /dev/null + sudo chmod +x /usr/local/bin/lua-language-server + rm lua-language-server-*.tar.gz + elif [[ "${{ runner.os }}" == "macOS" ]]; then + if [[ "$(uname -m)" == "x86_64" ]]; then + wget https://github.com/LuaLS/lua-language-server/releases/download/${LUA_LS_VERSION}/lua-language-server-${LUA_LS_VERSION}-darwin-x64.tar.gz + tar -xzf lua-language-server-${LUA_LS_VERSION}-darwin-x64.tar.gz -C "$LUA_LS_DIR" + else + wget https://github.com/LuaLS/lua-language-server/releases/download/${LUA_LS_VERSION}/lua-language-server-${LUA_LS_VERSION}-darwin-arm64.tar.gz + tar -xzf lua-language-server-${LUA_LS_VERSION}-darwin-arm64.tar.gz -C "$LUA_LS_DIR" + fi + chmod +x "$LUA_LS_DIR/bin/lua-language-server" + # Create wrapper script instead of symlink to ensure supporting files are found + echo '#!/bin/bash' | sudo tee /usr/local/bin/lua-language-server > /dev/null + echo 'cd "${HOME}/.serena/language_servers/lua/bin"' | sudo tee -a /usr/local/bin/lua-language-server > /dev/null + echo 'exec ./lua-language-server "$@"' | sudo tee -a /usr/local/bin/lua-language-server > /dev/null + sudo chmod +x /usr/local/bin/lua-language-server + rm lua-language-server-*.tar.gz + elif [[ "${{ runner.os }}" == "Windows" ]]; then + curl -L -o lua-ls.zip https://github.com/LuaLS/lua-language-server/releases/download/${LUA_LS_VERSION}/lua-language-server-${LUA_LS_VERSION}-win32-x64.zip + unzip -o lua-ls.zip -d "$LUA_LS_DIR" + # For Windows, we'll add the bin directory directly to PATH + # The lua-language-server.exe can find its supporting files relative to its location + echo "$LUA_LS_DIR/bin" >> $GITHUB_PATH + rm lua-ls.zip + fi + - name: Install Nix + if: runner.os != 'Windows' # Nix doesn't support Windows natively + uses: cachix/install-nix-action@v30 + with: + nix_path: nixpkgs=channel:nixos-unstable + - name: Install nixd (Nix Language Server) + if: runner.os != 'Windows' # Skip on Windows since Nix isn't available + shell: bash + run: | + # Install nixd using nix + nix profile install github:nix-community/nixd + + # Verify nixd is installed and working + if ! command -v nixd &> /dev/null; then + echo "nixd installation failed or not in PATH" + exit 1 + fi + + echo "$HOME/.nix-profile/bin" >> $GITHUB_PATH - name: Install uv shell: bash run: curl -LsSf https://astral.sh/uv/install.sh | sh diff --git a/.gitignore b/.gitignore index fdbd1c9f..1ac36b26 100644 --- a/.gitignore +++ b/.gitignore @@ -228,6 +228,9 @@ tmp/ # Exception: Don't ignore Elixir test repository lib directory (contains source code) !/test/resources/repos/elixir/test_repo/lib +# Exception: Don't ignore Nix test repository lib directory (contains source code) +!/test/resources/repos/nix/test_repo/lib + # Swift /test/resources/repos/swift/test_repo/.build /test/resources/repos/swift/test_repo/.swiftpm diff --git a/CHANGELOG.md b/CHANGELOG.md index abdae71b..8710af74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,12 @@ # latest Status of the `main` branch. Changes prior to the next official version change will appear here. -## New Features - -* **Dart language support**: Added full support for Dart programming language with comprehensive test coverage. Dart projects are now fully supported with symbol navigation, definition finding, and reference detection capabilities. - +* New languages supported: + * **Add support for Zig** via ZLS (Zig Language Server) + * **Add support for Lua** via lua-language-server (automatically downloaded if not installed) + * **Add support for Nix** via nixd (Nix Language server) - requires nixd installation (Windows not supported) + * **Dart language support**: Dart was always working, but now tests were added and it is promoted to "officially supported" + ## Breaking Changes * **Rust support now requires rustup**: The rust-analyzer is no longer bundled with Serena. Instead, it uses the rust-analyzer from your Rust toolchain managed by rustup. This ensures compatibility with your Rust version and eliminates outdated bundled binaries. If you don't have rustup installed, you'll need to install it from https://rustup.rs/ diff --git a/README.md b/README.md index 81230284..327cc3e2 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,9 @@ With Serena, we provide * Clojure * Bash * C/C++ (You may experience issues with finding references, we are working on it) + * Zig (requires installation of ZLS - Zig Language Server) + * Lua (automatically downloads lua-language-server if not installed) + * Nix (requires nixd installation) * Dart * indirect support (may require some code changes/manual installation) for: * Kotlin (untested) diff --git a/pyproject.toml b/pyproject.toml index 69d37068..30afdc37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -264,6 +264,9 @@ markers = [ "bash: language server running for Bash", "snapshot: snapshot tests for symbolic editing operations", "ruby: language server running for Ruby", + "zig: language server running for Zig", + "lua: language server running for Lua", + "nix: language server running for Nix", "dart: language server running for Dart", ] diff --git a/src/solidlsp/language_servers/lua_ls.py b/src/solidlsp/language_servers/lua_ls.py new file mode 100644 index 00000000..c0f64b37 --- /dev/null +++ b/src/solidlsp/language_servers/lua_ls.py @@ -0,0 +1,297 @@ +""" +Provides Lua specific instantiation of the LanguageServer class using lua-language-server. +""" + +import logging +import os +import pathlib +import platform +import shutil +import tarfile +import threading +import zipfile +from pathlib import Path + +import requests +from overrides import override + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_logger import LanguageServerLogger +from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + + +class LuaLanguageServer(SolidLanguageServer): + """ + Provides Lua specific instantiation of the LanguageServer class using lua-language-server. + """ + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + # For Lua projects, we should ignore: + # - .luarocks: package manager cache + # - lua_modules: local dependencies + # - node_modules: if the project has JavaScript components + return super().is_ignored_dirname(dirname) or dirname in [".luarocks", "lua_modules", "node_modules", "build", "dist", ".cache"] + + @staticmethod + def _get_lua_ls_path(): + """Get the path to lua-language-server executable.""" + # First check if it's in PATH + lua_ls = shutil.which("lua-language-server") + if lua_ls: + return lua_ls + + # Check common installation locations + home = Path.home() + possible_paths = [ + home / ".local" / "bin" / "lua-language-server", + home / ".serena" / "language_servers" / "lua" / "bin" / "lua-language-server", + Path("/usr/local/bin/lua-language-server"), + Path("/opt/lua-language-server/bin/lua-language-server"), + ] + + # Add Windows-specific paths + if platform.system() == "Windows": + possible_paths.extend( + [ + home / "AppData" / "Local" / "lua-language-server" / "bin" / "lua-language-server.exe", + home / ".serena" / "language_servers" / "lua" / "bin" / "lua-language-server.exe", + ] + ) + + for path in possible_paths: + if path.exists(): + return str(path) + + return None + + @staticmethod + def _download_lua_ls(): + """Download and install lua-language-server if not present.""" + system = platform.system() + machine = platform.machine().lower() + lua_ls_version = "3.15.0" + + # Map platform and architecture to download URL + if system == "Linux": + if machine in ["x86_64", "amd64"]: + download_name = f"lua-language-server-{lua_ls_version}-linux-x64.tar.gz" + elif machine in ["aarch64", "arm64"]: + download_name = f"lua-language-server-{lua_ls_version}-linux-arm64.tar.gz" + else: + raise RuntimeError(f"Unsupported Linux architecture: {machine}") + elif system == "Darwin": + if machine in ["x86_64", "amd64"]: + download_name = f"lua-language-server-{lua_ls_version}-darwin-x64.tar.gz" + elif machine in ["arm64", "aarch64"]: + download_name = f"lua-language-server-{lua_ls_version}-darwin-arm64.tar.gz" + else: + raise RuntimeError(f"Unsupported macOS architecture: {machine}") + elif system == "Windows": + if machine in ["amd64", "x86_64"]: + download_name = f"lua-language-server-{lua_ls_version}-win32-x64.zip" + else: + raise RuntimeError(f"Unsupported Windows architecture: {machine}") + else: + raise RuntimeError(f"Unsupported operating system: {system}") + + download_url = f"https://github.com/LuaLS/lua-language-server/releases/download/{lua_ls_version}/{download_name}" + + # Create installation directory + install_dir = Path.home() / ".serena" / "language_servers" / "lua" + install_dir.mkdir(parents=True, exist_ok=True) + + # Download the file + print(f"Downloading lua-language-server from {download_url}...") + response = requests.get(download_url, stream=True) + response.raise_for_status() + + # Save and extract + download_path = install_dir / download_name + with open(download_path, "wb") as f: + for chunk in response.iter_content(chunk_size=8192): + f.write(chunk) + + print(f"Extracting lua-language-server to {install_dir}...") + if download_name.endswith(".tar.gz"): + with tarfile.open(download_path, "r:gz") as tar: + tar.extractall(install_dir) + elif download_name.endswith(".zip"): + with zipfile.ZipFile(download_path, "r") as zip_ref: + zip_ref.extractall(install_dir) + + # Clean up download file + download_path.unlink() + + # Make executable on Unix systems + if system != "Windows": + lua_ls_path = install_dir / "bin" / "lua-language-server" + if lua_ls_path.exists(): + lua_ls_path.chmod(0o755) + return str(lua_ls_path) + else: + lua_ls_path = install_dir / "bin" / "lua-language-server.exe" + if lua_ls_path.exists(): + return str(lua_ls_path) + + raise RuntimeError("Failed to find lua-language-server executable after extraction") + + @staticmethod + def _setup_runtime_dependency(): + """ + Check if required Lua runtime dependencies are available. + Downloads lua-language-server if not present. + """ + lua_ls_path = LuaLanguageServer._get_lua_ls_path() + + if not lua_ls_path: + print("lua-language-server not found. Downloading...") + lua_ls_path = LuaLanguageServer._download_lua_ls() + print(f"lua-language-server installed at: {lua_ls_path}") + + return lua_ls_path + + def __init__( + self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings + ): + lua_ls_path = self._setup_runtime_dependency() + + super().__init__( + config, + logger, + 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 + def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for the Lua Language Server. + """ + root_uri = pathlib.Path(repository_absolute_path).as_uri() + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "completion": { + "dynamicRegistration": True, + "completionItem": { + "snippetSupport": True, + "commitCharactersSupport": True, + "documentationFormat": ["markdown", "plaintext"], + "deprecatedSupport": True, + "preselectSupport": True, + }, + }, + "hover": { + "dynamicRegistration": True, + "contentFormat": ["markdown", "plaintext"], + }, + "signatureHelp": { + "dynamicRegistration": True, + "signatureInformation": { + "documentationFormat": ["markdown", "plaintext"], + "parameterInformation": {"labelOffsetSupport": True}, + }, + }, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "configuration": True, + "symbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + }, + }, + "processId": os.getpid(), + "rootPath": repository_absolute_path, + "rootUri": root_uri, + "workspaceFolders": [ + { + "uri": root_uri, + "name": os.path.basename(repository_absolute_path), + } + ], + "initializationOptions": { + # Lua Language Server specific options + "runtime": { + "version": "Lua 5.4", + "path": ["?.lua", "?/init.lua"], + }, + "diagnostics": { + "enable": True, + "globals": ["vim", "describe", "it", "before_each", "after_each"], # Common globals + }, + "workspace": { + "library": [], # Can be extended with project-specific libraries + "checkThirdParty": False, + "userThirdParty": [], + }, + "telemetry": { + "enable": False, + }, + "completion": { + "enable": True, + "callSnippet": "Both", + "keywordSnippet": "Both", + }, + }, + } + return initialize_params + + def _start_server(self): + """Start Lua Language Server process""" + + def register_capability_handler(params): + return + + def window_log_message(msg): + self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + + def do_nothing(params): + return + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + self.logger.log("Starting Lua Language Server process", logging.INFO) + self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + self.logger.log( + "Sending initialize request from LSP client to LSP server and awaiting response", + logging.INFO, + ) + init_response = self.server.send.initialize(initialize_params) + + # Verify server capabilities + assert "textDocumentSync" in init_response["capabilities"] + assert "definitionProvider" in init_response["capabilities"] + assert "documentSymbolProvider" in init_response["capabilities"] + 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() diff --git a/src/solidlsp/language_servers/nixd_ls.py b/src/solidlsp/language_servers/nixd_ls.py new file mode 100644 index 00000000..3408f180 --- /dev/null +++ b/src/solidlsp/language_servers/nixd_ls.py @@ -0,0 +1,322 @@ +""" +Provides Nix specific instantiation of the LanguageServer class using nixd (Nix Language Server). + +Note: Windows is not supported as Nix itself doesn't support Windows natively. +""" + +import logging +import os +import pathlib +import platform +import shutil +import subprocess +import threading +from pathlib import Path + +from overrides import override + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_logger import LanguageServerLogger +from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + + +class NixLanguageServer(SolidLanguageServer): + """ + Provides Nix specific instantiation of the LanguageServer class using nixd. + """ + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + # For Nix projects, we should ignore: + # - result: nix build output symlinks + # - result-*: multiple build outputs + # - .direnv: direnv cache + return super().is_ignored_dirname(dirname) or dirname in ["result", ".direnv"] or dirname.startswith("result-") + + @staticmethod + def _get_nixd_version(): + """Get the installed nixd version or None if not found.""" + try: + result = subprocess.run(["nixd", "--version"], capture_output=True, text=True, check=False) + if result.returncode == 0: + # nixd outputs version like: nixd 2.0.0 + return result.stdout.strip() + except FileNotFoundError: + return None + return None + + @staticmethod + def _check_nixd_installed(): + """Check if nixd is installed in the system.""" + return shutil.which("nixd") is not None + + @staticmethod + def _get_nixd_path(): + """Get the path to nixd executable.""" + # First check if it's in PATH + nixd_path = shutil.which("nixd") + if nixd_path: + return nixd_path + + # Check common installation locations + home = Path.home() + possible_paths = [ + home / ".local" / "bin" / "nixd", + home / ".serena" / "language_servers" / "nixd" / "nixd", + home / ".nix-profile" / "bin" / "nixd", + Path("/usr/local/bin/nixd"), + Path("/run/current-system/sw/bin/nixd"), # NixOS system profile + Path("/opt/homebrew/bin/nixd"), # Homebrew on Apple Silicon + Path("/usr/local/opt/nixd/bin/nixd"), # Homebrew on Intel Mac + ] + + # Add Windows-specific paths + if platform.system() == "Windows": + possible_paths.extend( + [ + home / "AppData" / "Local" / "nixd" / "nixd.exe", + home / ".serena" / "language_servers" / "nixd" / "nixd.exe", + ] + ) + + for path in possible_paths: + if path.exists(): + return str(path) + + return None + + @staticmethod + def _install_nixd_with_nix(): + """Install nixd using nix if available.""" + # Check if nix is available + if not shutil.which("nix"): + return None + + print("Installing nixd using nix... This may take a few minutes.") + try: + # Try to install nixd using nix profile + result = subprocess.run( + ["nix", "profile", "install", "github:nix-community/nixd"], + capture_output=True, + text=True, + check=False, + timeout=600, # 10 minute timeout for building + ) + + if result.returncode == 0: + # Check if nixd is now in PATH + nixd_path = shutil.which("nixd") + if nixd_path: + print(f"Successfully installed nixd at: {nixd_path}") + return nixd_path + else: + # Try nix-env as fallback + result = subprocess.run( + ["nix-env", "-iA", "nixpkgs.nixd"], + capture_output=True, + text=True, + check=False, + timeout=600, + ) + if result.returncode == 0: + nixd_path = shutil.which("nixd") + if nixd_path: + print(f"Successfully installed nixd at: {nixd_path}") + return nixd_path + print(f"Failed to install nixd: {result.stderr}") + + except subprocess.TimeoutExpired: + print("Nix install timed out after 10 minutes") + except Exception as e: + print(f"Error installing nixd with nix: {e}") + + return None + + @staticmethod + def _setup_runtime_dependency(): + """ + Check if required Nix runtime dependencies are available. + Attempts to install nixd if not present. + """ + # First check if Nix is available (nixd needs it at runtime) + if not shutil.which("nix"): + print("WARNING: Nix is not installed. nixd requires Nix to function properly.") + raise RuntimeError("Nix is required for nixd. Please install Nix from https://nixos.org/download.html") + + nixd_path = NixLanguageServer._get_nixd_path() + + if not nixd_path: + print("nixd not found. Attempting to install...") + + # Try to install with nix if available + nixd_path = NixLanguageServer._install_nixd_with_nix() + + if not nixd_path: + raise RuntimeError( + "nixd (Nix Language Server) is not installed.\n" + "Please install nixd using one of the following methods:\n" + " - Using Nix flakes: nix profile install github:nix-community/nixd\n" + " - From nixpkgs: nix-env -iA nixpkgs.nixd\n" + " - On macOS with Homebrew: brew install nixd\n\n" + "After installation, make sure 'nixd' is in your PATH." + ) + + # Verify nixd works + try: + result = subprocess.run([nixd_path, "--version"], capture_output=True, text=True, check=False, timeout=5) + if result.returncode != 0: + raise RuntimeError(f"nixd failed to run: {result.stderr}") + except Exception as e: + raise RuntimeError(f"Failed to verify nixd installation: {e}") + + return nixd_path + + def __init__( + self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings + ): + nixd_path = self._setup_runtime_dependency() + + super().__init__( + config, + logger, + repository_root_path, + ProcessLaunchInfo(cmd=nixd_path, cwd=repository_root_path), + "nix", + solidlsp_settings, + ) + self.server_ready = threading.Event() + self.request_id = 0 + + @staticmethod + def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for nixd. + """ + root_uri = pathlib.Path(repository_absolute_path).as_uri() + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "completion": { + "dynamicRegistration": True, + "completionItem": { + "snippetSupport": True, + "commitCharactersSupport": True, + "documentationFormat": ["markdown", "plaintext"], + "deprecatedSupport": True, + "preselectSupport": True, + }, + }, + "hover": { + "dynamicRegistration": True, + "contentFormat": ["markdown", "plaintext"], + }, + "signatureHelp": { + "dynamicRegistration": True, + "signatureInformation": { + "documentationFormat": ["markdown", "plaintext"], + "parameterInformation": {"labelOffsetSupport": True}, + }, + }, + "codeAction": { + "dynamicRegistration": True, + "codeActionLiteralSupport": { + "codeActionKind": { + "valueSet": [ + "", + "quickfix", + "refactor", + "refactor.extract", + "refactor.inline", + "refactor.rewrite", + "source", + "source.organizeImports", + ] + } + }, + }, + "rename": {"dynamicRegistration": True, "prepareSupport": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "configuration": True, + "symbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + }, + }, + "processId": os.getpid(), + "rootPath": repository_absolute_path, + "rootUri": root_uri, + "workspaceFolders": [ + { + "uri": root_uri, + "name": os.path.basename(repository_absolute_path), + } + ], + "initializationOptions": { + # nixd specific options + "nixpkgs": {"expr": "import { }"}, + "formatting": {"command": ["nixpkgs-fmt"]}, # or ["alejandra"] or ["nixfmt"] + "options": { + "enable": True, + "target": { + "installable": "", # Will be auto-detected from flake.nix if present + }, + }, + }, + } + return initialize_params + + def _start_server(self): + """Start nixd server process""" + + def register_capability_handler(params): + return + + def window_log_message(msg): + self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + + def do_nothing(params): + return + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + self.logger.log("Starting nixd server process", logging.INFO) + self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + self.logger.log( + "Sending initialize request from LSP client to LSP server and awaiting response", + logging.INFO, + ) + init_response = self.server.send.initialize(initialize_params) + + # Verify server capabilities + assert "textDocumentSync" in init_response["capabilities"] + assert "definitionProvider" in init_response["capabilities"] + assert "documentSymbolProvider" in init_response["capabilities"] + 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/zls.py b/src/solidlsp/language_servers/zls.py new file mode 100644 index 00000000..893a4681 --- /dev/null +++ b/src/solidlsp/language_servers/zls.py @@ -0,0 +1,252 @@ +""" +Provides Zig specific instantiation of the LanguageServer class using ZLS (Zig Language Server). +""" + +import logging +import os +import pathlib +import platform +import shutil +import subprocess +import threading + +from overrides import override + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_logger import LanguageServerLogger +from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + + +class ZigLanguageServer(SolidLanguageServer): + """ + Provides Zig specific instantiation of the LanguageServer class using ZLS. + """ + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + # For Zig projects, we should ignore: + # - zig-cache: build cache directory + # - zig-out: default build output directory + # - .zig-cache: alternative cache location + # - node_modules: if the project has JavaScript components + return super().is_ignored_dirname(dirname) or dirname in ["zig-cache", "zig-out", ".zig-cache", "node_modules", "build", "dist"] + + @staticmethod + def _get_zig_version(): + """Get the installed Zig version or None if not found.""" + try: + result = subprocess.run(["zig", "version"], capture_output=True, text=True, check=False) + if result.returncode == 0: + return result.stdout.strip() + except FileNotFoundError: + return None + return None + + @staticmethod + def _get_zls_version(): + """Get the installed ZLS version or None if not found.""" + try: + result = subprocess.run(["zls", "--version"], capture_output=True, text=True, check=False) + if result.returncode == 0: + return result.stdout.strip() + except FileNotFoundError: + return None + return None + + @staticmethod + def _check_zls_installed(): + """Check if ZLS is installed in the system.""" + return shutil.which("zls") is not None + + @staticmethod + def _setup_runtime_dependency(): + """ + Check if required Zig runtime dependencies are available. + Raises RuntimeError with helpful message if dependencies are missing. + """ + # Check for Windows and provide error message + if platform.system() == "Windows": + raise RuntimeError( + "Windows is not supported by ZLS in this integration. " + "Cross-file references don't work reliably on Windows. Reason unknown." + ) + + zig_version = ZigLanguageServer._get_zig_version() + if not zig_version: + raise RuntimeError( + "Zig is not installed. Please install Zig from https://ziglang.org/download/ and make sure it is added to your PATH." + ) + + if not ZigLanguageServer._check_zls_installed(): + zls_version = ZigLanguageServer._get_zls_version() + if not zls_version: + raise RuntimeError( + "Found Zig but ZLS (Zig Language Server) is not installed.\n" + "Please install ZLS from https://github.com/zigtools/zls\n" + "You can install it via:\n" + " - Package managers (brew install zls, scoop install zls, etc.)\n" + " - Download pre-built binaries from GitHub releases\n" + " - Build from source with: zig build -Doptimize=ReleaseSafe\n\n" + "After installation, make sure 'zls' is added to your PATH." + ) + + return True + + def __init__( + self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings + ): + self._setup_runtime_dependency() + + super().__init__( + config, + logger, + repository_root_path, + ProcessLaunchInfo(cmd="zls", cwd=repository_root_path), + "zig", + solidlsp_settings, + ) + self.server_ready = threading.Event() + self.request_id = 0 + + @staticmethod + def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for the Zig Language Server. + """ + root_uri = pathlib.Path(repository_absolute_path).as_uri() + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "completion": { + "dynamicRegistration": True, + "completionItem": { + "snippetSupport": True, + "commitCharactersSupport": True, + "documentationFormat": ["markdown", "plaintext"], + "deprecatedSupport": True, + "preselectSupport": True, + }, + }, + "hover": { + "dynamicRegistration": True, + "contentFormat": ["markdown", "plaintext"], + }, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "configuration": True, + }, + }, + "processId": os.getpid(), + "rootPath": repository_absolute_path, + "rootUri": root_uri, + "workspaceFolders": [ + { + "uri": root_uri, + "name": os.path.basename(repository_absolute_path), + } + ], + "initializationOptions": { + # ZLS specific options based on schema.json + # Critical paths for ZLS to understand the project + "zig_exe_path": shutil.which("zig"), # Path to zig executable + "zig_lib_path": None, # Let ZLS auto-detect + "build_runner_path": None, # Let ZLS use its built-in runner + "global_cache_path": None, # Let ZLS use default cache + # Build configuration + "enable_build_on_save": True, # Enable to analyze project structure + "build_on_save_args": ["build"], + # Features + "enable_snippets": True, + "enable_argument_placeholders": True, + "semantic_tokens": "full", + "warn_style": False, + "highlight_global_var_declarations": False, + "skip_std_references": False, + "prefer_ast_check_as_child_process": True, + "completion_label_details": True, + # Inlay hints configuration + "inlay_hints_show_variable_type_hints": True, + "inlay_hints_show_struct_literal_field_type": True, + "inlay_hints_show_parameter_name": True, + "inlay_hints_show_builtin": True, + "inlay_hints_exclude_single_argument": True, + "inlay_hints_hide_redundant_param_names": False, + "inlay_hints_hide_redundant_param_names_last_token": False, + }, + } + return initialize_params + + def _start_server(self): + """Start ZLS server process""" + + def register_capability_handler(params): + return + + def window_log_message(msg): + self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + + def do_nothing(params): + return + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + self.logger.log("Starting ZLS server process", logging.INFO) + self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + self.logger.log( + "Sending initialize request from LSP client to LSP server and awaiting response", + logging.INFO, + ) + init_response = self.server.send.initialize(initialize_params) + + # Verify server capabilities + assert "textDocumentSync" in init_response["capabilities"] + assert "definitionProvider" in init_response["capabilities"] + assert "documentSymbolProvider" in init_response["capabilities"] + 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() + + # Open build.zig if it exists to help ZLS understand project structure + build_zig_path = os.path.join(self.repository_root_path, "build.zig") + if os.path.exists(build_zig_path): + try: + with open(build_zig_path, encoding="utf-8") as f: + content = f.read() + uri = pathlib.Path(build_zig_path).as_uri() + self.server.notify.did_open_text_document( + { + "textDocument": { + "uri": uri, + "languageId": "zig", + "version": 1, + "text": content, + } + } + ) + self.logger.log("Opened build.zig to provide project context to ZLS", logging.INFO) + except Exception as e: + self.logger.log(f"Failed to open build.zig: {e}", logging.WARNING) diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 9b888029..1dbd4f2a 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -238,6 +238,21 @@ class SolidLanguageServer(ABC): ls = BashLanguageServer(config, logger, repository_root_path, solidlsp_settings=solidlsp_settings) + elif config.code_language == Language.ZIG: + from solidlsp.language_servers.zls import ZigLanguageServer + + ls = ZigLanguageServer(config, logger, repository_root_path, solidlsp_settings=solidlsp_settings) + + elif config.code_language == Language.NIX: + from solidlsp.language_servers.nixd_ls import NixLanguageServer + + ls = NixLanguageServer(config, logger, repository_root_path, solidlsp_settings=solidlsp_settings) + + elif config.code_language == Language.LUA: + from solidlsp.language_servers.lua_ls import LuaLanguageServer + + ls = LuaLanguageServer(config, logger, repository_root_path, solidlsp_settings=solidlsp_settings) + else: logger.log(f"Language {config.code_language} is not supported", logging.ERROR) raise SolidLSPException(f"Language {config.code_language} is not supported") diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 4bed832e..45435d44 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -44,6 +44,9 @@ class Language(str, Enum): TERRAFORM = "terraform" SWIFT = "swift" BASH = "bash" + ZIG = "zig" + LUA = "lua" + NIX = "nix" # Experimental or deprecated Language Servers TYPESCRIPT_VTS = "typescript_vts" """Use the typescript language server through the natively bundled vscode extension via https://github.com/yioneko/vtsls""" @@ -109,6 +112,12 @@ class Language(str, Enum): return FilenameMatcher("*.swift") case self.BASH: return FilenameMatcher("*.sh", "*.bash") + case self.ZIG: + return FilenameMatcher("*.zig", "*.zon") + case self.LUA: + return FilenameMatcher("*.lua") + case self.NIX: + return FilenameMatcher("*.nix") case _: raise ValueError(f"Unhandled language: {self}") diff --git a/test/resources/repos/lua/test_repo/.gitignore b/test/resources/repos/lua/test_repo/.gitignore new file mode 100644 index 00000000..30bdcf9e --- /dev/null +++ b/test/resources/repos/lua/test_repo/.gitignore @@ -0,0 +1,13 @@ +# Lua specific +*.luac +.luarocks/ +lua_modules/ +luarocks/ + +# Build artifacts +build/ +dist/ + +# IDE +.vscode/ +.idea/ \ No newline at end of file diff --git a/test/resources/repos/lua/test_repo/main.lua b/test/resources/repos/lua/test_repo/main.lua new file mode 100644 index 00000000..d19d5212 --- /dev/null +++ b/test/resources/repos/lua/test_repo/main.lua @@ -0,0 +1,114 @@ +#!/usr/bin/env lua + +-- main.lua: Entry point for the test application + +local calculator = require("src.calculator") +local utils = require("src.utils") + +local function print_banner() + print("=" .. string.rep("=", 40)) + print(" Lua Test Repository") + print("=" .. string.rep("=", 40)) +end + +local function test_calculator() + print("\nTesting Calculator Module:") + print("5 + 3 =", calculator.add(5, 3)) + print("10 - 4 =", calculator.subtract(10, 4)) + print("6 * 7 =", calculator.multiply(6, 7)) + print("15 / 3 =", calculator.divide(15, 3)) + print("2^8 =", calculator.power(2, 8)) + print("5! =", calculator.factorial(5)) + + local numbers = {5, 2, 8, 3, 9, 1, 7} + print("Mean of", table.concat(numbers, ", "), "=", calculator.mean(numbers)) + print("Median of", table.concat(numbers, ", "), "=", calculator.median(numbers)) +end + +local function test_utils() + print("\nTesting Utils Module:") + + -- String utilities + print("Trimmed ' hello ' =", "'" .. utils.trim(" hello ") .. "'") + local parts = utils.split("apple,banana,orange", ",") + print("Split 'apple,banana,orange' by ',' =", table.concat(parts, " | ")) + print("'hello' starts with 'he' =", utils.starts_with("hello", "he")) + print("'world' ends with 'ld' =", utils.ends_with("world", "ld")) + + -- Table utilities + local t1 = {a = 1, b = 2} + local t2 = {b = 3, c = 4} + local merged = utils.table_merge(t1, t2) + print("Merged tables: a=" .. (merged.a or "nil") .. + ", b=" .. (merged.b or "nil") .. + ", c=" .. (merged.c or "nil")) + + -- Logger + local logger = utils.Logger:new("TestApp") + logger:info("Application started") + logger:debug("Debug information") + logger:warn("This is a warning") +end + +local function interactive_calculator() + print("\nInteractive Calculator (type 'quit' to exit):") + while true do + io.write("Enter operation (e.g., '5 + 3'): ") + local input = io.read() + + if input == "quit" then + break + end + + -- Simple parser for basic operations + local a, op, b = input:match("(%d+)%s*([%+%-%*/])%s*(%d+)") + if a and op and b then + a = tonumber(a) + b = tonumber(b) + local result + + if op == "+" then + result = calculator.add(a, b) + elseif op == "-" then + result = calculator.subtract(a, b) + elseif op == "*" then + result = calculator.multiply(a, b) + elseif op == "/" then + local success, res = pcall(calculator.divide, a, b) + if success then + result = res + else + print("Error: " .. res) + goto continue + end + end + + print("Result: " .. result) + else + print("Invalid input. Please use format: number operator number") + end + + ::continue:: + end +end + +-- Main execution +local function main(args) + print_banner() + + if #args == 0 then + test_calculator() + test_utils() + elseif args[1] == "interactive" then + interactive_calculator() + elseif args[1] == "test" then + test_calculator() + test_utils() + print("\nAll tests completed!") + else + print("Usage: lua main.lua [interactive|test]") + end +end + +-- Run main function +main(arg or {}) \ No newline at end of file diff --git a/test/resources/repos/lua/test_repo/src/calculator.lua b/test/resources/repos/lua/test_repo/src/calculator.lua new file mode 100644 index 00000000..d23b2587 --- /dev/null +++ b/test/resources/repos/lua/test_repo/src/calculator.lua @@ -0,0 +1,76 @@ +-- calculator.lua: A simple calculator module for testing LSP features + +local calculator = {} + +-- Basic arithmetic operations +function calculator.add(a, b) + return a + b +end + +function calculator.subtract(a, b) + return a - b +end + +function calculator.multiply(a, b) + return a * b +end + +function calculator.divide(a, b) + if b == 0 then + error("Division by zero") + end + return a / b +end + +-- Advanced operations +function calculator.power(base, exponent) + return base ^ exponent +end + +function calculator.factorial(n) + if n < 0 then + error("Factorial is not defined for negative numbers") + elseif n == 0 or n == 1 then + return 1 + else + local result = 1 + for i = 2, n do + result = result * i + end + return result + end +end + +-- Statistics functions +function calculator.mean(numbers) + if #numbers == 0 then + return nil + end + + local sum = 0 + for _, num in ipairs(numbers) do + sum = sum + num + end + return sum / #numbers +end + +function calculator.median(numbers) + if #numbers == 0 then + return nil + end + + local sorted = {} + for i, v in ipairs(numbers) do + sorted[i] = v + end + table.sort(sorted) + + local mid = math.floor(#sorted / 2) + if #sorted % 2 == 0 then + return (sorted[mid] + sorted[mid + 1]) / 2 + else + return sorted[mid + 1] + end +end + +return calculator \ No newline at end of file diff --git a/test/resources/repos/lua/test_repo/src/utils.lua b/test/resources/repos/lua/test_repo/src/utils.lua new file mode 100644 index 00000000..576b0cb2 --- /dev/null +++ b/test/resources/repos/lua/test_repo/src/utils.lua @@ -0,0 +1,120 @@ +-- utils.lua: Utility functions for the test repository + +local utils = {} + +-- String utilities +function utils.trim(s) + return s:match("^%s*(.-)%s*$") +end + +function utils.split(str, delimiter) + local result = {} + local pattern = string.format("([^%s]+)", delimiter) + for match in string.gmatch(str, pattern) do + table.insert(result, match) + end + return result +end + +function utils.starts_with(str, prefix) + return str:sub(1, #prefix) == prefix +end + +function utils.ends_with(str, suffix) + return str:sub(-#suffix) == suffix +end + +-- Table utilities +function utils.deep_copy(orig) + local orig_type = type(orig) + local copy + if orig_type == 'table' then + copy = {} + for orig_key, orig_value in next, orig, nil do + copy[utils.deep_copy(orig_key)] = utils.deep_copy(orig_value) + end + setmetatable(copy, utils.deep_copy(getmetatable(orig))) + else + copy = orig + end + return copy +end + +function utils.table_contains(tbl, value) + for _, v in ipairs(tbl) do + if v == value then + return true + end + end + return false +end + +function utils.table_merge(t1, t2) + local result = {} + for k, v in pairs(t1) do + result[k] = v + end + for k, v in pairs(t2) do + result[k] = v + end + return result +end + +-- File utilities +function utils.read_file(path) + local file = io.open(path, "r") + if not file then + return nil, "Could not open file: " .. path + end + local content = file:read("*all") + file:close() + return content +end + +function utils.write_file(path, content) + local file = io.open(path, "w") + if not file then + return false, "Could not open file for writing: " .. path + end + file:write(content) + file:close() + return true +end + +-- Class-like structure +utils.Logger = {} +utils.Logger.__index = utils.Logger + +function utils.Logger:new(name) + local self = setmetatable({}, utils.Logger) + self.name = name or "default" + self.level = "info" + return self +end + +function utils.Logger:set_level(level) + self.level = level +end + +function utils.Logger:log(message, level) + level = level or self.level + print(string.format("[%s] %s: %s", self.name, level:upper(), message)) +end + +function utils.Logger:debug(message) + self:log(message, "debug") +end + +function utils.Logger:info(message) + self:log(message, "info") +end + +function utils.Logger:warn(message) + self:log(message, "warn") +end + +function utils.Logger:error(message) + self:log(message, "error") +end + +return utils \ No newline at end of file diff --git a/test/resources/repos/lua/test_repo/tests/test_calculator.lua b/test/resources/repos/lua/test_repo/tests/test_calculator.lua new file mode 100644 index 00000000..9f2aaa8d --- /dev/null +++ b/test/resources/repos/lua/test_repo/tests/test_calculator.lua @@ -0,0 +1,93 @@ +-- test_calculator.lua: Unit tests for calculator module + +local calculator = require("src.calculator") + +local function assert_equals(actual, expected, message) + if actual ~= expected then + error(string.format("%s: expected %s, got %s", + message or "Assertion failed", tostring(expected), tostring(actual))) + end +end + +local function assert_error(func, message) + local success = pcall(func) + if success then + error(string.format("%s: expected error but none was thrown", + message or "Assertion failed")) + end +end + +local function test_basic_operations() + print("Testing basic operations...") + assert_equals(calculator.add(2, 3), 5, "Addition test") + assert_equals(calculator.add(-5, 5), 0, "Addition with negative") + assert_equals(calculator.add(0, 0), 0, "Addition with zeros") + + assert_equals(calculator.subtract(10, 3), 7, "Subtraction test") + assert_equals(calculator.subtract(5, 10), -5, "Subtraction negative result") + + assert_equals(calculator.multiply(4, 5), 20, "Multiplication test") + assert_equals(calculator.multiply(-3, 4), -12, "Multiplication with negative") + assert_equals(calculator.multiply(0, 100), 0, "Multiplication with zero") + + assert_equals(calculator.divide(10, 2), 5, "Division test") + assert_equals(calculator.divide(7, 2), 3.5, "Division with decimal result") + assert_error(function() calculator.divide(5, 0) end, "Division by zero") + + print("āœ“ Basic operations tests passed") +end + +local function test_advanced_operations() + print("Testing advanced operations...") + assert_equals(calculator.power(2, 3), 8, "Power test") + assert_equals(calculator.power(5, 0), 1, "Power of zero") + assert_equals(calculator.power(10, -1), 0.1, "Negative exponent") + + assert_equals(calculator.factorial(0), 1, "Factorial of 0") + assert_equals(calculator.factorial(1), 1, "Factorial of 1") + assert_equals(calculator.factorial(5), 120, "Factorial of 5") + assert_equals(calculator.factorial(10), 3628800, "Factorial of 10") + assert_error(function() calculator.factorial(-1) end, "Factorial of negative") + + print("āœ“ Advanced operations tests passed") +end + +local function test_statistics() + print("Testing statistics functions...") + + -- Mean tests + assert_equals(calculator.mean({1, 2, 3, 4, 5}), 3, "Mean of sequential numbers") + assert_equals(calculator.mean({10}), 10, "Mean of single number") + assert_equals(calculator.mean({-5, 5}), 0, "Mean with negatives") + assert_equals(calculator.mean({}), nil, "Mean of empty array") + + -- Median tests + assert_equals(calculator.median({1, 2, 3, 4, 5}), 3, "Median of odd count") + assert_equals(calculator.median({1, 2, 3, 4}), 2.5, "Median of even count") + assert_equals(calculator.median({5, 1, 3, 2, 4}), 3, "Median of unsorted") + assert_equals(calculator.median({7}), 7, "Median of single number") + assert_equals(calculator.median({}), nil, "Median of empty array") + + print("āœ“ Statistics tests passed") +end + +-- Run all tests +local function run_all_tests() + print("Running calculator tests...\n") + test_basic_operations() + test_advanced_operations() + test_statistics() + print("\nāœ… All calculator tests passed!") +end + +-- Execute tests if run directly +if arg and arg[0] and arg[0]:match("test_calculator%.lua$") then + run_all_tests() +end + +return { + run_all_tests = run_all_tests, + test_basic_operations = test_basic_operations, + test_advanced_operations = test_advanced_operations, + test_statistics = test_statistics +} \ No newline at end of file diff --git a/test/resources/repos/nix/test_repo/.gitignore b/test/resources/repos/nix/test_repo/.gitignore new file mode 100644 index 00000000..1ce154de --- /dev/null +++ b/test/resources/repos/nix/test_repo/.gitignore @@ -0,0 +1,11 @@ +# Nix specific +result +result-* +.direnv/ + +# Build artifacts +*.drv + +# IDE +.vscode/ +.idea/ \ No newline at end of file diff --git a/test/resources/repos/nix/test_repo/default.nix b/test/resources/repos/nix/test_repo/default.nix new file mode 100644 index 00000000..fddb1d6c --- /dev/null +++ b/test/resources/repos/nix/test_repo/default.nix @@ -0,0 +1,182 @@ +# default.nix - Traditional Nix expression for backwards compatibility +{ pkgs ? import {} }: + +let + # Import library functions + lib = pkgs.lib; + stdenv = pkgs.stdenv; + + # Import our custom utilities + utils = import ./lib/utils.nix { inherit lib; }; + + # Custom function to create a greeting + makeGreeting = name: "Hello, ${name}!"; + + # List manipulation functions (using imported utils) + listUtils = { + double = list: map (x: x * 2) list; + sum = list: lib.foldl' (acc: x: acc + x) 0 list; + average = list: + if list == [] + then 0 + else (listUtils.sum list) / (builtins.length list); + # Use function from imported utils + unique = utils.lists.unique; + }; + + # String utilities + stringUtils = rec { + capitalize = str: + let + first = lib.substring 0 1 str; + rest = lib.substring 1 (-1) str; + in + (lib.toUpper first) + rest; + + repeat = n: str: lib.concatStrings (lib.genList (_: str) n); + + padLeft = width: char: str: + let + len = lib.stringLength str; + padding = if len >= width then 0 else width - len; + in + (repeat padding char) + str; + }; + + # Package builder helper + buildSimplePackage = { name, version, script }: + stdenv.mkDerivation { + pname = name; + inherit version; + + phases = [ "installPhase" ]; + + installPhase = '' + mkdir -p $out/bin + cat > $out/bin/${name} << EOF + #!/usr/bin/env bash + ${script} + EOF + chmod +x $out/bin/${name} + ''; + }; + +in rec { + # Export utilities + inherit listUtils stringUtils makeGreeting; + + # Export imported utilities directly + inherit (utils) math strings; + + # Example packages + hello = buildSimplePackage { + name = "hello"; + version = "1.0"; + script = '' + echo "${makeGreeting "World"}" + ''; + }; + + calculator = buildSimplePackage { + name = "calculator"; + version = "0.1"; + script = '' + if [ $# -ne 3 ]; then + echo "Usage: calculator " + exit 1 + fi + + case $2 in + +) echo $(($1 + $3)) ;; + -) echo $(($1 - $3)) ;; + x) echo $(($1 * $3)) ;; + /) echo $(($1 / $3)) ;; + *) echo "Unknown operator: $2" ;; + esac + ''; + }; + + # Environment with multiple packages + devEnv = pkgs.buildEnv { + name = "dev-environment"; + paths = with pkgs; [ + git + vim + bash + hello + calculator + ]; + }; + + # Shell derivation + shell = pkgs.mkShell { + buildInputs = with pkgs; [ + bash + coreutils + findutils + gnugrep + gnused + ]; + + shellHook = '' + echo "Entering Nix shell environment" + echo "Available custom functions: makeGreeting, listUtils, stringUtils" + ''; + }; + + # Configuration example + config = { + system = { + stateVersion = "23.11"; + enable = true; + }; + + services = { + nginx = { + enable = false; + virtualHosts = { + "example.com" = { + root = "/var/www/example"; + locations."/" = { + index = "index.html"; + }; + }; + }; + }; + }; + + users = { + testUser = { + name = "test"; + group = "users"; + home = "/home/test"; + shell = "${pkgs.bash}/bin/bash"; + }; + }; + }; + + # Recursive attribute set example + tree = { + root = { + value = 1; + left = { + value = 2; + left = { value = 4; }; + right = { value = 5; }; + }; + right = { + value = 3; + left = { value = 6; }; + right = { value = 7; }; + }; + }; + + # Tree traversal function + traverse = node: + if node ? left && node ? right + then [ node.value ] ++ (tree.traverse node.left) ++ (tree.traverse node.right) + else if node ? value + then [ node.value ] + else []; + }; +} \ No newline at end of file diff --git a/test/resources/repos/nix/test_repo/flake.nix b/test/resources/repos/nix/test_repo/flake.nix new file mode 100644 index 00000000..415992ad --- /dev/null +++ b/test/resources/repos/nix/test_repo/flake.nix @@ -0,0 +1,146 @@ +{ + description = "Test Nix flake for language server testing"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + + # Import our default.nix for shared logic + defaultNix = import ./default.nix { inherit pkgs; }; + + # Custom derivation for testing + hello-custom = pkgs.stdenv.mkDerivation { + pname = "hello-custom"; + version = "1.0.0"; + + src = ./.; + + buildInputs = with pkgs; [ + bash + coreutils + ]; + + installPhase = '' + mkdir -p $out/bin + cp ${./scripts/hello.sh} $out/bin/hello-custom + chmod +x $out/bin/hello-custom + ''; + + meta = with pkgs.lib; { + description = "A custom hello world script"; + license = licenses.mit; + platforms = platforms.all; + }; + }; + + # Development shell configuration + devShell = pkgs.mkShell { + buildInputs = with pkgs; [ + # Development tools + git + gnumake + gcc + + # Nix tools + nix-prefetch-git + nixpkgs-fmt + nil + + # Languages + python3 + nodejs + rustc + cargo + ]; + + shellHook = '' + echo "Welcome to the Nix development shell!" + echo "Available tools: git, make, gcc, python3, nodejs, rustc" + ''; + }; + + in + { + # Packages + packages = { + default = hello-custom; + inherit hello-custom; + + # Another package for testing + utils = pkgs.stdenv.mkDerivation { + pname = "test-utils"; + version = "0.1.0"; + src = ./.; + + installPhase = '' + mkdir -p $out/share + echo "Utility functions" > $out/share/utils.txt + ''; + }; + }; + + # Apps + apps = { + default = { + type = "app"; + program = "${hello-custom}/bin/hello-custom"; + }; + + hello = { + type = "app"; + program = "${hello-custom}/bin/hello-custom"; + }; + }; + + # Development shells + devShells = { + default = devShell; + + # Minimal shell for testing + minimal = pkgs.mkShell { + buildInputs = with pkgs; [ + bash + coreutils + ]; + }; + }; + + # Overlay + overlays.default = final: prev: { + inherit hello-custom; + }; + + # NixOS module + nixosModules.default = { config, lib, pkgs, ... }: + with lib; + { + options.services.hello-custom = { + enable = mkEnableOption "hello-custom service"; + + message = mkOption { + type = types.str; + default = "Hello from NixOS!"; + description = "Message to display"; + }; + }; + + config = mkIf config.services.hello-custom.enable { + systemd.services.hello-custom = { + description = "Hello Custom Service"; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + ExecStart = "${hello-custom}/bin/hello-custom"; + Type = "oneshot"; + }; + }; + }; + }; + } + ); +} \ No newline at end of file diff --git a/test/resources/repos/nix/test_repo/lib/utils.nix b/test/resources/repos/nix/test_repo/lib/utils.nix new file mode 100644 index 00000000..0d0e3bdb --- /dev/null +++ b/test/resources/repos/nix/test_repo/lib/utils.nix @@ -0,0 +1,165 @@ +# Utility functions library +{ lib }: + +rec { + # Math utilities + math = { + # Calculate factorial + factorial = n: + if n == 0 + then 1 + else n * factorial (n - 1); + + # Calculate fibonacci number + fibonacci = n: + if n <= 1 + then n + else (fibonacci (n - 1)) + (fibonacci (n - 2)); + + # Check if number is prime + isPrime = n: + let + checkDivisible = i: + if i * i > n then true + else if lib.mod n i == 0 then false + else checkDivisible (i + 1); + in + if n <= 1 then false + else if n <= 3 then true + else checkDivisible 2; + + # Greatest common divisor + gcd = a: b: + if b == 0 + then a + else gcd b (lib.mod a b); + }; + + # String manipulation + strings = { + # Reverse a string + reverse = str: + let + len = lib.stringLength str; + chars = lib.genList (i: lib.substring (len - i - 1) 1 str) len; + in + lib.concatStrings chars; + + # Check if string is palindrome + isPalindrome = str: + str == strings.reverse str; + + # Convert to camelCase + toCamelCase = str: + let + words = lib.splitString "-" str; + capitalize = w: + if w == "" then "" + else (lib.toUpper (lib.substring 0 1 w)) + (lib.substring 1 (-1) w); + capitalizedWords = lib.tail (map capitalize words); + in + (lib.head words) + (lib.concatStrings capitalizedWords); + + # Convert to snake_case + toSnakeCase = str: + lib.replaceStrings ["-"] ["_"] (lib.toLower str); + }; + + # List operations + lists = { + # Get unique elements + unique = list: + lib.foldl' (acc: x: + if lib.elem x acc + then acc + else acc ++ [x] + ) [] list; + + # Zip two lists + zip = list1: list2: + let + len1 = lib.length list1; + len2 = lib.length list2; + minLen = if len1 < len2 then len1 else len2; + in + lib.genList (i: { + fst = lib.elemAt list1 i; + snd = lib.elemAt list2 i; + }) minLen; + + # Flatten nested list + flatten = list: + lib.foldl' (acc: x: + if builtins.isList x + then acc ++ (flatten x) + else acc ++ [x] + ) [] list; + + # Partition list by predicate + partition = pred: list: + lib.foldl' (acc: x: + if pred x + then { yes = acc.yes ++ [x]; no = acc.no; } + else { yes = acc.yes; no = acc.no ++ [x]; } + ) { yes = []; no = []; } list; + }; + + # Attribute set operations + attrs = { + # Deep merge two attribute sets + deepMerge = attr1: attr2: + lib.recursiveUpdate attr1 attr2; + + # Filter attributes by predicate + filterAttrs = pred: attrs: + lib.filterAttrs pred attrs; + + # Map over attribute values + mapValues = f: attrs: + lib.mapAttrs (name: value: f value) attrs; + + # Get nested attribute safely + getAttrPath = path: default: attrs: + lib.attrByPath path default attrs; + }; + + # File system utilities + files = { + # Read JSON file + readJSON = path: + builtins.fromJSON (builtins.readFile path); + + # Read TOML file + readTOML = path: + builtins.fromTOML (builtins.readFile path); + + # Check if path exists + pathExists = path: + builtins.pathExists path; + + # Get file type + getFileType = path: + let + type = builtins.readFileType path; + in + if type == "directory" then "dir" + else if type == "regular" then "file" + else if type == "symlink" then "link" + else "unknown"; + }; + + # Validation utilities + validate = { + # Check if value is email + isEmail = str: + builtins.match "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$" str != null; + + # Check if value is URL + isURL = str: + builtins.match "^https?://[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}" str != null; + + # Check if value is valid version + isVersion = str: + builtins.match "^[0-9]+\\.[0-9]+\\.[0-9]+$" str != null; + }; +} \ No newline at end of file diff --git a/test/resources/repos/nix/test_repo/modules/example.nix b/test/resources/repos/nix/test_repo/modules/example.nix new file mode 100644 index 00000000..744b673b --- /dev/null +++ b/test/resources/repos/nix/test_repo/modules/example.nix @@ -0,0 +1,145 @@ +# Example NixOS module +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.example; + + # Helper function to generate config file + generateConfig = settings: '' + # Generated configuration + ${lib.concatStringsSep "\n" (lib.mapAttrsToList (k: v: "${k} = ${toString v}") settings)} + ''; + +in { + # Module options + options = { + services.example = { + enable = mkEnableOption "example service"; + + package = mkOption { + type = types.package; + default = pkgs.hello; + description = "Package to use for the service"; + }; + + port = mkOption { + type = types.port; + default = 8080; + description = "Port to listen on"; + }; + + host = mkOption { + type = types.str; + default = "localhost"; + description = "Host to bind to"; + }; + + workers = mkOption { + type = types.int; + default = 4; + description = "Number of worker processes"; + }; + + settings = mkOption { + type = types.attrsOf types.anything; + default = {}; + description = "Additional settings"; + }; + + users = mkOption { + type = types.listOf types.str; + default = []; + description = "List of users with access"; + }; + + database = { + enable = mkOption { + type = types.bool; + default = false; + description = "Enable database support"; + }; + + type = mkOption { + type = types.enum [ "postgresql" "mysql" "sqlite" ]; + default = "sqlite"; + description = "Database type"; + }; + + host = mkOption { + type = types.str; + default = "localhost"; + description = "Database host"; + }; + + name = mkOption { + type = types.str; + default = "example"; + description = "Database name"; + }; + }; + }; + }; + + # Module configuration + config = mkIf cfg.enable { + # System packages + environment.systemPackages = [ cfg.package ]; + + # Systemd service + systemd.services.example = { + description = "Example Service"; + after = [ "network.target" ] ++ (optional cfg.database.enable "postgresql.service"); + wantedBy = [ "multi-user.target" ]; + + serviceConfig = { + Type = "simple"; + User = "example"; + Group = "example"; + ExecStart = "${cfg.package}/bin/example --port ${toString cfg.port} --host ${cfg.host}"; + Restart = "on-failure"; + RestartSec = 5; + + # Security hardening + PrivateTmp = true; + ProtectSystem = "strict"; + ProtectHome = true; + NoNewPrivileges = true; + }; + + environment = { + EXAMPLE_WORKERS = toString cfg.workers; + EXAMPLE_CONFIG = generateConfig cfg.settings; + } // optionalAttrs cfg.database.enable { + DATABASE_TYPE = cfg.database.type; + DATABASE_HOST = cfg.database.host; + DATABASE_NAME = cfg.database.name; + }; + }; + + # User and group + users.users.example = { + isSystemUser = true; + group = "example"; + description = "Example service user"; + }; + + users.groups.example = {}; + + # Firewall rules + networking.firewall = mkIf (cfg.host == "0.0.0.0") { + allowedTCPPorts = [ cfg.port ]; + }; + + # Database setup + services.postgresql = mkIf (cfg.database.enable && cfg.database.type == "postgresql") { + enable = true; + ensureDatabases = [ cfg.database.name ]; + ensureUsers = [{ + name = "example"; + ensureDBOwnership = true; + }]; + }; + }; +} \ No newline at end of file diff --git a/test/resources/repos/nix/test_repo/scripts/hello.sh b/test/resources/repos/nix/test_repo/scripts/hello.sh new file mode 100644 index 00000000..c57ba01b --- /dev/null +++ b/test/resources/repos/nix/test_repo/scripts/hello.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +# Simple hello script for testing +echo "Hello from Nix!" \ No newline at end of file diff --git a/test/resources/repos/zig/test_repo/.gitignore b/test/resources/repos/zig/test_repo/.gitignore new file mode 100644 index 00000000..0274a76d --- /dev/null +++ b/test/resources/repos/zig/test_repo/.gitignore @@ -0,0 +1,5 @@ +zig-cache/ +zig-out/ +.zig-cache/ +build/ +dist/ \ No newline at end of file diff --git a/test/resources/repos/zig/test_repo/build.zig b/test/resources/repos/zig/test_repo/build.zig new file mode 100644 index 00000000..da7317d0 --- /dev/null +++ b/test/resources/repos/zig/test_repo/build.zig @@ -0,0 +1,31 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const exe = b.addExecutable(.{ + .name = "test_repo", + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }); + + b.installArtifact(exe); + + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + + const run_step = b.step("run", "Run the app"); + run_step.dependOn(&run_cmd.step); + + const lib_tests = b.addTest(.{ + .root_source_file = b.path("src/calculator.zig"), + .target = target, + .optimize = optimize, + }); + + const run_lib_tests = b.addRunArtifact(lib_tests); + const test_step = b.step("test", "Run unit tests"); + test_step.dependOn(&run_lib_tests.step); +} \ No newline at end of file diff --git a/test/resources/repos/zig/test_repo/src/calculator.zig b/test/resources/repos/zig/test_repo/src/calculator.zig new file mode 100644 index 00000000..289ccf1c --- /dev/null +++ b/test/resources/repos/zig/test_repo/src/calculator.zig @@ -0,0 +1,66 @@ +const std = @import("std"); + +pub const CalculatorError = error{ + DivisionByZero, + Overflow, +}; + +pub const Calculator = struct { + const Self = @This(); + + pub fn init() Self { + return .{}; + } + + pub fn add(self: Self, a: i32, b: i32) i32 { + _ = self; + return a + b; + } + + pub fn subtract(self: Self, a: i32, b: i32) i32 { + _ = self; + return a - b; + } + + pub fn multiply(self: Self, a: i32, b: i32) i32 { + _ = self; + return a * b; + } + + pub fn divide(self: Self, a: i32, b: i32) !f64 { + _ = self; + if (b == 0) { + return CalculatorError.DivisionByZero; + } + return @as(f64, @floatFromInt(a)) / @as(f64, @floatFromInt(b)); + } + + pub fn power(self: Self, base: i32, exponent: u32) i64 { + _ = self; + return std.math.pow(i64, base, exponent); + } +}; + +test "Calculator add" { + const calc = Calculator.init(); + try std.testing.expectEqual(@as(i32, 7), calc.add(3, 4)); + try std.testing.expectEqual(@as(i32, 0), calc.add(-5, 5)); +} + +test "Calculator subtract" { + const calc = Calculator.init(); + try std.testing.expectEqual(@as(i32, -1), calc.subtract(3, 4)); + try std.testing.expectEqual(@as(i32, 10), calc.subtract(15, 5)); +} + +test "Calculator multiply" { + const calc = Calculator.init(); + try std.testing.expectEqual(@as(i32, 12), calc.multiply(3, 4)); + try std.testing.expectEqual(@as(i32, -25), calc.multiply(-5, 5)); +} + +test "Calculator divide" { + const calc = Calculator.init(); + try std.testing.expectEqual(@as(f64, 2.0), try calc.divide(10, 5)); + try std.testing.expectError(CalculatorError.DivisionByZero, calc.divide(10, 0)); +} \ No newline at end of file diff --git a/test/resources/repos/zig/test_repo/src/main.zig b/test/resources/repos/zig/test_repo/src/main.zig new file mode 100644 index 00000000..326b2cd0 --- /dev/null +++ b/test/resources/repos/zig/test_repo/src/main.zig @@ -0,0 +1,32 @@ +const std = @import("std"); +const calculator = @import("calculator.zig"); +const math_utils = @import("math_utils.zig"); + +pub fn main() !void { + const stdout = std.io.getStdOut().writer(); + + const calc = calculator.Calculator.init(); + + const sum = calc.add(10, 5); + const diff = calc.subtract(10, 5); + const prod = calc.multiply(10, 5); + const quot = calc.divide(10, 5) catch |err| { + try stdout.print("Division error: {}\n", .{err}); + return; + }; + + try stdout.print("10 + 5 = {}\n", .{sum}); + try stdout.print("10 - 5 = {}\n", .{diff}); + try stdout.print("10 * 5 = {}\n", .{prod}); + try stdout.print("10 / 5 = {}\n", .{quot}); + + const factorial_result = math_utils.factorial(5); + try stdout.print("5! = {}\n", .{factorial_result}); + + const is_prime = math_utils.isPrime(17); + try stdout.print("Is 17 prime? {}\n", .{is_prime}); +} + +pub fn greeting(name: []const u8) []const u8 { + return std.fmt.allocPrint(std.heap.page_allocator, "Hello, {s}!", .{name}) catch "Hello!"; +} \ No newline at end of file diff --git a/test/resources/repos/zig/test_repo/src/math_utils.zig b/test/resources/repos/zig/test_repo/src/math_utils.zig new file mode 100644 index 00000000..f214d72e --- /dev/null +++ b/test/resources/repos/zig/test_repo/src/math_utils.zig @@ -0,0 +1,65 @@ +const std = @import("std"); + +pub fn factorial(n: u32) u64 { + if (n == 0 or n == 1) { + return 1; + } + var result: u64 = 1; + var i: u32 = 2; + while (i <= n) : (i += 1) { + result *= i; + } + return result; +} + +pub fn isPrime(n: u32) bool { + if (n <= 1) return false; + if (n <= 3) return true; + if (n % 2 == 0 or n % 3 == 0) return false; + + var i: u32 = 5; + while (i * i <= n) : (i += 6) { + if (n % i == 0 or n % (i + 2) == 0) { + return false; + } + } + return true; +} + +pub fn gcd(a: u32, b: u32) u32 { + var x = a; + var y = b; + while (y != 0) { + const temp = y; + y = x % y; + x = temp; + } + return x; +} + +pub fn lcm(a: u32, b: u32) u32 { + return (a * b) / gcd(a, b); +} + +test "factorial" { + try std.testing.expectEqual(@as(u64, 1), factorial(0)); + try std.testing.expectEqual(@as(u64, 1), factorial(1)); + try std.testing.expectEqual(@as(u64, 120), factorial(5)); + try std.testing.expectEqual(@as(u64, 3628800), factorial(10)); +} + +test "isPrime" { + try std.testing.expect(!isPrime(0)); + try std.testing.expect(!isPrime(1)); + try std.testing.expect(isPrime(2)); + try std.testing.expect(isPrime(3)); + try std.testing.expect(!isPrime(4)); + try std.testing.expect(isPrime(17)); + try std.testing.expect(!isPrime(100)); +} + +test "gcd and lcm" { + try std.testing.expectEqual(@as(u32, 6), gcd(12, 18)); + try std.testing.expectEqual(@as(u32, 1), gcd(17, 19)); + try std.testing.expectEqual(@as(u32, 36), lcm(12, 18)); +} \ No newline at end of file diff --git a/test/resources/repos/zig/test_repo/zls.json b/test/resources/repos/zig/test_repo/zls.json new file mode 100644 index 00000000..71054e7a --- /dev/null +++ b/test/resources/repos/zig/test_repo/zls.json @@ -0,0 +1,12 @@ +{ + "enable_build_on_save": true, + "build_on_save_args": ["build"], + "enable_autofix": false, + "semantic_tokens": "full", + "enable_inlay_hints": true, + "inlay_hints_show_builtin": true, + "inlay_hints_exclude_single_argument": true, + "inlay_hints_show_parameter_name": true, + "skip_std_references": false, + "max_detail_length": 1048576 +} diff --git a/test/solidlsp/lua/test_lua_basic.py b/test/solidlsp/lua/test_lua_basic.py new file mode 100644 index 00000000..66fa2074 --- /dev/null +++ b/test/solidlsp/lua/test_lua_basic.py @@ -0,0 +1,255 @@ +""" +Tests for the Lua language server implementation. + +These tests validate symbol finding and cross-file reference capabilities +for Lua modules and functions. +""" + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_types import SymbolKind + + +@pytest.mark.lua +class TestLuaLanguageServer: + """Test Lua language server symbol finding and cross-file references.""" + + @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) + def test_find_symbols_in_calculator(self, language_server: SolidLanguageServer) -> None: + """Test finding specific functions in calculator.lua.""" + symbols = language_server.request_document_symbols("src/calculator.lua") + + assert symbols is not None + assert len(symbols) > 0 + + # Extract function names from the returned structure + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + function_names = set() + for symbol in symbol_list: + if isinstance(symbol, dict): + name = symbol.get("name", "") + # Handle both plain names and module-prefixed names + if "." in name: + name = name.split(".")[-1] + if symbol.get("kind") == SymbolKind.Function: + function_names.add(name) + + # Verify exact calculator functions exist + expected_functions = {"add", "subtract", "multiply", "divide", "factorial"} + found_functions = function_names & expected_functions + assert found_functions == expected_functions, f"Expected exactly {expected_functions}, found {found_functions}" + + # Verify specific functions + assert "add" in function_names, "add function not found" + assert "multiply" in function_names, "multiply function not found" + assert "factorial" in function_names, "factorial function not found" + + @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) + def test_find_symbols_in_utils(self, language_server: SolidLanguageServer) -> None: + """Test finding specific functions in utils.lua.""" + symbols = language_server.request_document_symbols("src/utils.lua") + + assert symbols is not None + assert len(symbols) > 0 + + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + function_names = set() + all_symbols = set() + + for symbol in symbol_list: + if isinstance(symbol, dict): + name = symbol.get("name", "") + all_symbols.add(name) + # Handle both plain names and module-prefixed names + if "." in name: + name = name.split(".")[-1] + if symbol.get("kind") == SymbolKind.Function: + function_names.add(name) + + # Verify exact string utility functions + expected_utils = {"trim", "split", "starts_with", "ends_with"} + found_utils = function_names & expected_utils + assert found_utils == expected_utils, f"Expected exactly {expected_utils}, found {found_utils}" + + # Verify exact table utility functions + table_utils = {"deep_copy", "table_contains", "table_merge"} + found_table_utils = function_names & table_utils + assert found_table_utils == table_utils, f"Expected exactly {table_utils}, found {found_table_utils}" + + # Check for Logger class/table + assert "Logger" in all_symbols or any("Logger" in s for s in all_symbols), "Logger not found in symbols" + + @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) + def test_find_symbols_in_main(self, language_server: SolidLanguageServer) -> None: + """Test finding functions in main.lua.""" + symbols = language_server.request_document_symbols("main.lua") + + assert symbols is not None + assert len(symbols) > 0 + + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + function_names = set() + + for symbol in symbol_list: + if isinstance(symbol, dict) and symbol.get("kind") == SymbolKind.Function: + function_names.add(symbol.get("name", "")) + + # Verify exact main functions exist + expected_funcs = {"print_banner", "test_calculator", "test_utils"} + found_funcs = function_names & expected_funcs + assert found_funcs == expected_funcs, f"Expected exactly {expected_funcs}, found {found_funcs}" + + assert "test_calculator" in function_names, "test_calculator function not found" + assert "test_utils" in function_names, "test_utils function not found" + + @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) + def test_cross_file_references_calculator_add(self, language_server: SolidLanguageServer) -> None: + """Test finding cross-file references to calculator.add function.""" + symbols = language_server.request_document_symbols("src/calculator.lua") + + assert symbols is not None + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + + # Find the add function + add_symbol = None + for sym in symbol_list: + if isinstance(sym, dict): + name = sym.get("name", "") + if "add" in name or name == "add": + add_symbol = sym + break + + assert add_symbol is not None, "add function not found in calculator.lua" + + # Get references to the add function + range_info = add_symbol.get("selectionRange", add_symbol.get("range")) + assert range_info is not None, "add function has no range information" + + range_start = range_info["start"] + refs = language_server.request_references("src/calculator.lua", range_start["line"], range_start["character"]) + + assert refs is not None + assert isinstance(refs, list) + # add function appears in: main.lua (lines 16, 71), test_calculator.lua (lines 22, 23, 24) + # Note: The declaration itself may or may not be included as a reference + assert len(refs) >= 5, f"Should find at least 5 references to calculator.add, found {len(refs)}" + + # Verify exact reference locations + ref_files = {} + for ref in refs: + filename = ref.get("uri", "").split("/")[-1] + if filename not in ref_files: + ref_files[filename] = [] + ref_files[filename].append(ref["range"]["start"]["line"]) + + # The declaration may or may not be included + if "calculator.lua" in ref_files: + assert ( + 5 in ref_files["calculator.lua"] + ), f"If declaration is included, it should be at line 6 (0-indexed: 5), found at {ref_files['calculator.lua']}" + + # Check main.lua has usages + assert "main.lua" in ref_files, "Should find add usages in main.lua" + assert ( + 15 in ref_files["main.lua"] or 70 in ref_files["main.lua"] + ), f"Should find add usage in main.lua, found at lines {ref_files.get('main.lua', [])}" + + # Check for cross-file references from main.lua + main_refs = [ref for ref in refs if "main.lua" in ref.get("uri", "")] + assert len(main_refs) > 0, "calculator.add should be called in main.lua" + + @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) + def test_cross_file_references_utils_trim(self, language_server: SolidLanguageServer) -> None: + """Test finding cross-file references to utils.trim function.""" + symbols = language_server.request_document_symbols("src/utils.lua") + + assert symbols is not None + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + + # Find the trim function + trim_symbol = None + for sym in symbol_list: + if isinstance(sym, dict): + name = sym.get("name", "") + if "trim" in name or name == "trim": + trim_symbol = sym + break + + assert trim_symbol is not None, "trim function not found in utils.lua" + + # Get references to the trim function + range_info = trim_symbol.get("selectionRange", trim_symbol.get("range")) + assert range_info is not None, "trim function has no range information" + + range_start = range_info["start"] + refs = language_server.request_references("src/utils.lua", range_start["line"], range_start["character"]) + + assert refs is not None + assert isinstance(refs, list) + # trim function appears in: usage (line 32 in main.lua) + # Note: The declaration itself may or may not be included as a reference + assert len(refs) >= 1, f"Should find at least 1 reference to utils.trim, found {len(refs)}" + + # Verify exact reference locations + ref_files = {} + for ref in refs: + filename = ref.get("uri", "").split("/")[-1] + if filename not in ref_files: + ref_files[filename] = [] + ref_files[filename].append(ref["range"]["start"]["line"]) + + # The declaration may or may not be included + if "utils.lua" in ref_files: + assert ( + 5 in ref_files["utils.lua"] + ), f"If declaration is included, it should be at line 6 (0-indexed: 5), found at {ref_files['utils.lua']}" + + # Check main.lua has usage + assert "main.lua" in ref_files, "Should find trim usage in main.lua" + assert ( + 31 in ref_files["main.lua"] + ), f"Should find trim usage at line 32 (0-indexed: 31) in main.lua, found at lines {ref_files.get('main.lua', [])}" + + # Check for cross-file references from main.lua + main_refs = [ref for ref in refs if "main.lua" in ref.get("uri", "")] + assert len(main_refs) > 0, "utils.trim should be called in main.lua" + + @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) + def test_hover_information(self, language_server: SolidLanguageServer) -> None: + """Test hover information for symbols.""" + # Get hover info for a function + hover_info = language_server.request_hover("src/calculator.lua", 5, 10) # Position near add function + + assert hover_info is not None, "Should provide hover information" + + # Hover info could be a dict with 'contents' or a string + if isinstance(hover_info, dict): + assert "contents" in hover_info or "value" in hover_info, "Hover should have contents" + + @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) + def test_full_symbol_tree(self, language_server: SolidLanguageServer) -> None: + """Test that full symbol tree is not empty.""" + symbols = language_server.request_full_symbol_tree() + + assert symbols is not None + assert len(symbols) > 0, "Symbol tree should not be empty" + + # The tree should have at least one root node + root = symbols[0] + assert isinstance(root, dict), "Root should be a dict" + assert "name" in root, "Root should have a name" + + @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) + def test_references_between_test_and_source(self, language_server: SolidLanguageServer) -> None: + """Test finding references from test files to source files.""" + # Check if test_calculator.lua references calculator module + test_symbols = language_server.request_document_symbols("tests/test_calculator.lua") + + assert test_symbols is not None + assert len(test_symbols) > 0 + + # The test file should have some content that references calculator + symbol_list = test_symbols[0] if isinstance(test_symbols, tuple) else test_symbols + assert len(symbol_list) > 0, "test_calculator.lua should have symbols" diff --git a/test/solidlsp/nix/test_nix_basic.py b/test/solidlsp/nix/test_nix_basic.py new file mode 100644 index 00000000..2a0c37df --- /dev/null +++ b/test/solidlsp/nix/test_nix_basic.py @@ -0,0 +1,218 @@ +""" +Tests for the Nix language server implementation using nixd. + +These tests validate symbol finding and cross-file reference capabilities for Nix expressions. +""" + +import platform + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language + +# Skip all Nix tests on Windows as Nix doesn't support Windows +pytestmark = pytest.mark.skipif(platform.system() == "Windows", reason="Nix and nil are not available on Windows") + + +@pytest.mark.nix +class TestNixLanguageServer: + """Test Nix language server symbol finding capabilities.""" + + @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) + def test_find_symbols_in_default_nix(self, language_server: SolidLanguageServer) -> None: + """Test finding specific symbols in default.nix.""" + symbols = language_server.request_document_symbols("default.nix") + + assert symbols is not None + assert len(symbols) > 0 + + # Extract symbol names from the returned structure + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + symbol_names = {sym.get("name") for sym in symbol_list if isinstance(sym, dict)} + + # Verify specific function exists + assert "makeGreeting" in symbol_names, "makeGreeting function not found" + + # Verify exact attribute sets are found + expected_attrs = {"listUtils", "stringUtils"} + found_attrs = symbol_names & expected_attrs + assert found_attrs == expected_attrs, f"Expected exactly {expected_attrs}, found {found_attrs}" + + @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) + def test_find_symbols_in_utils(self, language_server: SolidLanguageServer) -> None: + """Test finding symbols in lib/utils.nix.""" + symbols = language_server.request_document_symbols("lib/utils.nix") + + assert symbols is not None + assert len(symbols) > 0 + + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + symbol_names = {sym.get("name") for sym in symbol_list if isinstance(sym, dict)} + + # Verify exact utility modules are found + expected_modules = {"math", "strings", "lists", "attrs"} + found_modules = symbol_names & expected_modules + assert found_modules == expected_modules, f"Expected exactly {expected_modules}, found {found_modules}" + + @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) + def test_find_symbols_in_flake(self, language_server: SolidLanguageServer) -> None: + """Test finding symbols in flake.nix.""" + symbols = language_server.request_document_symbols("flake.nix") + + assert symbols is not None + assert len(symbols) > 0 + + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + symbol_names = {sym.get("name") for sym in symbol_list if isinstance(sym, dict)} + + # Flakes must have either inputs or outputs + assert "inputs" in symbol_names or "outputs" in symbol_names, "Flake must have inputs or outputs" + + @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) + def test_find_symbols_in_module(self, language_server: SolidLanguageServer) -> None: + """Test finding symbols in a NixOS module.""" + symbols = language_server.request_document_symbols("modules/example.nix") + + assert symbols is not None + assert len(symbols) > 0 + + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + symbol_names = {sym.get("name") for sym in symbol_list if isinstance(sym, dict)} + + # NixOS modules must have either options or config + assert "options" in symbol_names or "config" in symbol_names, "Module must have options or config" + + @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) + def test_find_references_within_file(self, language_server: SolidLanguageServer) -> None: + """Test finding references within the same file.""" + symbols = language_server.request_document_symbols("default.nix") + + assert symbols is not None + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + + # Find makeGreeting function + greeting_symbol = None + for sym in symbol_list: + if sym.get("name") == "makeGreeting": + greeting_symbol = sym + break + + assert greeting_symbol is not None, "makeGreeting function not found" + assert "range" in greeting_symbol, "Symbol must have range information" + + range_start = greeting_symbol["range"]["start"] + refs = language_server.request_references("default.nix", range_start["line"], range_start["character"]) + + assert refs is not None + assert isinstance(refs, list) + # nixd finds at least the inherit statement (line 66) + assert len(refs) >= 1, f"Should find at least 1 reference to makeGreeting, found {len(refs)}" + + # Verify makeGreeting is referenced at expected locations + if refs: + ref_lines = sorted([ref["range"]["start"]["line"] for ref in refs]) + # Check if we found the inherit (line 66, 0-indexed: 65) + assert 65 in ref_lines, f"Should find makeGreeting inherit at line 66, found at lines {[l+1 for l in ref_lines]}" + + @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) + def test_hover_information(self, language_server: SolidLanguageServer) -> None: + """Test hover information for symbols.""" + # Get hover info for makeGreeting function + hover_info = language_server.request_hover("default.nix", 9, 5) # Position near makeGreeting + + assert hover_info is not None, "Should provide hover information" + + if isinstance(hover_info, dict) and len(hover_info) > 0: + # If hover info is provided, it should have proper structure + assert "contents" in hover_info or "value" in hover_info, "Hover should have contents or value" + + @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) + def test_cross_file_references_utils_import(self, language_server: SolidLanguageServer) -> None: + """Test finding cross-file references for imported utils.""" + # Find references to 'utils' which is imported in default.nix from lib/utils.nix + # Line 10 in default.nix: utils = import ./lib/utils.nix { inherit lib; }; + refs = language_server.request_references("default.nix", 9, 2) # Position of 'utils' + + assert refs is not None + assert isinstance(refs, list) + + # Should find references within default.nix where utils is used + default_refs = [ref for ref in refs if "default.nix" in ref.get("uri", "")] + # utils is: imported (line 10), used in listUtils.unique (line 24), inherited in exports (line 69) + assert len(default_refs) >= 2, f"Should find at least 2 references to utils in default.nix, found {len(default_refs)}" + + # Verify utils is referenced at expected locations (0-indexed) + if default_refs: + ref_lines = sorted([ref["range"]["start"]["line"] for ref in default_refs]) + # Check for key references - at least the import (line 10) or usage (line 24) + assert ( + 9 in ref_lines or 23 in ref_lines + ), f"Should find utils import or usage, found references at lines {[l+1 for l in ref_lines]}" + + @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) + def test_verify_imports_exist(self, language_server: SolidLanguageServer) -> None: + """Verify that our test files have proper imports set up.""" + # Verify that default.nix imports utils from lib/utils.nix + symbols = language_server.request_document_symbols("default.nix") + + assert symbols is not None + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + + # Check that makeGreeting exists (defined in default.nix) + symbol_names = {sym.get("name") for sym in symbol_list if isinstance(sym, dict)} + assert "makeGreeting" in symbol_names, "makeGreeting should be found in default.nix" + + # Verify lib/utils.nix has the expected structure + utils_symbols = language_server.request_document_symbols("lib/utils.nix") + assert utils_symbols is not None + utils_list = utils_symbols[0] if isinstance(utils_symbols, tuple) else utils_symbols + utils_names = {sym.get("name") for sym in utils_list if isinstance(sym, dict)} + + # Verify key functions exist in utils + assert "math" in utils_names, "math should be found in lib/utils.nix" + assert "strings" in utils_names, "strings should be found in lib/utils.nix" + + @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) + def test_go_to_definition_cross_file(self, language_server: SolidLanguageServer) -> None: + """Test go-to-definition from default.nix to lib/utils.nix.""" + # Line 24 in default.nix: unique = utils.lists.unique; + # Test go-to-definition for 'utils' + definitions = language_server.request_definition("default.nix", 23, 14) # Position of 'utils' + + assert definitions is not None + assert isinstance(definitions, list) + + if len(definitions) > 0: + # Should point to the import statement or utils.nix + assert any( + "utils" in def_item.get("uri", "") or "default.nix" in def_item.get("uri", "") for def_item in definitions + ), "Definition should relate to utils import or utils.nix file" + + @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) + def test_definition_navigation_in_flake(self, language_server: SolidLanguageServer) -> None: + """Test definition navigation in flake.nix.""" + # Test that we can navigate to definitions within flake.nix + # Line 69: default = hello-custom; + definitions = language_server.request_definition("flake.nix", 68, 20) # Position of 'hello-custom' + + assert definitions is not None + assert isinstance(definitions, list) + # nixd should find the definition of hello-custom in the same file + if len(definitions) > 0: + assert any( + "flake.nix" in def_item.get("uri", "") for def_item in definitions + ), "Should find hello-custom definition in flake.nix" + + @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) + def test_full_symbol_tree(self, language_server: SolidLanguageServer) -> None: + """Test that full symbol tree is not empty.""" + symbols = language_server.request_full_symbol_tree() + + assert symbols is not None + assert len(symbols) > 0, "Symbol tree should not be empty" + + # The tree should have at least one root node + root = symbols[0] + assert isinstance(root, dict), "Root should be a dict" + assert "name" in root, "Root should have a name" diff --git a/test/solidlsp/zig/test_zig_basic.py b/test/solidlsp/zig/test_zig_basic.py new file mode 100644 index 00000000..80f8fcc1 --- /dev/null +++ b/test/solidlsp/zig/test_zig_basic.py @@ -0,0 +1,343 @@ +""" +Basic integration tests for Zig language server functionality. + +These tests validate symbol finding and navigation capabilities using the Zig Language Server (ZLS). +Note: ZLS requires files to be open in the editor to find cross-file references (performance optimization). +""" + +import os +import sys + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_types import SymbolKind + + +@pytest.mark.zig +@pytest.mark.skipif( + sys.platform == "win32", reason="ZLS is disabled on Windows - cross-file references don't work reliably. Reason unknown." +) +class TestZigLanguageServer: + """Test Zig language server symbol finding and navigation capabilities. + + NOTE: All tests are skipped on Windows as ZLS is disabled on that platform + due to unreliable cross-file reference functionality. Reason unknown. + """ + + @pytest.mark.parametrize("language_server", [Language.ZIG], indirect=True) + def test_find_symbols_in_main(self, language_server: SolidLanguageServer) -> None: + """Test finding specific symbols in main.zig.""" + file_path = os.path.join("src", "main.zig") + symbols = language_server.request_document_symbols(file_path) + + assert symbols is not None + assert len(symbols) > 0 + + # Extract symbol names from the returned structure + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + symbol_names = {sym.get("name") for sym in symbol_list if isinstance(sym, dict)} + + # Verify specific symbols exist + assert "main" in symbol_names, "main function not found" + assert "greeting" in symbol_names, "greeting function not found" + + @pytest.mark.parametrize("language_server", [Language.ZIG], indirect=True) + def test_find_symbols_in_calculator(self, language_server: SolidLanguageServer) -> None: + """Test finding Calculator struct and its methods.""" + file_path = os.path.join("src", "calculator.zig") + symbols = language_server.request_document_symbols(file_path) + + assert symbols is not None + assert len(symbols) > 0 + + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + + # Find Calculator struct + calculator_symbol = None + for sym in symbol_list: + if sym.get("name") == "Calculator": + calculator_symbol = sym + break + + assert calculator_symbol is not None, "Calculator struct not found" + # ZLS may use different symbol kinds for structs (14 = Namespace, 5 = Class, 23 = Struct) + assert calculator_symbol.get("kind") in [ + SymbolKind.Class, + SymbolKind.Struct, + SymbolKind.Namespace, + 5, + 14, + 23, + ], "Calculator should be a struct/class/namespace" + + # Check for Calculator methods (init, add, subtract, etc.) + # Methods might be in children or at the same level + all_symbols = [] + for sym in symbol_list: + all_symbols.append(sym.get("name")) + if "children" in sym: + for child in sym["children"]: + all_symbols.append(child.get("name")) + + # Verify exact calculator methods exist + expected_methods = {"init", "add", "subtract", "multiply", "divide"} + found_methods = set(all_symbols) & expected_methods + assert found_methods == expected_methods, f"Expected exactly {expected_methods}, found: {found_methods}" + + @pytest.mark.parametrize("language_server", [Language.ZIG], indirect=True) + def test_find_symbols_in_math_utils(self, language_server: SolidLanguageServer) -> None: + """Test finding functions in math_utils.zig.""" + file_path = os.path.join("src", "math_utils.zig") + symbols = language_server.request_document_symbols(file_path) + + assert symbols is not None + assert len(symbols) > 0 + + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + symbol_names = {sym.get("name") for sym in symbol_list if isinstance(sym, dict)} + + # Verify math utility functions exist + assert "factorial" in symbol_names, "factorial function not found" + assert "isPrime" in symbol_names, "isPrime function not found" + + @pytest.mark.parametrize("language_server", [Language.ZIG], indirect=True) + def test_find_references_within_file(self, language_server: SolidLanguageServer) -> None: + """Test finding references within the same file.""" + file_path = os.path.join("src", "calculator.zig") + symbols = language_server.request_document_symbols(file_path) + + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + + # Find Calculator struct + calculator_symbol = None + for sym in symbol_list: + if sym.get("name") == "Calculator": + calculator_symbol = sym + break + + assert calculator_symbol is not None, "Calculator struct not found" + + # Find references to Calculator within the same file + sel_range = calculator_symbol.get("selectionRange", calculator_symbol.get("range")) + assert sel_range is not None, "Calculator symbol has no range information" + + sel_start = sel_range["start"] + refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + + assert refs is not None + assert isinstance(refs, list) + # ZLS finds references within the same file + # Calculator is used in 4 test usages (lines 45, 51, 57, 63) + # Note: ZLS may not include the declaration itself as a reference + assert len(refs) >= 4, f"Should find at least 4 Calculator references within calculator.zig, found {len(refs)}" + + # Verify we found the test usages + ref_lines = sorted([ref["range"]["start"]["line"] for ref in refs]) + test_lines = [44, 50, 56, 62] # 0-indexed: tests at lines 45, 51, 57, 63 + for line in test_lines: + assert line in ref_lines, f"Should find Calculator reference at line {line + 1}, found at lines {[l + 1 for l in ref_lines]}" + + @pytest.mark.parametrize("language_server", [Language.ZIG], indirect=True) + @pytest.mark.skipif( + sys.platform == "win32", reason="ZLS cross-file references don't work reliably on Windows - URI path handling issues" + ) + def test_cross_file_references_with_open_files(self, language_server: SolidLanguageServer) -> None: + """ + Test finding cross-file references with files open. + + ZLS limitation: Cross-file references (textDocument/references) only work when + target files are open. This is a performance optimization in ZLS. + + NOTE: Disabled on Windows as cross-file references cannot be made to work reliably + due to URI path handling differences between Windows and Unix systems. + """ + import time + + # Open the files that contain references to enable cross-file search + with language_server.open_file("build.zig"): + with language_server.open_file(os.path.join("src", "main.zig")): + with language_server.open_file(os.path.join("src", "calculator.zig")): + # Give ZLS a moment to analyze the open files + time.sleep(1) + + # Find Calculator struct + symbols = language_server.request_document_symbols(os.path.join("src", "calculator.zig")) + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + + calculator_symbol = None + for sym in symbol_list: + if sym.get("name") == "Calculator": + calculator_symbol = sym + break + + assert calculator_symbol is not None, "Calculator struct not found" + + sel_range = calculator_symbol.get("selectionRange", calculator_symbol.get("range")) + assert sel_range is not None, "Calculator symbol has no range information" + + # Find references to Calculator + sel_start = sel_range["start"] + refs = language_server.request_references( + os.path.join("src", "calculator.zig"), sel_start["line"], sel_start["character"] + ) + + assert refs is not None + assert isinstance(refs, list) + + # With files open, ZLS should find cross-file references + main_refs = [ref for ref in refs if "main.zig" in ref.get("uri", "")] + + assert len(main_refs) >= 1, f"Should find at least 1 Calculator reference in main.zig, found {len(main_refs)}" + + # Verify exact location in main.zig (line 8, 0-indexed: 7) + main_ref_line = main_refs[0]["range"]["start"]["line"] + assert ( + main_ref_line == 7 + ), f"Calculator reference in main.zig should be at line 8 (0-indexed: 7), found at line {main_ref_line + 1}" + + @pytest.mark.parametrize("language_server", [Language.ZIG], indirect=True) + def test_cross_file_references_within_file(self, language_server: SolidLanguageServer) -> None: + """ + Test that ZLS finds references within the same file. + + Note: ZLS is designed to be lightweight and only analyzes files that are explicitly opened. + Cross-file references require manually opening the relevant files first. + """ + # Find references to Calculator from calculator.zig + file_path = os.path.join("src", "calculator.zig") + symbols = language_server.request_document_symbols(file_path) + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + + calculator_symbol = None + for sym in symbol_list: + if sym.get("name") == "Calculator": + calculator_symbol = sym + break + + assert calculator_symbol is not None, "Calculator struct not found" + + sel_range = calculator_symbol.get("selectionRange", calculator_symbol.get("range")) + assert sel_range is not None, "Calculator symbol has no range information" + + sel_start = sel_range["start"] + refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + + assert refs is not None + assert isinstance(refs, list) + + # ZLS finds references within the same file + # Calculator is used in 4 test usages (lines 45, 51, 57, 63) + # Note: ZLS may not include the declaration itself as a reference + assert len(refs) >= 4, f"Should find at least 4 Calculator references within calculator.zig, found {len(refs)}" + + # Verify we found the test usages + ref_lines = sorted([ref["range"]["start"]["line"] for ref in refs]) + test_lines = [44, 50, 56, 62] # 0-indexed: tests at lines 45, 51, 57, 63 + for line in test_lines: + assert line in ref_lines, f"Should find Calculator reference at line {line + 1}, found at lines {[l + 1 for l in ref_lines]}" + + @pytest.mark.parametrize("language_server", [Language.ZIG], indirect=True) + @pytest.mark.skipif( + sys.platform == "win32", reason="ZLS cross-file references don't work reliably on Windows - URI path handling issues" + ) + def test_go_to_definition_cross_file(self, language_server: SolidLanguageServer) -> None: + """ + Test go-to-definition from main.zig to calculator.zig. + + ZLS capability: Go-to-definition (textDocument/definition) works cross-file + WITHOUT requiring files to be open. + + NOTE: Disabled on Windows as cross-file references cannot be made to work reliably + due to URI path handling differences between Windows and Unix systems. + """ + file_path = os.path.join("src", "main.zig") + + # Line 8: const calc = calculator.Calculator.init(); + # Test go-to-definition for Calculator + definitions = language_server.request_definition(file_path, 7, 25) # Position of "Calculator" + + assert definitions is not None + assert isinstance(definitions, list) + assert len(definitions) > 0, "Should find definition of Calculator" + + # Should point to calculator.zig + calc_def = definitions[0] + assert "calculator.zig" in calc_def.get("uri", ""), "Definition should be in calculator.zig" + + @pytest.mark.parametrize("language_server", [Language.ZIG], indirect=True) + @pytest.mark.skipif( + sys.platform == "win32", reason="ZLS cross-file references don't work reliably on Windows - URI path handling issues" + ) + def test_cross_file_function_usage(self, language_server: SolidLanguageServer) -> None: + """Test finding usage of functions from math_utils in main.zig. + + NOTE: Disabled on Windows as cross-file references cannot be made to work reliably + due to URI path handling differences between Windows and Unix systems. + """ + # Line 23 in main.zig: const factorial_result = math_utils.factorial(5); + definitions = language_server.request_definition(os.path.join("src", "main.zig"), 22, 40) # Position of "factorial" + + assert definitions is not None + assert isinstance(definitions, list) + + if len(definitions) > 0: + # Should find factorial definition in math_utils.zig + math_def = [d for d in definitions if "math_utils.zig" in d.get("uri", "")] + assert len(math_def) > 0, "Should find factorial definition in math_utils.zig" + + @pytest.mark.parametrize("language_server", [Language.ZIG], indirect=True) + def test_verify_cross_file_imports(self, language_server: SolidLanguageServer) -> None: + """Verify that our test files have proper cross-file imports.""" + # Verify main.zig imports + main_symbols = language_server.request_document_symbols(os.path.join("src", "main.zig")) + assert main_symbols is not None + main_list = main_symbols[0] if isinstance(main_symbols, tuple) else main_symbols + main_names = {sym.get("name") for sym in main_list if isinstance(sym, dict)} + + # main.zig should have main and greeting functions + assert "main" in main_names, "main function should be in main.zig" + assert "greeting" in main_names, "greeting function should be in main.zig" + + # Verify calculator.zig exports Calculator + calc_symbols = language_server.request_document_symbols(os.path.join("src", "calculator.zig")) + assert calc_symbols is not None + calc_list = calc_symbols[0] if isinstance(calc_symbols, tuple) else calc_symbols + calc_names = {sym.get("name") for sym in calc_list if isinstance(sym, dict)} + assert "Calculator" in calc_names, "Calculator struct should be in calculator.zig" + + # Verify math_utils.zig exports functions + math_symbols = language_server.request_document_symbols(os.path.join("src", "math_utils.zig")) + assert math_symbols is not None + math_list = math_symbols[0] if isinstance(math_symbols, tuple) else math_symbols + math_names = {sym.get("name") for sym in math_list if isinstance(sym, dict)} + assert "factorial" in math_names, "factorial function should be in math_utils.zig" + assert "isPrime" in math_names, "isPrime function should be in math_utils.zig" + + @pytest.mark.parametrize("language_server", [Language.ZIG], indirect=True) + def test_hover_information(self, language_server: SolidLanguageServer) -> None: + """Test hover information for symbols.""" + file_path = os.path.join("src", "main.zig") + + # Get hover info for the main function + hover_info = language_server.request_hover(file_path, 4, 8) # Position of "main" function + + assert hover_info is not None, "Should provide hover information for main function" + + # Hover info could be a dict with 'contents' or a string + if isinstance(hover_info, dict): + assert "contents" in hover_info or "value" in hover_info, "Hover should have contents" + + @pytest.mark.parametrize("language_server", [Language.ZIG], indirect=True) + def test_full_symbol_tree(self, language_server: SolidLanguageServer) -> None: + """Test that full symbol tree is not empty.""" + symbols = language_server.request_full_symbol_tree() + + assert symbols is not None + assert len(symbols) > 0, "Symbol tree should not be empty" + + # The tree should have at least one root node + root = symbols[0] + assert isinstance(root, dict), "Root should be a dict" + assert "name" in root, "Root should have a name"