From ff1b9057c070a074b73e3b3bfeff9355e318d6ba Mon Sep 17 00:00:00 2001 From: Sergey Belov Date: Mon, 16 Mar 2026 22:40:58 +0100 Subject: [PATCH] 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. --- .github/workflows/pytest.yml | 3 + README.md | 2 +- docs/01-about/020_programming-languages.md | 8 +- pyproject.toml | 1 + .../ansible_language_server.py | 318 ++++++++++++++++++ src/solidlsp/ls.py | 6 +- src/solidlsp/ls_config.py | 13 + .../ansible/test_repo/inventory/hosts.yml | 13 + .../repos/ansible/test_repo/playbook.yml | 31 ++ .../test_repo/roles/common/defaults/main.yml | 3 + .../test_repo/roles/common/handlers/main.yml | 4 + .../test_repo/roles/common/tasks/main.yml | 19 ++ test/solidlsp/ansible/__init__.py | 0 test/solidlsp/ansible/test_ansible_basic.py | 51 +++ 14 files changed, 467 insertions(+), 5 deletions(-) create mode 100644 src/solidlsp/language_servers/ansible_language_server.py create mode 100644 test/resources/repos/ansible/test_repo/inventory/hosts.yml create mode 100644 test/resources/repos/ansible/test_repo/playbook.yml create mode 100644 test/resources/repos/ansible/test_repo/roles/common/defaults/main.yml create mode 100644 test/resources/repos/ansible/test_repo/roles/common/handlers/main.yml create mode 100644 test/resources/repos/ansible/test_repo/roles/common/tasks/main.yml create mode 100644 test/solidlsp/ansible/__init__.py create mode 100644 test/solidlsp/ansible/test_ansible_basic.py diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index c3c0b95b..e757f030 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -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 diff --git a/README.md b/README.md index 171727a6..5fc6a9db 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/01-about/020_programming-languages.md b/docs/01-about/020_programming-languages.md index f4a02c31..241e0b62 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -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.

``` -See the [JetBrains Plugin documentation](../02-usage/025_jetbrains_plugin) for usage details. \ No newline at end of file +See the [JetBrains Plugin documentation](../02-usage/025_jetbrains_plugin) for usage details. diff --git a/pyproject.toml b/pyproject.toml index ccc5fa1c..90662655 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/src/solidlsp/language_servers/ansible_language_server.py b/src/solidlsp/language_servers/ansible_language_server.py new file mode 100644 index 00000000..7c2f88a1 --- /dev/null +++ b/src/solidlsp/language_servers/ansible_language_server.py @@ -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") diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 02b1ab5f..881f070d 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -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 diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 58d4c326..2633e62f 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -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}") diff --git a/test/resources/repos/ansible/test_repo/inventory/hosts.yml b/test/resources/repos/ansible/test_repo/inventory/hosts.yml new file mode 100644 index 00000000..616cd917 --- /dev/null +++ b/test/resources/repos/ansible/test_repo/inventory/hosts.yml @@ -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 diff --git a/test/resources/repos/ansible/test_repo/playbook.yml b/test/resources/repos/ansible/test_repo/playbook.yml new file mode 100644 index 00000000..37a4e978 --- /dev/null +++ b/test/resources/repos/ansible/test_repo/playbook.yml @@ -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 diff --git a/test/resources/repos/ansible/test_repo/roles/common/defaults/main.yml b/test/resources/repos/ansible/test_repo/roles/common/defaults/main.yml new file mode 100644 index 00000000..83723e36 --- /dev/null +++ b/test/resources/repos/ansible/test_repo/roles/common/defaults/main.yml @@ -0,0 +1,3 @@ +--- +nginx_listen_port: 80 +deploy_user: deploy diff --git a/test/resources/repos/ansible/test_repo/roles/common/handlers/main.yml b/test/resources/repos/ansible/test_repo/roles/common/handlers/main.yml new file mode 100644 index 00000000..aa567e7c --- /dev/null +++ b/test/resources/repos/ansible/test_repo/roles/common/handlers/main.yml @@ -0,0 +1,4 @@ +--- +- name: Restart common services + ansible.builtin.debug: + msg: "Restarting common services" diff --git a/test/resources/repos/ansible/test_repo/roles/common/tasks/main.yml b/test/resources/repos/ansible/test_repo/roles/common/tasks/main.yml new file mode 100644 index 00000000..5ce8ceae --- /dev/null +++ b/test/resources/repos/ansible/test_repo/roles/common/tasks/main.yml @@ -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 diff --git a/test/solidlsp/ansible/__init__.py b/test/solidlsp/ansible/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/solidlsp/ansible/test_ansible_basic.py b/test/solidlsp/ansible/test_ansible_basic.py new file mode 100644 index 00000000..ba99e6c5 --- /dev/null +++ b/test/solidlsp/ansible/test_ansible_basic.py @@ -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]}"