From f2919f11c5a5531962be549f67e053d8afd92399 Mon Sep 17 00:00:00 2001 From: Yen <5915590+antigenius0910@users.noreply.github.com> Date: Wed, 15 Oct 2025 15:46:25 -0500 Subject: [PATCH] Add rego language support (#670) Also added an ignore rule for ruff (about unused unpacked variables), we might reintroduce this rule later --- .github/workflows/pytest.yml | 31 +++- pyproject.toml | 2 + src/solidlsp/language_servers/regal_server.py | 140 ++++++++++++++++++ src/solidlsp/ls_config.py | 7 + .../repos/rego/test_repo/policies/authz.rego | 33 +++++ .../rego/test_repo/policies/validation.rego | 23 +++ .../repos/rego/test_repo/utils/helpers.rego | 29 ++++ test/solidlsp/rego/test_rego_basic.py | 138 +++++++++++++++++ 8 files changed, 401 insertions(+), 2 deletions(-) create mode 100644 src/solidlsp/language_servers/regal_server.py create mode 100644 test/resources/repos/rego/test_repo/policies/authz.rego create mode 100644 test/resources/repos/rego/test_repo/policies/validation.rego create mode 100644 test/resources/repos/rego/test_repo/utils/helpers.rego create mode 100644 test/solidlsp/rego/test_rego_basic.py diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index e87eccfb..7936dbc9 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -248,13 +248,13 @@ jobs: 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: Verify Nix package build if: runner.os != 'Windows' # Nix only supported on Linux/macOS @@ -262,6 +262,33 @@ jobs: run: | # Verify the flake builds successfully nix build --no-link + - name: Install Regal (Rego Language Server) + shell: bash + run: | + REGAL_VERSION="0.36.1" + + if [[ "${{ runner.os }}" == "Linux" ]]; then + if [[ "$(uname -m)" == "x86_64" ]]; then + curl -L -o regal https://github.com/StyraInc/regal/releases/download/v${REGAL_VERSION}/regal_Linux_x86_64 + else + curl -L -o regal https://github.com/StyraInc/regal/releases/download/v${REGAL_VERSION}/regal_Linux_arm64 + fi + chmod +x regal + sudo mv regal /usr/local/bin/ + elif [[ "${{ runner.os }}" == "macOS" ]]; then + if [[ "$(uname -m)" == "x86_64" ]]; then + curl -L -o regal https://github.com/StyraInc/regal/releases/download/v${REGAL_VERSION}/regal_Darwin_x86_64 + else + curl -L -o regal https://github.com/StyraInc/regal/releases/download/v${REGAL_VERSION}/regal_Darwin_arm64 + fi + chmod +x regal + sudo mv regal /usr/local/bin/ + elif [[ "${{ runner.os }}" == "Windows" ]]; then + curl -L -o regal.exe https://github.com/StyraInc/regal/releases/download/v${REGAL_VERSION}/regal_Windows_x86_64.exe + mkdir -p "$HOME/bin" + mv regal.exe "$HOME/bin/" + echo "$HOME/bin" >> $GITHUB_PATH + fi - name: Install uv shell: bash run: curl -LsSf https://astral.sh/uv/install.sh | sh diff --git a/pyproject.toml b/pyproject.toml index e6bbdf1e..fb7c230d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -167,6 +167,7 @@ ignore = [ "PLC0415", "RUF002", "RUF005", + "RUF059", "SIM118", "SIM108", "E501", @@ -275,6 +276,7 @@ markers = [ "dart: language server running for Dart", "erlang: language server running for Erlang", "al: language server running for AL (Microsoft Dynamics 365 Business Central)", + "rego: language server running for Rego", "markdown: language server running for Markdown", ] diff --git a/src/solidlsp/language_servers/regal_server.py b/src/solidlsp/language_servers/regal_server.py new file mode 100644 index 00000000..eb6abc1d --- /dev/null +++ b/src/solidlsp/language_servers/regal_server.py @@ -0,0 +1,140 @@ +"""Regal Language Server implementation for Rego policy files.""" + +import logging +import os +import shutil +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.ls_utils import PathUtils +from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + + +class RegalLanguageServer(SolidLanguageServer): + """ + Provides Rego specific instantiation of the LanguageServer class using Regal. + + Regal is the official linter and language server for Rego (Open Policy Agent's policy language). + See: https://github.com/StyraInc/regal + """ + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in [".regal", ".opa"] + + def __init__( + self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings + ): + """ + Creates a RegalLanguageServer instance. + + This class is not meant to be instantiated directly. Use LanguageServer.create() instead. + + :param config: Language server configuration + :param logger: Logger instance + :param repository_root_path: Path to the repository root + :param solidlsp_settings: Settings for solidlsp + """ + # Regal should be installed system-wide (via CI or user installation) + regal_executable_path = shutil.which("regal") + if not regal_executable_path: + raise RuntimeError( + "Regal language server not found. Please install it from https://github.com/StyraInc/regal or via your package manager." + ) + + super().__init__( + config, + logger, + repository_root_path, + ProcessLaunchInfo(cmd=f"{regal_executable_path} language-server", cwd=repository_root_path), + "rego", + solidlsp_settings, + ) + self.server_ready = threading.Event() + + @staticmethod + def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for the Regal Language Server. + + :param repository_absolute_path: Absolute path to the repository + :return: LSP initialization parameters + """ + root_uri = PathUtils.path_to_uri(repository_absolute_path) + return { + "processId": os.getpid(), + "locale": "en", + "rootPath": repository_absolute_path, + "rootUri": root_uri, + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, # type: ignore[arg-type] + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, # type: ignore[list-item] + "codeAction": {"dynamicRegistration": True}, + "formatting": {"dynamicRegistration": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "symbol": {"dynamicRegistration": True}, + }, + }, + "workspaceFolders": [ + { + "name": os.path.basename(repository_absolute_path), + "uri": root_uri, + } + ], + } + + def _start_server(self) -> None: + """Start Regal language server process and wait for initialization.""" + + def register_capability_handler(params) -> None: # type: ignore[no-untyped-def] + return + + def window_log_message(msg) -> None: # type: ignore[no-untyped-def] + self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + + def do_nothing(params) -> None: # type: ignore[no-untyped-def] + 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 Regal 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 "capabilities" in init_response + assert "textDocumentSync" in init_response["capabilities"] + + self.server.notify.initialized({}) + self.completions_available.set() + + # Regal server is ready immediately after initialization + self.server_ready.set() + self.server_ready.wait() diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index e2dfd970..d70c1dce 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -55,6 +55,7 @@ class Language(str, Enum): NIX = "nix" ERLANG = "erlang" AL = "al" + REGO = "rego" # 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""" @@ -147,6 +148,8 @@ class Language(str, Enum): return FilenameMatcher("*.erl", "*.hrl", "*.escript", "*.config", "*.app", "*.app.src") case self.AL: return FilenameMatcher("*.al", "*.dal") + case self.REGO: + return FilenameMatcher("*.rego") case self.MARKDOWN: return FilenameMatcher("*.md", "*.markdown") case _: @@ -262,6 +265,10 @@ class Language(str, Enum): from solidlsp.language_servers.al_language_server import ALLanguageServer return ALLanguageServer + case self.REGO: + from solidlsp.language_servers.regal_server import RegalLanguageServer + + return RegalLanguageServer case self.MARKDOWN: from solidlsp.language_servers.marksman import Marksman diff --git a/test/resources/repos/rego/test_repo/policies/authz.rego b/test/resources/repos/rego/test_repo/policies/authz.rego new file mode 100644 index 00000000..fbac4bab --- /dev/null +++ b/test/resources/repos/rego/test_repo/policies/authz.rego @@ -0,0 +1,33 @@ +package policies + +import data.utils + +# Default deny +default allow := false + +# Admin access rule +allow if { + input.user.role == "admin" + utils.is_valid_user(input.user) +} + +# Read access for authenticated users +allow_read if { + input.action == "read" + input.user.authenticated +} + +# User roles list +admin_roles := ["admin", "superuser"] + +# Helper function to check if user is admin +is_admin(user) if { + admin_roles[_] == user.role +} + +# Check if action is allowed for user +check_permission(user, action) if { + user.role == "admin" + allowed_actions := ["read", "write", "delete"] + allowed_actions[_] == action +} diff --git a/test/resources/repos/rego/test_repo/policies/validation.rego b/test/resources/repos/rego/test_repo/policies/validation.rego new file mode 100644 index 00000000..49e64221 --- /dev/null +++ b/test/resources/repos/rego/test_repo/policies/validation.rego @@ -0,0 +1,23 @@ +package policies + +import data.policies +import data.utils + +# Validate user input +validate_user_input if { + utils.is_valid_user(input.user) + utils.is_valid_email(input.user.email) +} + +# Check if user has valid credentials +has_valid_credentials(user) if { + user.username != "" + user.password != "" + utils.is_valid_email(user.email) +} + +# Validate request +validate_request if { + input.user.authenticated + policies.allow +} diff --git a/test/resources/repos/rego/test_repo/utils/helpers.rego b/test/resources/repos/rego/test_repo/utils/helpers.rego new file mode 100644 index 00000000..2fb60bd4 --- /dev/null +++ b/test/resources/repos/rego/test_repo/utils/helpers.rego @@ -0,0 +1,29 @@ +package utils + +# User validation +is_valid_user(user) if { + user.id != "" + user.email != "" +} + +# Email validation +is_valid_email(email) if { + contains(email, "@") + contains(email, ".") +} + +# Username validation +is_valid_username(username) if { + count(username) >= 3 + count(username) <= 32 +} + +# Check if string is empty +is_empty(str) if { + str == "" +} + +# Check if array contains element +array_contains(arr, elem) if { + arr[_] == elem +} diff --git a/test/solidlsp/rego/test_rego_basic.py b/test/solidlsp/rego/test_rego_basic.py new file mode 100644 index 00000000..814562dc --- /dev/null +++ b/test/solidlsp/rego/test_rego_basic.py @@ -0,0 +1,138 @@ +"""Tests for Rego language server (Regal) functionality.""" + +import os +import sys + +import pytest + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_utils import SymbolUtils + + +@pytest.mark.rego +@pytest.mark.skipif( + sys.platform == "win32", reason="Regal LSP has Windows path handling bug - see https://github.com/StyraInc/regal/issues/1683" +) +class TestRegoLanguageServer: + """Test Regal language server functionality for Rego.""" + + @pytest.mark.parametrize("language_server", [Language.REGO], indirect=True) + def test_request_document_symbols_authz(self, language_server: SolidLanguageServer) -> None: + """Test that document symbols can be retrieved from authz.rego.""" + file_path = os.path.join("policies", "authz.rego") + symbols = language_server.request_document_symbols(file_path) + + assert symbols is not None + assert len(symbols) > 0 + + # Extract symbol names + 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 Rego rules/functions are found + assert "allow" in symbol_names, "allow rule not found" + assert "allow_read" in symbol_names, "allow_read rule not found" + assert "is_admin" in symbol_names, "is_admin function not found" + assert "admin_roles" in symbol_names, "admin_roles constant not found" + + @pytest.mark.parametrize("language_server", [Language.REGO], indirect=True) + def test_request_document_symbols_helpers(self, language_server: SolidLanguageServer) -> None: + """Test that document symbols can be retrieved from helpers.rego.""" + file_path = os.path.join("utils", "helpers.rego") + symbols = language_server.request_document_symbols(file_path) + + assert symbols is not None + assert len(symbols) > 0 + + # Extract symbol names + 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 helper functions are found + assert "is_valid_user" in symbol_names, "is_valid_user function not found" + assert "is_valid_email" in symbol_names, "is_valid_email function not found" + assert "is_valid_username" in symbol_names, "is_valid_username function not found" + + @pytest.mark.parametrize("language_server", [Language.REGO], indirect=True) + def test_find_symbol_full_tree(self, language_server: SolidLanguageServer) -> None: + """Test finding symbols across entire workspace using symbol tree.""" + symbols = language_server.request_full_symbol_tree() + + # Use SymbolUtils to check for expected symbols + assert SymbolUtils.symbol_tree_contains_name(symbols, "allow"), "allow rule not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "is_valid_user"), "is_valid_user function not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "is_admin"), "is_admin function not found in symbol tree" + + @pytest.mark.parametrize("language_server", [Language.REGO], indirect=True) + def test_request_definition_within_file(self, language_server: SolidLanguageServer) -> None: + """Test go-to-definition for symbols within the same file.""" + # In authz.rego, check_permission references admin_roles + file_path = os.path.join("policies", "authz.rego") + + # Get document symbols + symbols = language_server.request_document_symbols(file_path) + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + + # Find the is_admin symbol which references admin_roles + is_admin_symbol = next((s for s in symbol_list if s.get("name") == "is_admin"), None) + assert is_admin_symbol is not None, "is_admin symbol should always be found in authz.rego" + assert "range" in is_admin_symbol, "is_admin symbol should have a range" + + # Request definition from within is_admin (line 25, which references admin_roles at line 21) + # Line 25 is: admin_roles[_] == user.role + line = is_admin_symbol["range"]["start"]["line"] + 1 + char = 4 # Position at "admin_roles" + + definitions = language_server.request_definition(file_path, line, char) + assert definitions is not None and len(definitions) > 0, "Should find definition for admin_roles" + + # Verify the definition points to admin_roles in the same file + assert any("authz.rego" in defn.get("relativePath", "") for defn in definitions), "Definition should be in authz.rego" + + @pytest.mark.parametrize("language_server", [Language.REGO], indirect=True) + def test_request_definition_across_files(self, language_server: SolidLanguageServer) -> None: + """Test go-to-definition for symbols across files (cross-file references).""" + # In authz.rego line 11, the allow rule calls utils.is_valid_user + # This function is defined in utils/helpers.rego + file_path = os.path.join("policies", "authz.rego") + + # Get document symbols + symbols = language_server.request_document_symbols(file_path) + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + + # Find the allow symbol + allow_symbol = next((s for s in symbol_list if s.get("name") == "allow"), None) + assert allow_symbol is not None, "allow symbol should always be found in authz.rego" + assert "range" in allow_symbol, "allow symbol should have a range" + + # Request definition from line 11 where utils.is_valid_user is called + # Line 11: utils.is_valid_user(input.user) + line = 10 # 0-indexed, so line 11 in file is line 10 in LSP + char = 7 # Position at "is_valid_user" in "utils.is_valid_user" + + definitions = language_server.request_definition(file_path, line, char) + assert definitions is not None and len(definitions) > 0, "Should find cross-file definition for is_valid_user" + + # Verify the definition points to helpers.rego (cross-file) + assert any( + "helpers.rego" in defn.get("relativePath", "") for defn in definitions + ), "Definition should be in utils/helpers.rego (cross-file reference)" + + @pytest.mark.parametrize("language_server", [Language.REGO], indirect=True) + def test_find_symbols_validation(self, language_server: SolidLanguageServer) -> None: + """Test finding symbols in validation.rego which has imports.""" + file_path = os.path.join("policies", "validation.rego") + symbols = language_server.request_document_symbols(file_path) + + assert symbols is not None + assert len(symbols) > 0 + + # Extract symbol names + 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 expected symbols + assert "validate_user_input" in symbol_names, "validate_user_input rule not found" + assert "has_valid_credentials" in symbol_names, "has_valid_credentials function not found" + assert "validate_request" in symbol_names, "validate_request rule not found"