Add limited Ansible language server support (#1170)

References and definition not working yet. Since ansible resolves same files as yaml LS, we will need to add disambiguation later.

Work in progress, improvements on the language server itself are being worked on, ansible support in Serena will be extended accordingly in the future.
This commit is contained in:
Sergey Belov authored and GitHub committed 2026-03-16 22:40:58 +01:00
1 parent 70edc0d1cc
commit ff1b9057c0
14 files changed
+467 -5

No files matched your search

+3
View File
@@ -383,6 +383,9 @@ jobs:
echo "PERL_MB_OPT=--install_base \"$HOME/perl5\"" >> $GITHUB_ENV
echo "PERL_MM_OPT=INSTALL_BASE=$HOME/perl5" >> $GITHUB_ENV
echo "$HOME/perl5/bin" >> $GITHUB_PATH
- name: Install ansible-core and ansible-lint (for Ansible language server tests)
shell: bash
run: uv run pip install ansible-core ansible-lint
- name: Install Elm
shell: bash
run: npm install -g elm@0.19.1-6
+1 -1
View File
@@ -81,7 +81,7 @@ that implement the language server protocol (LSP).
The underlying language servers are typically open-source projects (like Serena) or at least freely available for use.
With Serena's LSP library, we provide **support for over 30 programming languages**, including
AL, Bash, C#, C/C++, Clojure, Dart, Elixir, Elm, Erlang, Fortran, GLSL, Go, Groovy (partial support), Haskell, HLSL, Java, Javascript, Julia, Kotlin, Lean 4, Lua, Luau, Markdown, MATLAB, Nix, OCaml, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Solidity, Swift, TOML, TypeScript, WGSL, YAML, and Zig.
AL, Ansible, Bash, C#, C/C++, Clojure, Dart, Elixir, Elm, Erlang, Fortran, GLSL, Go, Groovy (partial support), Haskell, HLSL, Java, Javascript, Julia, Kotlin, Lean 4, Lua, Luau, Markdown, MATLAB, Nix, OCaml, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Solidity, Swift, TOML, TypeScript, WGSL, YAML, and Zig.
> [!IMPORTANT]
> Some language servers require additional dependencies to be installed; see the [Language Support](https://oraios.github.io/serena/01-about/020_programming-languages.html) page for details.
+7 -1
View File
@@ -28,6 +28,12 @@ We currently provide direct, out-of-the-box support for the programming language
Some languages require additional installations or setup steps, as noted.
* **AL**
* **Ansible**
(experimental; requires Node.js and npm; automatically installs `@ansible/ansible-language-server`;
must be explicitly specified in the `languages` entry in the `project.yml`; requires `ansible` in PATH for full functionality)
the upstream `@ansible/ansible-language-server@1.2.3` supports hover, completion, definition,
semantic tokens, and validation; document symbols, workspace symbols, references, and rename
are not supported by this version)
* **Bash**
* **C#**
(by default, uses the Roslyn language server (language `csharp`), requiring [.NET v10+](https://dotnet.microsoft.com/en-us/download/dotnet) and, on Windows, `pwsh` ([PowerShell 7+](https://learn.microsoft.com/en-us/powershell/scripting/install/install-powershell-on-windows?view=powershell-7.5));
@@ -135,4 +141,4 @@ of choice.
</p>
```
See the [JetBrains Plugin documentation](../02-usage/025_jetbrains_plugin) for usage details.
See the [JetBrains Plugin documentation](../02-usage/025_jetbrains_plugin) for usage details.
+1
View File
@@ -331,6 +331,7 @@ markers = [
"hlsl: language server running for HLSL shaders (uses shader-language-server)",
"lean4: language server running for Lean 4",
"solidity: language server running for Solidity (uses @nomicfoundation/solidity-language-server)",
"ansible: language server running for Ansible (uses @ansible/ansible-language-server)",
]
[tool.codespell]
@@ -0,0 +1,318 @@
"""
Provides Ansible specific instantiation of the LanguageServer class using ansible-language-server.
Contains various configurations and settings specific to Ansible YAML files (playbooks, roles, etc.).
"""
import fnmatch
import logging
import os
import pathlib
import shutil
from typing import Any, ClassVar
from overrides import override
from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection
from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
from solidlsp.settings import SolidLSPSettings
log = logging.getLogger(__name__)
def _deep_merge(base: dict, override: dict) -> None:
"""Recursively merge *override* into *base*, modifying *base* in-place."""
for key, value in override.items():
if key in base and isinstance(base[key], dict) and isinstance(value, dict):
_deep_merge(base[key], value)
else:
base[key] = value
class AnsibleLanguageServer(SolidLanguageServer):
"""Provides Ansible specific instantiation of the LanguageServer class using ansible-language-server.
Contains various configurations and settings specific to Ansible YAML files
(playbooks, roles, inventories, etc.).
Supported ``ls_specific_settings`` keys (via ``serena_config.yml``):
* ``ls_path`` (str) — path to the ansible-language-server executable
(handled by the base class).
* ``ansible_path`` (str, default ``"ansible"``) — path to the ``ansible`` executable.
* ``python_interpreter_path`` (str, default ``"python3"``) — path to the Python interpreter.
* ``python_activation_script`` (str, default ``""``) — virtualenv activation script.
* ``lint_enabled`` (bool, default ``False``) — enable ansible-lint
(requires a separate installation of ``ansible-lint``).
* ``lint_path`` (str, default ``"ansible-lint"``) — path to ``ansible-lint``.
* ``ansible_settings`` (dict) — full settings dict, deep-merged on top of defaults.
The structure mirrors the Ansible Language Server settings
(``ansible.*``, ``python.*``, ``validation.*``, ``completion.*``,
``executionEnvironment.*``).
"""
# directory names that signal ansible content at ANY nesting level
_ANSIBLE_DIR_NAMES: ClassVar[set[str]] = {
"roles",
"playbooks",
"tasks",
"handlers",
"group_vars",
"host_vars",
"inventory",
"inventories",
"defaults",
"vars",
"meta",
}
# filename patterns handled by ansible LS regardless of path
_ANSIBLE_FILENAME_PATTERNS: ClassVar[list[str]] = [
"playbook*.yml",
"playbook*.yaml",
"site.yml",
"site.yaml",
"requirements.yml",
"requirements.yaml",
]
@staticmethod
def _is_ansible_path(relative_path: str) -> bool:
"""Check if a file is in an ansible-specific location.
Matches if ANY component of the path is an ansible-specific
directory name (e.g. ``roles``, ``tasks``, ``group_vars``),
or if the filename matches an ansible-specific pattern.
This works regardless of nesting depth:
``project/deploy/roles/web/tasks/main.yml`` matches on both
``roles`` and ``tasks``.
:param relative_path: path relative to the repository root
:return: True if the path is an ansible-specific location
"""
normalized = relative_path.replace("\\", "/")
parts = normalized.split("/")
# check if any directory component is ansible-specific
dir_parts = parts[:-1]
for part in dir_parts:
if part in AnsibleLanguageServer._ANSIBLE_DIR_NAMES:
return True
# check filename patterns (e.g. playbook.yml, site.yaml)
filename = parts[-1]
for pattern in AnsibleLanguageServer._ANSIBLE_FILENAME_PATTERNS:
if fnmatch.fnmatch(filename, pattern):
return True
return False
@override
def is_ignored_path(self, relative_path: str, ignore_unsupported_files: bool = True) -> bool:
# standard ignore rules (extension, gitignore, etc.)
if super().is_ignored_path(relative_path, ignore_unsupported_files):
return True
# for yml/yaml files, check if they are in ansible-specific paths
if relative_path.endswith((".yml", ".yaml")):
if not self._is_ansible_path(relative_path):
return True
return False
@staticmethod
def _determine_log_level(line: str) -> int:
"""Classify ansible-language-server stderr output to avoid false-positive errors."""
line_lower = line.lower()
if any(
[
"ansible is not installed" in line_lower,
"ansible-lint" in line_lower and "not found" in line_lower,
"cannot find module" in line_lower,
]
):
return logging.DEBUG
return SolidLanguageServer._determine_log_level(line)
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
"""Creates an AnsibleLanguageServer instance.
This class is not meant to be instantiated directly.
Use ``SolidLanguageServer.create()`` instead.
"""
super().__init__(
config,
repository_root_path,
None,
"ansible",
solidlsp_settings,
)
def _create_dependency_provider(self) -> LanguageServerDependencyProvider:
return self.DependencyProvider(self._custom_settings, self._ls_resources_dir)
class DependencyProvider(LanguageServerDependencyProviderSinglePath):
def _get_or_install_core_dependency(self) -> str:
"""Setup runtime dependencies for Ansible Language Server and return the path to the executable."""
# verify both node and npm are installed
is_node_installed = shutil.which("node") is not None
assert is_node_installed, "node is not installed or isn't in PATH. Please install Node.js and try again."
is_npm_installed = shutil.which("npm") is not None
assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again."
deps = RuntimeDependencyCollection(
[
RuntimeDependency(
id="ansible-language-server",
description="Ansible Language Server (@ansible/ansible-language-server)",
command="npm install --prefix ./ @ansible/ansible-language-server@1.2.3",
platform_id="any",
),
]
)
# install ansible-language-server if not already installed
ansible_ls_dir = os.path.join(self._ls_resources_dir, "ansible-lsp")
ansible_executable_path = os.path.join(ansible_ls_dir, "node_modules", ".bin", "ansible-language-server")
# handle Windows executable extension
if os.name == "nt":
ansible_executable_path += ".cmd"
if not os.path.exists(ansible_executable_path):
log.info(f"Ansible Language Server executable not found at {ansible_executable_path}. Installing...")
deps.install(ansible_ls_dir)
log.info("Ansible Language Server dependencies installed successfully")
if not os.path.exists(ansible_executable_path):
raise FileNotFoundError(
f"ansible-language-server executable not found at {ansible_executable_path}, "
"something went wrong with the installation."
)
return ansible_executable_path
def _create_launch_command(self, core_path: str) -> list[str]:
return [core_path, "--stdio"]
def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams:
"""Returns the initialize params for the Ansible Language Server.
Reads shortcut keys and the ``ansible_settings`` dict from ``_custom_settings``
to build ``initializationOptions``.
"""
root_uri = pathlib.Path(repository_absolute_path).as_uri()
# default ansible settings, populated from shortcut keys
ansible_settings: dict[str, Any] = {
"ansible": {
"path": self._custom_settings.get("ansible_path", "ansible"),
"useFullyQualifiedCollectionNames": True,
},
"python": {
"interpreterPath": self._custom_settings.get("python_interpreter_path", "python3"),
"activationScript": self._custom_settings.get("python_activation_script", ""),
},
"validation": {
"enabled": True,
"lint": {
"enabled": self._custom_settings.get("lint_enabled", False),
"path": self._custom_settings.get("lint_path", "ansible-lint"),
},
},
"completion": {
"provideRedirectModules": True,
"provideModuleOptionAliases": True,
},
"executionEnvironment": {"enabled": False},
}
# full override via ansible_settings dict for advanced configuration
user_settings = self._custom_settings.settings.get("ansible_settings")
if user_settings:
if not isinstance(user_settings, dict):
raise TypeError(
f"ansible_settings must be a dict, got {type(user_settings).__name__}. "
"Expected structure matching Ansible LS settings: "
"{'ansible': {...}, 'python': {...}, 'validation': {...}, ...}"
)
_deep_merge(ansible_settings, user_settings)
initialize_params = {
"locale": "en",
"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))},
},
"hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]},
"codeAction": {"dynamicRegistration": True},
},
"workspace": {
"workspaceFolders": True,
"didChangeConfiguration": {"dynamicRegistration": True},
"symbol": {"dynamicRegistration": True},
},
},
"processId": os.getpid(),
"rootPath": repository_absolute_path,
"rootUri": root_uri,
"workspaceFolders": [
{
"uri": root_uri,
"name": os.path.basename(repository_absolute_path),
}
],
"initializationOptions": {"ansible": ansible_settings},
}
return initialize_params # type: ignore
def _start_server(self) -> None:
"""Starts the Ansible Language Server, waits for the server to be ready."""
def register_capability_handler(params: Any) -> None:
return
def show_message_request_handler(params: Any) -> None:
"""Handle ``window/showMessageRequest`` by returning ``null``.
Per the LSP spec, returning ``null`` means no action was selected.
Without this handler the client replies with ``MethodNotFound``,
which the ansible LS treats as fatal.
"""
log.info(f"LSP: window/showMessageRequest (dismissed): {params.get('message', params)}")
return
def do_nothing(params: Any) -> None:
return
def window_log_message(msg: dict) -> None:
log.info(f"LSP: window/logMessage: {msg}")
self.server.on_request("client/registerCapability", register_capability_handler)
self.server.on_request("window/showMessageRequest", show_message_request_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)
log.info("Starting Ansible server process")
self.server.start()
initialize_params = self._get_initialize_params(self.repository_root_path)
log.info("Sending initialize request from LSP client to LSP server and awaiting response")
init_response = self.server.send.initialize(initialize_params)
log.debug(f"Received initialize response from Ansible server: {init_response}")
log.debug(f"Ansible server capabilities: {list(init_response['capabilities'].keys())}")
self.server.notify.initialized({})
log.info("Ansible server initialization complete")
+3 -3
View File
@@ -1104,12 +1104,12 @@ class SolidLanguageServer(ABC):
}
response: list[LSPTypes.CompletionItem] | LSPTypes.CompletionList | None = None
num_retries = 0
while response is None or (response["isIncomplete"] and num_retries < 30): # type: ignore
for _ in range(30):
response = self.server.send.completion(completion_params)
if isinstance(response, list):
response = {"items": response, "isIncomplete": False}
num_retries += 1
if response is None or not response["isIncomplete"]: # type: ignore
break
# TODO: Understand how to appropriately handle `isIncomplete`
if response is None or (response["isIncomplete"] and not allow_incomplete): # type: ignore
+13
View File
@@ -129,6 +129,12 @@ class Language(str, Enum):
hover, and diagnostics. Requires Node.js and npm.
Works best with a foundry.toml or hardhat.config.js in the project root.
"""
ANSIBLE = "ansible"
"""Ansible language server (experimental) using @ansible/ansible-language-server.
Supports *.yaml and *.yml files (same extensions as YAML, hence experimental).
Must be explicitly specified in project.yml. Requires Node.js and npm.
Requires ``ansible`` in PATH for full functionality.
"""
@classmethod
def iter_all(cls, include_experimental: bool = False) -> Iterable[Self]:
@@ -145,6 +151,7 @@ class Language(str, Enum):
in the project.yml configuration.
"""
return self in {
self.ANSIBLE,
self.TYPESCRIPT_VTS,
self.PYTHON_JEDI,
self.CSHARP_OMNISHARP,
@@ -299,6 +306,8 @@ class Language(str, Enum):
return FilenameMatcher("*.sv", "*.svh", "*.v", "*.vh")
case self.SOLIDITY:
return FilenameMatcher("*.sol")
case self.ANSIBLE:
return FilenameMatcher("*.yaml", "*.yml")
case _:
raise ValueError(f"Unhandled language: {self}")
@@ -506,6 +515,10 @@ class Language(str, Enum):
from solidlsp.language_servers.solidity_language_server import SolidityLanguageServer
return SolidityLanguageServer
case self.ANSIBLE:
from solidlsp.language_servers.ansible_language_server import AnsibleLanguageServer
return AnsibleLanguageServer
case _:
raise ValueError(f"Unhandled language: {self}")
@@ -0,0 +1,13 @@
---
all:
children:
webservers:
hosts:
web1.example.com:
ansible_host: 192.168.1.10
web2.example.com:
ansible_host: 192.168.1.11
dbservers:
hosts:
db1.example.com:
ansible_host: 192.168.1.20
@@ -0,0 +1,31 @@
---
- name: Configure web servers
hosts: webservers
become: true
vars:
http_port: 80
max_connections: 100
tasks:
- name: Install nginx
ansible.builtin.package:
name: nginx
state: present
- name: Start nginx service
ansible.builtin.service:
name: nginx
state: started
enabled: true
- name: Copy config file
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
notify: Restart nginx
handlers:
- name: Restart nginx
ansible.builtin.service:
name: nginx
state: restarted
@@ -0,0 +1,3 @@
---
nginx_listen_port: 80
deploy_user: deploy
@@ -0,0 +1,4 @@
---
- name: Restart common services
ansible.builtin.debug:
msg: "Restarting common services"
@@ -0,0 +1,19 @@
---
- name: Update package cache
ansible.builtin.package:
update_cache: true
- name: Install common packages
ansible.builtin.package:
name: "{{ item }}"
state: present
loop:
- curl
- git
- vim
- name: Create deploy user
ansible.builtin.user:
name: deploy
state: present
shell: /bin/bash
View File
Whitespace-only changes.
@@ -0,0 +1,51 @@
"""
Basic integration tests for the Ansible language server.
These tests validate initialization, hover, and completion capabilities
using the standard Ansible test repository. They work with the standard
@ansible/ansible-language-server from npm.
"""
from pathlib import Path
import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
@pytest.mark.ansible
class TestAnsibleLanguageServerBasics:
"""Test basic Ansible language server functionality."""
@pytest.mark.parametrize("language_server", [Language.ANSIBLE], indirect=True)
@pytest.mark.parametrize("repo_path", [Language.ANSIBLE], indirect=True)
def test_ls_is_running(self, language_server: SolidLanguageServer, repo_path: Path) -> None:
"""Language server starts and points to the correct repo."""
assert language_server.is_running()
assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve()
@pytest.mark.parametrize("language_server", [Language.ANSIBLE], indirect=True)
def test_hover_on_module_contains_documentation(self, language_server: SolidLanguageServer) -> None:
"""Hover on ansible.builtin.package returns module documentation."""
# playbook.yml line 10 (0-indexed): "ansible.builtin.package:"
result = language_server.request_hover("playbook.yml", 10, 8)
assert result is not None, "Expected hover info for ansible.builtin.package"
hover_value = result["contents"]
if isinstance(hover_value, dict):
hover_text = hover_value.get("value", "")
elif isinstance(hover_value, list):
hover_text = " ".join(str(v) for v in hover_value)
else:
hover_text = str(hover_value)
assert "package" in hover_text.lower(), f"Hover should mention 'package', got: {hover_text[:300]}"
@pytest.mark.parametrize("language_server", [Language.ANSIBLE], indirect=True)
def test_completions_contain_module_names(self, language_server: SolidLanguageServer) -> None:
"""Completions at a task keyword position return Ansible module names."""
# playbook.yml line 10 (0-indexed), col 6: inside a task block
result = language_server.request_completions("playbook.yml", 10, 6)
assert result is not None, "Expected completion results"
assert len(result) > 0, "Expected non-empty completion list"
labels = [item["completionText"] for item in result if "completionText" in item]
assert labels, f"Expected completions with completionText, got: {result[:3]}"