"""Step definitions for LSP Registry, Runtime, and Tool Adapter tests. Covers LspRegistry, LspRuntime, LspToolAdapter, LspBinding, LspServerConfig, and the LSP error hierarchy. """ from __future__ import annotations from typing import Any from behave import given, then, when from behave.runner import Context from pydantic import ValidationError as PydanticValidationError from cleveragents.core.exceptions import CleverAgentsError from cleveragents.lsp.errors import ( LspError, LspNotAvailableError, LspServerNotFoundError, ) from cleveragents.lsp.models import ( LspBinding, LspCapability, LspServerConfig, ) from cleveragents.lsp.registry import LspRegistry from cleveragents.lsp.runtime import LspRuntime from cleveragents.lsp.tool_adapter import LspToolAdapter # --------------------------------------------------------------------------- # Capability name -> enum mapping # --------------------------------------------------------------------------- _CAP_MAP: dict[str, LspCapability] = {c.value: c for c in LspCapability} # --------------------------------------------------------------------------- # Background # --------------------------------------------------------------------------- @given("a clean LSP registry") def step_clean_lsp_registry(context: Context) -> None: context.lsp_registry = LspRegistry() context.lsp_config: LspServerConfig | None = None context.lsp_result: Any = None context.lsp_error: Exception | None = None context.lsp_tool_specs: list[dict[str, Any]] = [] # --------------------------------------------------------------------------- # Config creation # --------------------------------------------------------------------------- @given( 'an LSP server config named "{name}" for language "{language}" ' 'with command "{command}"' ) def step_lsp_config_single_lang( context: Context, name: str, language: str, command: str ) -> None: context.lsp_config = LspServerConfig( name=name, languages=[language.strip().lower()], command=command, ) @given( 'an LSP server config named "{name}" for languages "{languages}" ' 'with command "{command}"' ) def step_lsp_config_multi_lang( context: Context, name: str, languages: str, command: str ) -> None: lang_list = [lang.strip().lower() for lang in languages.split(",")] context.lsp_config = LspServerConfig( name=name, languages=lang_list, command=command, ) @given('the LSP server config has capabilities "{caps_str}"') def step_lsp_config_capabilities(context: Context, caps_str: str) -> None: caps = [_CAP_MAP[c.strip()] for c in caps_str.split(",")] assert context.lsp_config is not None context.lsp_config = context.lsp_config.model_copy(update={"capabilities": caps}) # --------------------------------------------------------------------------- # Register # --------------------------------------------------------------------------- @when("the LSP server is registered") @given("the LSP server is registered") def step_register_lsp_server(context: Context) -> None: assert context.lsp_config is not None context.lsp_registry.register(context.lsp_config) @when('a second LSP server with the same name "{name}" is registered') def step_register_duplicate(context: Context, name: str) -> None: dup = LspServerConfig(name=name, command="dup-cmd", languages=["python"]) try: context.lsp_registry.register(dup) context.lsp_error = None except ValueError as exc: context.lsp_error = exc @given("the following LSP servers are registered:") def step_register_table(context: Context) -> None: for row in context.table: config = LspServerConfig( name=row["name"], languages=[row["language"].strip().lower()], command=row["command"], ) context.lsp_registry.register(config) # --------------------------------------------------------------------------- # Lookup # --------------------------------------------------------------------------- @when('I look up LSP server "{name}"') def step_lookup(context: Context, name: str) -> None: context.lsp_result = context.lsp_registry.get(name) @when('I look up LSP server "{name}" with get_or_raise') def step_lookup_or_raise(context: Context, name: str) -> None: try: context.lsp_result = context.lsp_registry.get_or_raise(name) context.lsp_error = None except LspServerNotFoundError as exc: context.lsp_error = exc # --------------------------------------------------------------------------- # List # --------------------------------------------------------------------------- @when("I list all LSP servers") def step_list_all(context: Context) -> None: context.lsp_result = context.lsp_registry.list_servers() @when('I list LSP servers with namespace "{namespace}"') def step_list_ns(context: Context, namespace: str) -> None: context.lsp_result = context.lsp_registry.list_servers(namespace=namespace) @when('I list LSP servers with language "{language}"') def step_list_lang(context: Context, language: str) -> None: context.lsp_result = context.lsp_registry.list_servers(language=language) # --------------------------------------------------------------------------- # Remove # --------------------------------------------------------------------------- @when('I remove LSP server "{name}"') def step_remove(context: Context, name: str) -> None: context.lsp_result = context.lsp_registry.remove(name) # --------------------------------------------------------------------------- # Runtime # --------------------------------------------------------------------------- @given("a local-mode LSP runtime") def step_lsp_runtime(context: Context) -> None: context.lsp_runtime = LspRuntime() @when('I call start_server on the runtime with name "{name}" and workspace "{ws}"') def step_runtime_start(context: Context, name: str, ws: str) -> None: try: context.lsp_runtime.start_server(name, ws) context.lsp_error = None except (LspNotAvailableError, LspServerNotFoundError, LspError) as exc: context.lsp_error = exc @when('I call stop_server on the runtime with name "{name}"') def step_runtime_stop(context: Context, name: str) -> None: try: context.lsp_runtime.stop_server(name) context.lsp_error = None except (LspNotAvailableError, LspServerNotFoundError, LspError) as exc: context.lsp_error = exc @when('I call get_diagnostics on the runtime with name "{name}" and file "{file_path}"') def step_runtime_diagnostics(context: Context, name: str, file_path: str) -> None: try: context.lsp_result = context.lsp_runtime.get_diagnostics(name, file_path) context.lsp_error = None except (LspNotAvailableError, LspServerNotFoundError, LspError) as exc: context.lsp_error = exc @when( 'I call get_completions on the runtime with name "{name}" ' 'file "{file_path}" line {line:d} column {col:d}' ) def step_runtime_completions( context: Context, name: str, file_path: str, line: int, col: int ) -> None: try: context.lsp_result = context.lsp_runtime.get_completions( name, file_path, line, col ) context.lsp_error = None except (LspNotAvailableError, LspServerNotFoundError, LspError) as exc: context.lsp_error = exc # --------------------------------------------------------------------------- # Tool adapter # --------------------------------------------------------------------------- @when("I generate tool specs from the adapter") def step_generate_specs(context: Context) -> None: adapter = LspToolAdapter() assert context.lsp_config is not None context.lsp_tool_specs = adapter.generate_tool_specs(context.lsp_config) @when("I invoke the first tool spec handler") def step_invoke_handler(context: Context) -> None: assert context.lsp_tool_specs, "No tool specs generated" handler = context.lsp_tool_specs[0]["handler"] try: handler() context.lsp_error = None except LspNotAvailableError as exc: context.lsp_error = exc # --------------------------------------------------------------------------- # Binding # --------------------------------------------------------------------------- @when('I create a binding for node "{node}" to server "{server}"') def step_create_binding(context: Context, node: str, server: str) -> None: try: context.lsp_binding = LspBinding( node_name=node, lsp_server_name=server, ) context.lsp_error = None except PydanticValidationError as exc: context.lsp_error = exc # --------------------------------------------------------------------------- # Model validation # --------------------------------------------------------------------------- @when('I try to create an LSP server config with name "{name}" and command "{command}"') def step_invalid_config(context: Context, name: str, command: str) -> None: try: context.lsp_config = LspServerConfig(name=name, command=command) context.lsp_error = None except PydanticValidationError as exc: context.lsp_error = exc # --------------------------------------------------------------------------- # Error creation # --------------------------------------------------------------------------- @when("I create an LspNotAvailableError with default message") def step_create_not_available_error(context: Context) -> None: context.lsp_error = LspNotAvailableError() @when('I create an LspServerNotFoundError for server "{name}"') def step_create_not_found_error(context: Context, name: str) -> None: context.lsp_error = LspServerNotFoundError(name) @when("I try to create an LspServerNotFoundError with empty name") def step_create_not_found_empty(context: Context) -> None: try: LspServerNotFoundError("") context.lsp_error = None except ValueError as exc: context.lsp_error = exc # --------------------------------------------------------------------------- # Assertions # --------------------------------------------------------------------------- @then('the LSP registry should contain "{name}"') def step_registry_contains(context: Context, name: str) -> None: assert name in context.lsp_registry, f"Registry should contain '{name}'" @then("the LSP registry should have {count:d} server") @then("the LSP registry should have {count:d} servers") def step_registry_count(context: Context, count: int) -> None: assert len(context.lsp_registry) == count, ( f"Expected {count} servers, got {len(context.lsp_registry)}" ) @then('the LSP server "{name}" should support language "{lang}"') def step_server_supports_lang(context: Context, name: str, lang: str) -> None: config = context.lsp_registry.get(name) assert config is not None, f"Server '{name}' not found" assert lang.lower() in config.languages, f"Server '{name}' should support '{lang}'" @then('the LSP server "{name}" should have {count:d} capabilities') def step_server_cap_count(context: Context, name: str, count: int) -> None: config = context.lsp_registry.get(name) assert config is not None, f"Server '{name}' not found" assert len(config.capabilities) == count, ( f"Expected {count} capabilities, got {len(config.capabilities)}" ) @then("the LSP lookup result should not be None") def step_result_not_none(context: Context) -> None: assert context.lsp_result is not None @then("the LSP lookup result should be None") def step_result_none(context: Context) -> None: assert context.lsp_result is None @then('the LSP lookup result name should be "{name}"') def step_result_name(context: Context, name: str) -> None: assert context.lsp_result.name == name @then("the LSP server list should contain {count:d} servers") def step_list_count(context: Context, count: int) -> None: assert len(context.lsp_result) == count, ( f"Expected {count}, got {len(context.lsp_result)}" ) @then("the LSP removal result should be True") def step_removal_true(context: Context) -> None: assert context.lsp_result is True @then("the LSP removal result should be False") def step_removal_false(context: Context) -> None: assert context.lsp_result is False # NOTE: The @then step 'a ValueError should be raised mentioning ...' # is defined in config_service_coverage_steps.py (shared step). @then("an LspNotAvailableError should be raised") def step_not_available_error(context: Context) -> None: assert context.lsp_error is not None, "Expected an error" assert isinstance(context.lsp_error, LspNotAvailableError), ( f"Expected LspNotAvailableError, got {type(context.lsp_error).__name__}" ) @then("an LSP error should be raised") def step_any_lsp_error(context: Context) -> None: assert context.lsp_error is not None, "Expected an LSP error" assert isinstance(context.lsp_error, LspError), ( f"Expected LspError subclass, got {type(context.lsp_error).__name__}" ) @then('the LSP error message should mention "{text}"') @then('the LSP error message should contain "{text}"') def step_lsp_error_mentions(context: Context, text: str) -> None: assert context.lsp_error is not None assert text in str(context.lsp_error), ( f"Error '{context.lsp_error}' should mention '{text}'" ) @then('an LspServerNotFoundError should be raised for "{name}"') def step_not_found_error(context: Context, name: str) -> None: assert context.lsp_error is not None, "Expected an error" assert isinstance(context.lsp_error, LspServerNotFoundError), ( f"Expected LspServerNotFoundError, got {type(context.lsp_error).__name__}" ) assert name in str(context.lsp_error) @then("the tool spec list should contain {count:d} specs") def step_spec_count(context: Context, count: int) -> None: assert len(context.lsp_tool_specs) == count, ( f"Expected {count} specs, got {len(context.lsp_tool_specs)}" ) @then('the tool spec names should include "{name}"') def step_spec_name_includes(context: Context, name: str) -> None: names = [s["name"] for s in context.lsp_tool_specs] assert name in names, f"Tool spec '{name}' not in {names}" @then("the binding should be valid") def step_binding_valid(context: Context) -> None: assert context.lsp_error is None, f"Unexpected error: {context.lsp_error}" assert hasattr(context, "lsp_binding"), "No binding created" @then('the binding node_name should be "{name}"') def step_binding_node(context: Context, name: str) -> None: assert context.lsp_binding.node_name == name @then('the binding lsp_server_name should be "{name}"') def step_binding_server(context: Context, name: str) -> None: assert context.lsp_binding.lsp_server_name == name @then("the binding auto_detect should be True") def step_binding_auto_detect(context: Context) -> None: assert context.lsp_binding.auto_detect is True # NOTE: The @then step 'a ValidationError should be raised mentioning ...' # is defined in config_service_coverage_steps.py (shared step). @then('the LSP server config language "{lang}" should be present') def step_config_lang_present(context: Context, lang: str) -> None: assert context.lsp_config is not None assert lang in context.lsp_config.languages @then('the LSP server config namespace should be "{ns}"') def step_config_namespace(context: Context, ns: str) -> None: assert context.lsp_config is not None assert context.lsp_config.namespace == ns @then('the LSP server config short_name should be "{short}"') def step_config_short_name(context: Context, short: str) -> None: assert context.lsp_config is not None assert context.lsp_config.short_name == short @then("LspError should be a subclass of CleverAgentsError") def step_lsp_error_hierarchy(context: Context) -> None: assert issubclass(LspError, CleverAgentsError) @then('the error server_name should be "{name}"') def step_error_server_name(context: Context, name: str) -> None: assert hasattr(context.lsp_error, "server_name"), "Error has no server_name" assert context.lsp_error.server_name == name @then("a ValueError should be raised") def step_value_error_raised(context: Context) -> None: assert context.lsp_error is not None, "Expected a ValueError" assert isinstance(context.lsp_error, ValueError), ( f"Expected ValueError, got {type(context.lsp_error).__name__}" )