Added YAML language server (#763)

Also minor fixes in Elixir LS and a more customizable logging (inferring log level from the log message) for LS implementations
This commit is contained in:
Adam Lichtl authored and GitHub committed 2025-11-19 03:53:22 +01:00
1 parent e26c8b5195
commit 81a0fd875c
19 files changed
+615 -27

No files matched your search

+2 -1
View File
@@ -31,9 +31,10 @@ Status of the `main` branch. Changes prior to the next official version change w
* **Add support for Zig** via ZLS (cross-file references may not fully work on Windows)
* **Add support for Lua** via lua-language-server
* **Add support for Nix** requires nixd installation (Windows not supported)
* **Add experimental support for YAML** via yaml-language-server with LSP integration for .yaml and .yml files
* **Dart now officially supported**: Dart was always working, but now tests were added, and it is promoted to "officially supported"
* **Rust now uses already installed rustup**: The rust-analyzer is no longer bundled with Serena. Instead, it uses the rust-analyzer from your Rust toolchain managed by rustup. This ensures compatibility with your Rust version and eliminates outdated bundled binaries.
* **Kotlin now officially supported**: We now use the official Kotlin LS, tests run through and performance is good, even though the LS is in an early development stage.
* **Kotlin now officially supported**: We now use the official Kotlin LS, tests run through and performance is good, even though the LS is in an early development stage.
* **Add support for Erlang** experimental, may hang or be slow, uses the recently archived [erlang_ls](https://github.com/erlang-ls/erlang_ls)
* **Ruby dual language server support**: Added ruby-lsp as the modern primary Ruby language server. Solargraph remains available as an experimental legacy option. ruby-lsp supports both .rb and .erb files, while Solargraph supports .rb files only.
+1 -1
View File
@@ -68,7 +68,7 @@ than existing solutions that charge a premium.
Language servers provide support for a wide range of programming languages.
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, Go, Haskell, Java, Javascript, Julia, Kotlin, Lua, Markdown, Nix, Perl, PHP, Python, R, Ruby, Rust, Scala, Swift, TypeScript and Zig.
AL, Bash, C#, C/C++, Clojure, Dart, Elixir, Elm, Erlang, Fortran, Go, Haskell, Java, Javascript, Julia, Kotlin, Lua, Markdown, Nix, Perl, PHP, Python, R, Ruby, Rust, Scala, Swift, TypeScript, YAML and Zig.
> [!IMPORTANT]
> Some languages require additional dependencies to be installed; see the [Language Support](https://oraios.github.io/serena/01-about/020_programming-languages.html) page for details.
+1
View File
@@ -297,6 +297,7 @@ markers = [
"julia: Julia language server tests",
"fortran: language server running for Fortran",
"haskell: Haskell language server tests",
"yaml: language server running for YAML",
]
[tool.codespell]
+1 -1
View File
@@ -4,7 +4,7 @@
# haskell java julia kotlin lua markdown
# nix perl php python python_jedi r
# rego ruby ruby_solargraph rust scala swift
# terraform typescript typescript_vts zig
# terraform typescript typescript_vts yaml zig
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
@@ -187,7 +187,8 @@ class BashLanguageServer(SolidLanguageServer):
self.logger.log("Waiting for Bash language server to be ready...", logging.INFO)
if not self.server_ready.wait(timeout=3.0):
# Fallback: assume server is ready after timeout
self.logger.log("Timeout waiting for bash server ready signal, proceeding anyway", logging.WARNING)
# This is common. bash-language-server doesn't always send explicit ready signals. Log as info
self.logger.log("Timeout waiting for bash server ready signal, proceeding anyway", logging.INFO)
self.server_ready.set()
self.completions_available.set()
else:
@@ -4,7 +4,6 @@ import pathlib
import stat
import subprocess
import threading
import time
from overrides import override
@@ -34,6 +33,15 @@ class ElixirTools(SolidLanguageServer):
# - cover: coverage reports
return super().is_ignored_dirname(dirname) or dirname in ["_build", "deps", "node_modules", ".elixir_ls", "cover"]
@override
def is_ignored_path(self, relative_path: str, ignore_unsupported_files: bool = True) -> bool:
"""Check if a path should be ignored for symbol indexing."""
if relative_path.endswith("mix.exs"):
# These are project configuration files, not source code with symbols to index
return True
return super().is_ignored_path(relative_path, ignore_unsupported_files)
def _is_next_ls_internal_file(self, abs_path: str) -> bool:
"""Check if an absolute path is a Next LS internal file that should be ignored."""
return any(
@@ -309,7 +317,7 @@ class ElixirTools(SolidLanguageServer):
self.server.on_request("client/registerCapability", register_capability_handler)
self.server.on_notification("window/logMessage", window_log_message)
self.server.on_notification("$/progress", check_server_ready)
self.server.on_notification("window/workDoneProgress/create", do_nothing)
self.server.on_request("window/workDoneProgress/create", do_nothing)
self.server.on_notification("$/workDoneProgress", work_done_progress)
self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
@@ -329,11 +337,12 @@ class ElixirTools(SolidLanguageServer):
# Next LS may not provide all capabilities immediately, so we check for basic ones
assert "textDocumentSync" in init_response["capabilities"], f"Missing textDocumentSync in {init_response['capabilities']}"
# Some capabilities might be optional or provided later
# Some capabilities might be optional or provided later. This is expected, so we log as info
if "completionProvider" not in init_response["capabilities"]:
self.logger.log("Warning: completionProvider not available in initial capabilities", logging.WARNING)
self.logger.log("completionProvider not available in initial capabilities", logging.INFO)
if "definitionProvider" not in init_response["capabilities"]:
self.logger.log("Warning: definitionProvider not available in initial capabilities", logging.WARNING)
self.logger.log("definitionProvider not available in initial capabilities", logging.INFO)
self.server.notify.initialized({})
self.completions_available.set()
@@ -346,12 +355,6 @@ class ElixirTools(SolidLanguageServer):
if self.server_ready.wait(timeout=ready_timeout):
self.logger.log("Next LS is ready and available for requests", logging.INFO)
# Add a small settling period to ensure background indexing is complete
# Next LS often continues compilation/indexing in background after ready signal
settling_time = 120.0
self.logger.log(f"Allowing {settling_time} seconds for Next LS background indexing to complete...", logging.INFO)
time.sleep(settling_time)
self.logger.log("Next LS settling period complete", logging.INFO)
else:
error_msg = f"Next LS failed to initialize within {ready_timeout} seconds. This may indicate a problem with the Elixir installation, project compilation, or Next LS itself."
self.logger.log(error_msg, logging.ERROR)
+18
View File
@@ -27,6 +27,24 @@ class Gopls(SolidLanguageServer):
# - dist/build: common output directories
return super().is_ignored_dirname(dirname) or dirname in ["vendor", "node_modules", "dist", "build"]
@staticmethod
def _determine_log_level(line: str) -> int:
"""Classify gopls stderr output to avoid false-positive errors."""
line_lower = line.lower()
# File discovery messages that are not actual errors
if any(
[
"discover.go:" in line_lower,
"walker.go:" in line_lower,
"walking of {file://" in line_lower,
"bus: -> discover" in line_lower,
]
):
return logging.DEBUG
return SolidLanguageServer._determine_log_level(line)
@staticmethod
def _get_go_version():
"""Get the installed Go version or None if not found."""
+20 -5
View File
@@ -5,7 +5,6 @@ Provides Rust specific instantiation of the LanguageServer class. Contains vario
import logging
import os
import pathlib
import shutil
import subprocess
import threading
@@ -24,6 +23,22 @@ class RustAnalyzer(SolidLanguageServer):
Provides Rust specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Rust.
"""
@staticmethod
def _determine_log_level(line: str) -> int:
"""Classify rust-analyzer stderr output to avoid false-positive errors."""
line_lower = line.lower()
# Known informational/warning messages from rust-analyzer that aren't critical errors
if any(
[
"failed to find any projects in" in line_lower,
"fetchworkspaceerror" in line_lower,
]
):
return logging.DEBUG
return SolidLanguageServer._determine_log_level(line)
@staticmethod
def _get_rustup_version():
"""Get installed rustup version or None if not found."""
@@ -37,17 +52,17 @@ class RustAnalyzer(SolidLanguageServer):
@staticmethod
def _get_rust_analyzer_path():
"""Get rust-analyzer path via rustup or system PATH."""
# First try rustup
"""Get rust-analyzer path via rustup. Returns None if not found."""
try:
# Note: we avoid using system PATH to avoid picking up incorrect aliases
result = subprocess.run(["rustup", "which", "rust-analyzer"], capture_output=True, text=True, check=False)
if result.returncode == 0:
return result.stdout.strip()
except FileNotFoundError:
pass
# Fallback to system PATH
return shutil.which("rust-analyzer")
return None
@staticmethod
def _ensure_rust_analyzer_installed():
@@ -25,6 +25,34 @@ class TerraformLS(SolidLanguageServer):
def is_ignored_dirname(self, dirname: str) -> bool:
return super().is_ignored_dirname(dirname) or dirname in [".terraform", "terraform.tfstate.d"]
@staticmethod
def _determine_log_level(line: str) -> int:
"""Classify terraform-ls stderr output to avoid false-positive errors."""
line_lower = line.lower()
# File discovery messages that are not actual errors
if any(
[
"discover.go:" in line_lower,
"walker.go:" in line_lower,
"walking of {file://" in line_lower,
"bus: -> discover" in line_lower,
]
):
return logging.DEBUG
# Known informational messages from terraform-ls that contain "error" but aren't errors
# Note: pattern match is flexible to handle file paths between keywords
if any(
[
"loading module metadata returned error:" in line_lower and "state not changed" in line_lower,
"incoming notification for" in line_lower,
]
):
return logging.DEBUG
return SolidLanguageServer._determine_log_level(line)
@staticmethod
def _ensure_tf_command_available(logger: LanguageServerLogger):
logger.log("Starting terraform version detection...", logging.DEBUG)
@@ -69,6 +69,11 @@ class TypeScriptLanguageServer(SolidLanguageServer):
"coverage",
]
@staticmethod
def _determine_log_level(line: str) -> int:
"""Classify typescript-language-server stderr output to avoid false-positive errors."""
return SolidLanguageServer._determine_log_level(line)
@classmethod
def _setup_runtime_dependencies(
cls, logger: LanguageServerLogger, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings
@@ -0,0 +1,194 @@
"""
Provides YAML specific instantiation of the LanguageServer class using yaml-language-server.
Contains various configurations and settings specific to YAML files.
"""
import logging
import os
import pathlib
import shutil
import threading
from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection
from solidlsp.ls import SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.ls_logger import LanguageServerLogger
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings
class YamlLanguageServer(SolidLanguageServer):
"""
Provides YAML specific instantiation of the LanguageServer class using yaml-language-server.
Contains various configurations and settings specific to YAML files.
"""
@staticmethod
def _determine_log_level(line: str) -> int:
"""Classify yaml-language-server stderr output to avoid false-positive errors."""
line_lower = line.lower()
# Known informational messages from yaml-language-server that aren't critical errors
if any(
[
"cannot find module" in line_lower and "package.json" in line_lower, # Schema resolution - not critical
"no parser" in line_lower, # Parser messages - informational
]
):
return logging.DEBUG
return SolidLanguageServer._determine_log_level(line)
def __init__(
self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings
):
"""
Creates a YamlLanguageServer instance. This class is not meant to be instantiated directly.
Use LanguageServer.create() instead.
"""
yaml_lsp_executable_path = self._setup_runtime_dependencies(logger, config, solidlsp_settings)
super().__init__(
config,
logger,
repository_root_path,
ProcessLaunchInfo(cmd=yaml_lsp_executable_path, cwd=repository_root_path),
"yaml",
solidlsp_settings,
)
self.server_ready = threading.Event()
@classmethod
def _setup_runtime_dependencies(
cls, logger: LanguageServerLogger, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings
) -> str:
"""
Setup runtime dependencies for YAML Language Server and return the command to start the server.
"""
# 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 NodeJS 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="yaml-language-server",
description="yaml-language-server package (Red Hat)",
command="npm install --prefix ./ yaml-language-server@1.19.2",
platform_id="any",
),
]
)
# Install yaml-language-server if not already installed
yaml_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "yaml-lsp")
yaml_executable_path = os.path.join(yaml_ls_dir, "node_modules", ".bin", "yaml-language-server")
# Handle Windows executable extension
if os.name == "nt":
yaml_executable_path += ".cmd"
if not os.path.exists(yaml_executable_path):
logger.log(f"YAML Language Server executable not found at {yaml_executable_path}. Installing...", logging.INFO)
deps.install(logger, yaml_ls_dir)
logger.log("YAML language server dependencies installed successfully", logging.INFO)
if not os.path.exists(yaml_executable_path):
raise FileNotFoundError(
f"yaml-language-server executable not found at {yaml_executable_path}, something went wrong with the installation."
)
return f"{yaml_executable_path} --stdio"
@staticmethod
def _get_initialize_params(repository_absolute_path: str) -> InitializeParams:
"""
Returns the initialize params for the YAML Language Server.
"""
root_uri = pathlib.Path(repository_absolute_path).as_uri()
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": {
"yaml": {
"schemaStore": {"enable": True, "url": "https://www.schemastore.org/api/json/catalog.json"},
"format": {"enable": True},
"validate": True,
"hover": True,
"completion": True,
}
},
}
return initialize_params
def _start_server(self):
"""
Starts the YAML Language Server, waits for the server to be ready and yields the LanguageServer instance.
"""
def register_capability_handler(params):
return
def do_nothing(params):
return
def window_log_message(msg):
self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO)
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 YAML 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)
self.logger.log(f"Received initialize response from YAML server: {init_response}", logging.DEBUG)
# Verify document symbol support is available
if "documentSymbolProvider" in init_response["capabilities"]:
self.logger.log("YAML server supports document symbols", logging.INFO)
else:
self.logger.log("Warning: YAML server does not report document symbol support", logging.WARNING)
self.server.notify.initialized({})
# YAML language server is ready immediately after initialization
self.logger.log("YAML server initialization complete", logging.INFO)
self.server_ready.set()
self.completions_available.set()
+21
View File
@@ -151,6 +151,26 @@ class SolidLanguageServer(ABC):
"""
return dirname.startswith(".")
@staticmethod
def _determine_log_level(line: str) -> int:
"""
Classify a stderr line from the language server to determine appropriate logging level.
Language servers may emit informational messages to stderr that contain words like "error"
but are not actual errors. Subclasses can override this method to filter out known
false-positive patterns specific to their language server.
:param line: The stderr line to classify
:return: A logging level (logging.DEBUG, logging.INFO, logging.WARNING, or logging.ERROR)
"""
line_lower = line.lower()
# Default classification: treat lines with "error" or "exception" as ERROR level
if "error" in line_lower or "exception" in line_lower or line.startswith("E["):
return logging.ERROR
else:
return logging.INFO
@classmethod
def get_language_enum_instance(cls) -> Language:
return Language.from_ls_class(cls)
@@ -291,6 +311,7 @@ class SolidLanguageServer(ABC):
self.server = SolidLanguageServerHandler(
process_launch_info,
language=self.language,
determine_log_level=self._determine_log_level,
logger=logging_fn,
start_independent_lsp_process=config.start_independent_lsp_process,
)
+11 -1
View File
@@ -78,6 +78,10 @@ class Language(str, Enum):
Must be explicitly specified as the main language, not auto-detected.
This is an edge case primarily useful when working on documentation-heavy projects.
"""
YAML = "yaml"
"""YAML language server (experimental).
Must be explicitly specified as the main language, not auto-detected.
"""
@classmethod
def iter_all(cls, include_experimental: bool = False) -> Iterable[Self]:
@@ -89,7 +93,7 @@ class Language(str, Enum):
"""
Check if the language server is experimental or deprecated.
"""
return self in {self.TYPESCRIPT_VTS, self.PYTHON_JEDI, self.CSHARP_OMNISHARP, self.RUBY_SOLARGRAPH, self.MARKDOWN}
return self in {self.TYPESCRIPT_VTS, self.PYTHON_JEDI, self.CSHARP_OMNISHARP, self.RUBY_SOLARGRAPH, self.MARKDOWN, self.YAML}
def __str__(self) -> str:
return self.value
@@ -142,6 +146,8 @@ class Language(str, Enum):
return FilenameMatcher("*.swift")
case self.BASH:
return FilenameMatcher("*.sh", "*.bash")
case self.YAML:
return FilenameMatcher("*.yaml", "*.yml")
case self.ZIG:
return FilenameMatcher("*.zig", "*.zon")
case self.LUA:
@@ -259,6 +265,10 @@ class Language(str, Enum):
from solidlsp.language_servers.bash_language_server import BashLanguageServer
return BashLanguageServer
case self.YAML:
from solidlsp.language_servers.yaml_language_server import YamlLanguageServer
return YamlLanguageServer
case self.ZIG:
from solidlsp.language_servers.zls import ZigLanguageServer
+3 -6
View File
@@ -54,7 +54,6 @@ class LanguageServerTerminatedException(Exception):
class Request(ToStringMixin):
@dataclass
class Result:
payload: PayloadLike | None = None
@@ -135,11 +134,13 @@ class SolidLanguageServerHandler:
self,
process_launch_info: ProcessLaunchInfo,
language: Language,
determine_log_level: Callable[[str], int],
logger: Callable[[str, str, StringDict | str], None] | None = None,
start_independent_lsp_process=True,
request_timeout: float | None = None,
) -> None:
self.language = language
self._determine_log_level = determine_log_level
self.send = LanguageServerRequest(self)
self.notify = LspNotification(self.send_notification)
@@ -385,11 +386,7 @@ class SolidLanguageServerHandler:
if not line:
continue
line = line.decode(ENCODING, errors="replace")
line_lower = line.lower()
if "error" in line_lower or "exception" in line_lower or line.startswith("E["):
level = logging.ERROR
else:
level = logging.INFO
level = self._determine_log_level(line)
log.log(level, line)
except Exception as e:
log.error("Error while reading stderr from language server process: %s", e, exc_info=e)
@@ -0,0 +1,25 @@
# Application configuration
app:
name: test-application
version: 1.0.0
port: 8080
debug: true
database:
host: localhost
port: 5432
name: testdb
username: admin
password: secret123
logging:
level: info
format: json
outputs:
- console
- file
features:
authentication: true
caching: false
monitoring: true
@@ -0,0 +1,41 @@
# Sample data structure
users:
- id: 1
name: John Doe
email: john@example.com
roles:
- admin
- developer
active: true
- id: 2
name: Jane Smith
email: jane@example.com
roles:
- developer
active: true
- id: 3
name: Bob Johnson
email: bob@example.com
roles:
- viewer
active: false
projects:
- name: project-alpha
status: active
team:
- John Doe
- Jane Smith
tags:
- backend
- api
- name: project-beta
status: planning
team:
- Jane Smith
tags:
- frontend
- ui
@@ -0,0 +1,51 @@
# Docker compose services definition
version: '3.8'
services:
web:
image: nginx:latest
ports:
- "80:80"
- "443:443"
volumes:
- ./html:/usr/share/nginx/html
environment:
- NGINX_HOST=localhost
- NGINX_PORT=80
networks:
- frontend
api:
image: node:18
ports:
- "3000:3000"
depends_on:
- database
environment:
- NODE_ENV=production
- DB_HOST=database
networks:
- frontend
- backend
database:
image: postgres:15
ports:
- "5432:5432"
environment:
- POSTGRES_DB=mydb
- POSTGRES_USER=admin
- POSTGRES_PASSWORD=password
volumes:
- db-data:/var/lib/postgresql/data
networks:
- backend
networks:
frontend:
driver: bridge
backend:
driver: bridge
volumes:
db-data:
View File
Whitespace-only changes.
+177
View File
@@ -0,0 +1,177 @@
"""
Basic integration tests for the YAML language server functionality.
These tests validate the functionality of the language server APIs
like request_document_symbols using the YAML test repository.
"""
from pathlib import Path
import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
@pytest.mark.yaml
class TestYAMLLanguageServerBasics:
"""Test basic functionality of the YAML language server."""
@pytest.mark.parametrize("language_server", [Language.YAML], indirect=True)
@pytest.mark.parametrize("repo_path", [Language.YAML], indirect=True)
def test_yaml_language_server_initialization(self, language_server: SolidLanguageServer, repo_path: Path) -> None:
"""Test that YAML language server can be initialized successfully."""
assert language_server is not None
assert language_server.language == Language.YAML
assert language_server.is_running()
assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve()
@pytest.mark.parametrize("language_server", [Language.YAML], indirect=True)
@pytest.mark.parametrize("repo_path", [Language.YAML], indirect=True)
def test_yaml_config_file_symbols(self, language_server: SolidLanguageServer, repo_path: Path) -> None:
"""Test document symbols detection in config.yaml with specific symbol verification."""
all_symbols, root_symbols = language_server.request_document_symbols("config.yaml").get_all_symbols_and_roots()
assert all_symbols is not None, "Should return symbols for config.yaml"
assert len(all_symbols) > 0, f"Should find symbols in config.yaml, found {len(all_symbols)}"
# Verify specific top-level keys are detected
symbol_names = [sym.get("name") for sym in all_symbols]
assert "app" in symbol_names, "Should detect 'app' key in config.yaml"
assert "database" in symbol_names, "Should detect 'database' key in config.yaml"
assert "logging" in symbol_names, "Should detect 'logging' key in config.yaml"
assert "features" in symbol_names, "Should detect 'features' key in config.yaml"
# Verify nested symbols exist (child keys under 'app')
assert "name" in symbol_names, "Should detect nested 'name' key"
assert "port" in symbol_names, "Should detect nested 'port' key"
assert "debug" in symbol_names, "Should detect nested 'debug' key"
# Check symbol kinds are appropriate (LSP kinds: 2=module/namespace, 15=string, 16=number, 17=boolean)
app_symbol = next((s for s in all_symbols if s.get("name") == "app"), None)
assert app_symbol is not None, "Should find 'app' symbol"
assert app_symbol.get("kind") == 2, "Top-level object should have kind 2 (module/namespace)"
port_symbol = next((s for s in all_symbols if s.get("name") == "port"), None)
assert port_symbol is not None, "Should find 'port' symbol"
assert port_symbol.get("kind") == 16, "'port' should have kind 16 (number)"
debug_symbol = next((s for s in all_symbols if s.get("name") == "debug"), None)
assert debug_symbol is not None, "Should find 'debug' symbol"
assert debug_symbol.get("kind") == 17, "'debug' should have kind 17 (boolean)"
@pytest.mark.parametrize("language_server", [Language.YAML], indirect=True)
@pytest.mark.parametrize("repo_path", [Language.YAML], indirect=True)
def test_yaml_services_file_symbols(self, language_server: SolidLanguageServer, repo_path: Path) -> None:
"""Test symbol detection in services.yml Docker Compose file."""
all_symbols, root_symbols = language_server.request_document_symbols("services.yml").get_all_symbols_and_roots()
assert all_symbols is not None, "Should return symbols for services.yml"
assert len(all_symbols) > 0, f"Should find symbols in services.yml, found {len(all_symbols)}"
# Verify specific top-level keys from Docker Compose file
symbol_names = [sym.get("name") for sym in all_symbols]
assert "version" in symbol_names, "Should detect 'version' key"
assert "services" in symbol_names, "Should detect 'services' key"
assert "networks" in symbol_names, "Should detect 'networks' key"
assert "volumes" in symbol_names, "Should detect 'volumes' key"
# Verify service names
assert "web" in symbol_names, "Should detect 'web' service"
assert "api" in symbol_names, "Should detect 'api' service"
assert "database" in symbol_names, "Should detect 'database' service"
# Check that arrays are properly detected
ports_symbols = [s for s in all_symbols if s.get("name") == "ports"]
assert len(ports_symbols) > 0, "Should find 'ports' arrays in services"
# Arrays should have kind 18
for ports_sym in ports_symbols:
assert ports_sym.get("kind") == 18, "'ports' should have kind 18 (array)"
@pytest.mark.parametrize("language_server", [Language.YAML], indirect=True)
@pytest.mark.parametrize("repo_path", [Language.YAML], indirect=True)
def test_yaml_data_file_symbols(self, language_server: SolidLanguageServer, repo_path: Path) -> None:
"""Test symbol detection in data.yaml file with array structures."""
all_symbols, root_symbols = language_server.request_document_symbols("data.yaml").get_all_symbols_and_roots()
assert all_symbols is not None, "Should return symbols for data.yaml"
assert len(all_symbols) > 0, f"Should find symbols in data.yaml, found {len(all_symbols)}"
# Verify top-level keys
symbol_names = [sym.get("name") for sym in all_symbols]
assert "users" in symbol_names, "Should detect 'users' array"
assert "projects" in symbol_names, "Should detect 'projects' array"
# Verify array elements (indexed by position)
# data.yaml has user entries and project entries
assert "id" in symbol_names, "Should detect 'id' fields in array elements"
assert "name" in symbol_names, "Should detect 'name' fields"
assert "email" in symbol_names, "Should detect 'email' fields"
assert "roles" in symbol_names, "Should detect 'roles' arrays"
@pytest.mark.parametrize("language_server", [Language.YAML], indirect=True)
@pytest.mark.parametrize("repo_path", [Language.YAML], indirect=True)
def test_yaml_symbols_with_body(self, language_server: SolidLanguageServer, repo_path: Path) -> None:
"""Test request_document_symbols with body extraction."""
all_symbols, root_symbols = language_server.request_document_symbols("config.yaml").get_all_symbols_and_roots()
assert all_symbols is not None, "Should return symbols for config.yaml"
assert len(all_symbols) > 0, "Should have symbols"
# Find the 'app' symbol and verify its body
app_symbol = next((s for s in all_symbols if s.get("name") == "app"), None)
assert app_symbol is not None, "Should find 'app' symbol"
# Check that body exists and contains expected content
assert "body" in app_symbol, "'app' symbol should have body"
app_body = app_symbol["body"]
assert "app:" in app_body, "Body should start with 'app:'"
assert "name: test-application" in app_body, "Body should contain 'name' field"
assert "version: 1.0.0" in app_body, "Body should contain 'version' field"
assert "port: 8080" in app_body, "Body should contain 'port' field"
assert "debug: true" in app_body, "Body should contain 'debug' field"
# Find a simple string value symbol and verify its body
name_symbols = [s for s in all_symbols if s.get("name") == "name" and "body" in s]
assert len(name_symbols) > 0, "Should find 'name' symbols with bodies"
# At least one should contain "test-application"
assert any("test-application" in s["body"] for s in name_symbols), "Should find name with test-application"
# Find the database symbol and check its body
database_symbol = next((s for s in all_symbols if s.get("name") == "database"), None)
assert database_symbol is not None, "Should find 'database' symbol"
assert "body" in database_symbol, "'database' symbol should have body"
db_body = database_symbol["body"]
assert "database:" in db_body, "Body should start with 'database:'"
assert "host: localhost" in db_body, "Body should contain host configuration"
assert "port: 5432" in db_body, "Body should contain port configuration"
@pytest.mark.parametrize("language_server", [Language.YAML], indirect=True)
@pytest.mark.parametrize("repo_path", [Language.YAML], indirect=True)
def test_yaml_symbol_ranges(self, language_server: SolidLanguageServer, repo_path: Path) -> None:
"""Test that symbols have proper range information."""
all_symbols, root_symbols = language_server.request_document_symbols("config.yaml").get_all_symbols_and_roots()
assert all_symbols is not None
assert len(all_symbols) > 0
# Check the 'app' symbol range
app_symbol = next((s for s in all_symbols if s.get("name") == "app"), None)
assert app_symbol is not None, "Should find 'app' symbol"
assert "range" in app_symbol, "'app' symbol should have range"
app_range = app_symbol["range"]
assert "start" in app_range, "Range should have start"
assert "end" in app_range, "Range should have end"
assert app_range["start"]["line"] == 1, "'app' should start at line 1 (0-indexed, actual line 2)"
# The app block spans from line 2 to line 7 in the file (1-indexed)
# In 0-indexed LSP coordinates: line 1 (start) to line 6 (end)
assert app_range["end"]["line"] == 6, "'app' should end at line 6 (0-indexed)"
# Check a nested symbol range
port_symbols = [s for s in all_symbols if s.get("name") == "port"]
assert len(port_symbols) > 0, "Should find 'port' symbols"
# Find the one under 'app' (should be at line 4 in 0-indexed, actual line 5)
app_port = next((s for s in port_symbols if s["range"]["start"]["line"] == 4), None)
assert app_port is not None, "Should find 'port' under 'app'"
assert app_port["range"]["start"]["character"] == 2, "'port' should be indented 2 spaces"