diff --git a/CHANGELOG.md b/CHANGELOG.md index ba9a8211..e79bc1b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 26aadc1b..92970158 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/pyproject.toml b/pyproject.toml index 86f29a47..14c27deb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/src/serena/resources/project.template.yml b/src/serena/resources/project.template.yml index f003b361..362dc7f4 100644 --- a/src/serena/resources/project.template.yml +++ b/src/serena/resources/project.template.yml @@ -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 diff --git a/src/solidlsp/language_servers/bash_language_server.py b/src/solidlsp/language_servers/bash_language_server.py index f7dd1d5e..904d51fe 100644 --- a/src/solidlsp/language_servers/bash_language_server.py +++ b/src/solidlsp/language_servers/bash_language_server.py @@ -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: diff --git a/src/solidlsp/language_servers/elixir_tools/elixir_tools.py b/src/solidlsp/language_servers/elixir_tools/elixir_tools.py index 5c7a6fb5..b5b8643e 100644 --- a/src/solidlsp/language_servers/elixir_tools/elixir_tools.py +++ b/src/solidlsp/language_servers/elixir_tools/elixir_tools.py @@ -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) diff --git a/src/solidlsp/language_servers/gopls.py b/src/solidlsp/language_servers/gopls.py index c21663b7..5b16c2dc 100644 --- a/src/solidlsp/language_servers/gopls.py +++ b/src/solidlsp/language_servers/gopls.py @@ -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.""" diff --git a/src/solidlsp/language_servers/rust_analyzer.py b/src/solidlsp/language_servers/rust_analyzer.py index e332c2e6..c8125f85 100644 --- a/src/solidlsp/language_servers/rust_analyzer.py +++ b/src/solidlsp/language_servers/rust_analyzer.py @@ -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(): diff --git a/src/solidlsp/language_servers/terraform_ls.py b/src/solidlsp/language_servers/terraform_ls.py index b20d8653..e83c6cb6 100644 --- a/src/solidlsp/language_servers/terraform_ls.py +++ b/src/solidlsp/language_servers/terraform_ls.py @@ -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) diff --git a/src/solidlsp/language_servers/typescript_language_server.py b/src/solidlsp/language_servers/typescript_language_server.py index 361d7284..51517474 100644 --- a/src/solidlsp/language_servers/typescript_language_server.py +++ b/src/solidlsp/language_servers/typescript_language_server.py @@ -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 diff --git a/src/solidlsp/language_servers/yaml_language_server.py b/src/solidlsp/language_servers/yaml_language_server.py new file mode 100644 index 00000000..16ffa979 --- /dev/null +++ b/src/solidlsp/language_servers/yaml_language_server.py @@ -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() diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index d4bfa12b..03c63dbe 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -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, ) diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 580b0c95..41f61f7b 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -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 diff --git a/src/solidlsp/ls_handler.py b/src/solidlsp/ls_handler.py index ce7b48ac..ee1dd2a7 100644 --- a/src/solidlsp/ls_handler.py +++ b/src/solidlsp/ls_handler.py @@ -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) diff --git a/test/resources/repos/yaml/test_repo/config.yaml b/test/resources/repos/yaml/test_repo/config.yaml new file mode 100644 index 00000000..f6b6ebac --- /dev/null +++ b/test/resources/repos/yaml/test_repo/config.yaml @@ -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 diff --git a/test/resources/repos/yaml/test_repo/data.yaml b/test/resources/repos/yaml/test_repo/data.yaml new file mode 100644 index 00000000..86eb2832 --- /dev/null +++ b/test/resources/repos/yaml/test_repo/data.yaml @@ -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 diff --git a/test/resources/repos/yaml/test_repo/services.yml b/test/resources/repos/yaml/test_repo/services.yml new file mode 100644 index 00000000..ce92c8a8 --- /dev/null +++ b/test/resources/repos/yaml/test_repo/services.yml @@ -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: diff --git a/test/solidlsp/yaml_ls/__init__.py b/test/solidlsp/yaml_ls/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/solidlsp/yaml_ls/test_yaml_basic.py b/test/solidlsp/yaml_ls/test_yaml_basic.py new file mode 100644 index 00000000..b1eafc83 --- /dev/null +++ b/test/solidlsp/yaml_ls/test_yaml_basic.py @@ -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"