Add rego language support (#670)

Also added an ignore rule for ruff (about unused unpacked variables), we might reintroduce this rule later
This commit is contained in:
Yen authored and GitHub committed 2025-10-15 22:46:25 +02:00
1 parent 39a11657a9
commit f2919f11c5
8 files changed
+401 -2

No files matched your search

+29 -2
View File
@@ -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
+2
View File
@@ -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",
]
@@ -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()
+7
View File
@@ -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
@@ -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
}
@@ -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
}
@@ -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
}
+138
View File
@@ -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"