mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-13 20:15:20 +00:00
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:
1 parent
70edc0d1cc
commit
ff1b9057c0
14 files changed
+467
-5
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
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]}"
|
||||
Reference in new issue
Block a user