mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-14 16:18:03 +00:00
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:
8 files changed
+401
-2
No files matched your search
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user