diff --git a/CHANGELOG.md b/CHANGELOG.md index 20e39198..b8be9db6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +- Implemented functional LSP runtime replacing local-mode stubs with real + LSP protocol support. `StdioTransport` manages server subprocesses via + JSON-RPC over stdin/stdout. `LspClient` implements initialize/shutdown/ + diagnostics/completions. `LspLifecycleManager` provides reference-counted + instances, health checks, and crash restart. `LanguageDiscovery` implements + 4-layer detection (extension, shebang, UKO, project config). Includes + 27 Behave BDD scenarios and 6 Robot integration tests. (#826) - Aligned plan lifecycle model with specification: ERRORED is now terminal in `is_terminal`, per-phase state validation enforces APPLIED/CONSTRAINED to APPLY-only and COMPLETE to diff --git a/features/consolidated_misc.feature b/features/consolidated_misc.feature index e52c3634..cbf85605 100644 --- a/features/consolidated_misc.feature +++ b/features/consolidated_misc.feature @@ -1335,41 +1335,40 @@ Feature: Consolidated Misc Then the LSP removal result should be False # --------------------------------------------------------------------------- - # Runtime stub raises LspNotAvailableError + # Runtime raises LspServerNotFoundError for unregistered servers # --------------------------------------------------------------------------- @lsp_runtime - Scenario: Runtime start_server raises LspNotAvailableError + Scenario: Runtime start_server raises error for unregistered server Given a clean LSP registry Given a local-mode LSP runtime When I call start_server on the runtime with name "local/pyright" and workspace "/tmp" - Then an LspNotAvailableError should be raised - And the LSP error message should mention "local mode" + Then an LSP error should be raised @lsp_runtime - Scenario: Runtime stop_server raises LspNotAvailableError + Scenario: Runtime stop_server raises error for unregistered server Given a clean LSP registry Given a local-mode LSP runtime When I call stop_server on the runtime with name "local/pyright" - Then an LspNotAvailableError should be raised + Then an LSP error should be raised @lsp_runtime - Scenario: Runtime get_diagnostics raises LspNotAvailableError + Scenario: Runtime get_diagnostics raises error for unregistered server Given a clean LSP registry Given a local-mode LSP runtime When I call get_diagnostics on the runtime with name "local/pyright" and file "/tmp/test.py" - Then an LspNotAvailableError should be raised + Then an LSP error should be raised @lsp_runtime - Scenario: Runtime get_completions raises LspNotAvailableError + Scenario: Runtime get_completions raises error for unregistered server Given a clean LSP registry Given a local-mode LSP runtime When I call get_completions on the runtime with name "local/pyright" file "/tmp/test.py" line 1 column 1 - Then an LspNotAvailableError should be raised + Then an LSP error should be raised # --------------------------------------------------------------------------- # Tool adapter generates tool specs from capabilities diff --git a/features/lsp_functional_runtime.feature b/features/lsp_functional_runtime.feature new file mode 100644 index 00000000..18a60145 --- /dev/null +++ b/features/lsp_functional_runtime.feature @@ -0,0 +1,149 @@ +Feature: Functional LSP Runtime + As an actor runtime + I need LSP servers to provide real code intelligence + So that actors can get diagnostics and completions + + # ── StdioTransport ──────────────────────────────────────────────── + + Scenario: StdioTransport rejects empty command + When I try to create a StdioTransport with empty command + Then a ValueError should be raised for transport + + Scenario: StdioTransport reports not alive before start + Given I create a StdioTransport for "echo" + Then the transport should not be alive + + Scenario: StdioTransport start and stop with echo + Given I create a StdioTransport for "cat" + When I start the transport + Then the transport should be alive + When I stop the transport + Then the transport should not be alive + + # ── LspClient ───────────────────────────────────────────────────── + + Scenario: LspClient starts not initialized + Given I create an LspClient with a mock transport + Then the client should not be initialized + + Scenario: LspClient tracks request IDs + Given I create an LspClient with a mock transport + When I get two request IDs from the client + Then the IDs should be sequential + + # ── LspLifecycleManager ────────────────────────────────────────── + + Scenario: LspLifecycleManager starts empty + Given I create an LspLifecycleManager + Then the manager should have no running servers + + Scenario: LspLifecycleManager stop unknown server raises error + Given I create an LspLifecycleManager + When I try to stop server "local/unknown" on the manager + Then an LspServerNotFoundError should be raised for lifecycle + + Scenario: LspLifecycleManager health check returns false for unknown + Given I create an LspLifecycleManager + Then health check for "local/unknown" should return false + + # ── LspRuntime with registry ───────────────────────────────────── + + Scenario: LspRuntime start_server rejects empty name + Given I create a functional LspRuntime + When I try to start server with empty name and workspace "/tmp" + Then a ValueError should be raised for runtime + + Scenario: LspRuntime start_server rejects empty workspace + Given I create a functional LspRuntime + When I try to start server "local/test" with empty workspace + Then a ValueError should be raised for runtime + + Scenario: LspRuntime start_server raises for unregistered server + Given I create a functional LspRuntime + When I try to start unregistered server "local/missing" + Then an LspServerNotFoundError should be raised for runtime + + Scenario: LspRuntime get_diagnostics rejects empty name + Given I create a functional LspRuntime + When I try to get diagnostics with empty name + Then a ValueError should be raised for runtime + + Scenario: LspRuntime get_completions validates line >= 1 + Given I create a functional LspRuntime + When I try to get completions with line 0 + Then a ValueError should be raised for runtime + + Scenario: LspRuntime get_completions validates column >= 1 + Given I create a functional LspRuntime + When I try to get completions with column 0 + Then a ValueError should be raised for runtime + + # ── LanguageDiscovery ──────────────────────────────────────────── + + Scenario: LanguageDiscovery detects Python from extension + Given I create a LanguageDiscovery instance + Then language for "test.py" should be "python" + And language for "test.pyi" should be "python" + + Scenario: LanguageDiscovery detects TypeScript from extension + Given I create a LanguageDiscovery instance + Then language for "app.ts" should be "typescript" + And language for "app.tsx" should be "typescriptreact" + + Scenario: LanguageDiscovery detects Rust from extension + Given I create a LanguageDiscovery instance + Then language for "main.rs" should be "rust" + + Scenario: LanguageDiscovery returns plaintext for unknown + Given I create a LanguageDiscovery instance + Then language for "data.xyz" should be "plaintext" + + Scenario: LanguageDiscovery detects Dockerfile by name + Given I create a LanguageDiscovery instance + Then language for "Dockerfile" should be "dockerfile" + + Scenario: LanguageDiscovery uses project language as fallback + Given I create a LanguageDiscovery with project language "go" + Then language for "unknown_file" should be "go" + + Scenario: LanguageDiscovery cache invalidation works + Given I create a LanguageDiscovery instance + When I detect language for "test.py" + And I invalidate "test.py" from discovery cache + Then the cache should not contain "test.py" + + Scenario: LanguageDiscovery detects shebang for Python script + Given I create a LanguageDiscovery instance + And I create a temp file with shebang "#!/usr/bin/env python3" + Then the temp file language should be "python" + + Scenario: LanguageDiscovery detects shebang for bash script + Given I create a LanguageDiscovery instance + And I create a temp file with shebang "#!/bin/bash" + Then the temp file language should be "shellscript" + + # ── LspToolAdapter with runtime ────────────────────────────────── + + Scenario: LspToolAdapter generates runtime-backed handlers when runtime provided + Given I create an LspToolAdapter with a functional runtime + And I have an LSP server config with diagnostics capability + When I generate functional tool specs from the adapter + Then the tool specs should have runtime handlers + + Scenario: LspToolAdapter generates local-mode handlers without runtime + Given I create an LspToolAdapter without runtime + And I have an LSP server config with diagnostics capability + When I generate functional tool specs from the adapter + Then the tool specs should have local-mode handlers + + # ── Binding activation ─────────────────────────────────────────── + + Scenario: LspRuntime activate_bindings with no bindings returns empty + Given I create a functional LspRuntime + When I activate bindings with empty list + Then the activated servers list should be empty + + Scenario: LspRuntime activate_bindings skips unregistered servers + Given I create a functional LspRuntime + When I activate bindings with unregistered server binding + Then the activated servers list should be empty diff --git a/features/steps/lsp_functional_runtime_steps.py b/features/steps/lsp_functional_runtime_steps.py new file mode 100644 index 00000000..3d77675b --- /dev/null +++ b/features/steps/lsp_functional_runtime_steps.py @@ -0,0 +1,342 @@ +"""Step definitions for the functional LSP runtime feature tests.""" + +from __future__ import annotations + +import os +import tempfile +from unittest.mock import MagicMock, create_autospec + +from behave import given, then, when +from behave.runner import Context + +from cleveragents.lsp.client import LspClient +from cleveragents.lsp.discovery import LanguageDiscovery +from cleveragents.lsp.errors import LspNotAvailableError, LspServerNotFoundError +from cleveragents.lsp.lifecycle import LspLifecycleManager +from cleveragents.lsp.models import LspCapability, LspServerConfig +from cleveragents.lsp.runtime import LspRuntime +from cleveragents.lsp.tool_adapter import LspToolAdapter +from cleveragents.lsp.transport import StdioTransport + +# --------------------------------------------------------------------------- +# StdioTransport steps +# --------------------------------------------------------------------------- + + +@when("I try to create a StdioTransport with empty command") +def step_transport_empty_command(context: Context) -> None: + try: + StdioTransport(command="") + context.transport_error = None + except ValueError as exc: + context.transport_error = exc + + +@then("a ValueError should be raised for transport") +def step_transport_value_error(context: Context) -> None: + assert context.transport_error is not None + assert isinstance(context.transport_error, ValueError) + + +@given('I create a StdioTransport for "{cmd}"') +def step_create_transport(context: Context, cmd: str) -> None: + context.transport = StdioTransport(command=cmd) + + +@then("the transport should not be alive") +def step_transport_not_alive(context: Context) -> None: + assert not context.transport.is_alive + + +@when("I start the transport") +def step_start_transport(context: Context) -> None: + context.transport.start() + + +@then("the transport should be alive") +def step_transport_alive(context: Context) -> None: + assert context.transport.is_alive + + +@when("I stop the transport") +def step_stop_transport(context: Context) -> None: + context.transport.stop() + + +# --------------------------------------------------------------------------- +# LspClient steps +# --------------------------------------------------------------------------- + + +@given("I create an LspClient with a mock transport") +def step_create_client_mock(context: Context) -> None: + mock_transport = create_autospec(StdioTransport, instance=True) + context.lsp_client = LspClient(mock_transport, server_name="test/mock") + + +@then("the client should not be initialized") +def step_client_not_initialized(context: Context) -> None: + assert not context.lsp_client.is_initialized + + +@when("I get two request IDs from the client") +def step_get_request_ids(context: Context) -> None: + context.id1 = context.lsp_client._next_id() + context.id2 = context.lsp_client._next_id() + + +@then("the IDs should be sequential") +def step_ids_sequential(context: Context) -> None: + assert context.id2 == context.id1 + 1 + + +# --------------------------------------------------------------------------- +# LspLifecycleManager steps +# --------------------------------------------------------------------------- + + +@given("I create an LspLifecycleManager") +def step_create_lifecycle(context: Context) -> None: + context.lifecycle_mgr = LspLifecycleManager() + + +@then("the manager should have no running servers") +def step_lifecycle_empty(context: Context) -> None: + assert len(context.lifecycle_mgr.list_running()) == 0 + + +@when('I try to stop server "{name}" on the manager') +def step_lifecycle_stop_unknown(context: Context, name: str) -> None: + try: + context.lifecycle_mgr.stop_server(name) + context.lifecycle_error = None + except LspServerNotFoundError as exc: + context.lifecycle_error = exc + + +@then("an LspServerNotFoundError should be raised for lifecycle") +def step_lifecycle_not_found(context: Context) -> None: + assert context.lifecycle_error is not None + assert isinstance(context.lifecycle_error, LspServerNotFoundError) + + +@then('health check for "{name}" should return false') +def step_health_false(context: Context, name: str) -> None: + assert not context.lifecycle_mgr.health_check(name) + + +# --------------------------------------------------------------------------- +# LspRuntime steps +# --------------------------------------------------------------------------- + + +@given("I create a functional LspRuntime") +def step_create_runtime(context: Context) -> None: + context.func_runtime = LspRuntime() + context.runtime_error = None + + +@when('I try to start server with empty name and workspace "{ws}"') +def step_runtime_empty_name(context: Context, ws: str) -> None: + try: + context.func_runtime.start_server("", ws) + context.runtime_error = None + except ValueError as exc: + context.runtime_error = exc + + +@when('I try to start server "{name}" with empty workspace') +def step_runtime_empty_workspace(context: Context, name: str) -> None: + try: + context.func_runtime.start_server(name, "") + context.runtime_error = None + except ValueError as exc: + context.runtime_error = exc + + +@when('I try to start unregistered server "{name}"') +def step_runtime_unregistered(context: Context, name: str) -> None: + try: + context.func_runtime.start_server(name, "/tmp") + context.runtime_error = None + except LspServerNotFoundError as exc: + context.runtime_error = exc + + +@then("a ValueError should be raised for runtime") +def step_runtime_value_error(context: Context) -> None: + assert context.runtime_error is not None + assert isinstance(context.runtime_error, ValueError) + + +@then("an LspServerNotFoundError should be raised for runtime") +def step_runtime_not_found(context: Context) -> None: + assert context.runtime_error is not None + assert isinstance(context.runtime_error, LspServerNotFoundError) + + +@when("I try to get diagnostics with empty name") +def step_runtime_diag_empty(context: Context) -> None: + try: + context.func_runtime.get_diagnostics("", "/tmp/test.py") + context.runtime_error = None + except ValueError as exc: + context.runtime_error = exc + + +@when("I try to get completions with line 0") +def step_runtime_completions_line0(context: Context) -> None: + try: + context.func_runtime.get_completions("local/test", "/tmp/test.py", 0, 1) + context.runtime_error = None + except ValueError as exc: + context.runtime_error = exc + + +@when("I try to get completions with column 0") +def step_runtime_completions_col0(context: Context) -> None: + try: + context.func_runtime.get_completions("local/test", "/tmp/test.py", 1, 0) + context.runtime_error = None + except ValueError as exc: + context.runtime_error = exc + + +# --------------------------------------------------------------------------- +# LanguageDiscovery steps +# --------------------------------------------------------------------------- + + +@given("I create a LanguageDiscovery instance") +def step_create_discovery(context: Context) -> None: + context.discovery = LanguageDiscovery() + + +@given('I create a LanguageDiscovery with project language "{lang}"') +def step_create_discovery_project(context: Context, lang: str) -> None: + context.discovery = LanguageDiscovery(project_languages=[lang]) + + +@then('language for "{filename}" should be "{expected}"') +def step_check_language(context: Context, filename: str, expected: str) -> None: + result = context.discovery.detect_file_language(filename) + assert result == expected, f"Expected '{expected}', got '{result}'" + + +@when('I detect language for "{filename}"') +def step_detect_language(context: Context, filename: str) -> None: + context.detected_lang = context.discovery.detect_file_language(filename) + + +@when('I invalidate "{filename}" from discovery cache') +def step_invalidate_cache(context: Context, filename: str) -> None: + context.discovery.invalidate(filename) + + +@then('the cache should not contain "{filename}"') +def step_cache_not_contains(context: Context, filename: str) -> None: + assert filename not in context.discovery._cache + + +@given('I create a temp file with shebang "{shebang}"') +def step_create_shebang_file(context: Context, shebang: str) -> None: + fd, path = tempfile.mkstemp(suffix="") + with os.fdopen(fd, "w") as f: + f.write(f"{shebang}\nprint('hello')\n") + context.temp_shebang_file = path + + +@then('the temp file language should be "{lang}"') +def step_temp_file_lang(context: Context, lang: str) -> None: + result = context.discovery.detect_file_language(context.temp_shebang_file) + assert result == lang, f"Expected '{lang}', got '{result}'" + os.unlink(context.temp_shebang_file) + + +# --------------------------------------------------------------------------- +# LspToolAdapter steps +# --------------------------------------------------------------------------- + + +@given("I create an LspToolAdapter with a functional runtime") +def step_adapter_with_runtime(context: Context) -> None: + context.adapter_runtime = LspRuntime() + context.tool_adapter = LspToolAdapter(runtime=context.adapter_runtime) + + +@given("I create an LspToolAdapter without runtime") +def step_adapter_no_runtime(context: Context) -> None: + context.tool_adapter = LspToolAdapter() + + +@given("I have an LSP server config with diagnostics capability") +def step_server_config_diag(context: Context) -> None: + context.adapter_config = LspServerConfig( + name="local/test-server", + command="echo", + languages=["python"], + capabilities=[LspCapability.DIAGNOSTICS], + ) + + +@when("I generate functional tool specs from the adapter") +def step_generate_specs(context: Context) -> None: + context.generated_specs = context.tool_adapter.generate_tool_specs( + context.adapter_config, + ) + + +@then("the tool specs should have runtime handlers") +def step_specs_runtime_handlers(context: Context) -> None: + assert len(context.generated_specs) > 0 + spec = context.generated_specs[0] + assert spec["name"] == "local/test-server/diagnostics" + # Runtime handler should not raise LspNotAvailableError + # (it would raise LspServerNotFoundError instead since server not started) + handler = spec["handler"] + try: + handler(file_path="/tmp/test.py") + except LspNotAvailableError as exc: + raise AssertionError( + "Expected runtime handler, got local-mode handler" + ) from exc + except Exception: + pass # Expected: server not started + + +@then("the tool specs should have local-mode handlers") +def step_specs_local_handlers(context: Context) -> None: + assert len(context.generated_specs) > 0 + spec = context.generated_specs[0] + handler = spec["handler"] + try: + handler(file_path="/tmp/test.py") + raise AssertionError("Expected LspNotAvailableError") + except LspNotAvailableError: + pass # Expected + + +# --------------------------------------------------------------------------- +# Binding activation steps +# --------------------------------------------------------------------------- + + +@when("I activate bindings with empty list") +def step_activate_empty(context: Context) -> None: + context.activated = context.func_runtime.activate_bindings([], "/tmp") + + +@then("the activated servers list should be empty") +def step_activated_empty(context: Context) -> None: + assert context.activated == [] + + +@when("I activate bindings with unregistered server binding") +def step_activate_unregistered(context: Context) -> None: + mock_binding = MagicMock() + mock_binding.lsp_server_name = "local/nonexistent" + mock_binding.node_name = "test_node" + context.activated = context.func_runtime.activate_bindings( + [mock_binding], + "/tmp", + ) diff --git a/features/steps/lsp_registry_steps.py b/features/steps/lsp_registry_steps.py index 0e0e045b..afa73e01 100644 --- a/features/steps/lsp_registry_steps.py +++ b/features/steps/lsp_registry_steps.py @@ -185,7 +185,7 @@ 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 as exc: + except (LspNotAvailableError, LspServerNotFoundError, LspError) as exc: context.lsp_error = exc @@ -194,7 +194,7 @@ def step_runtime_stop(context: Context, name: str) -> None: try: context.lsp_runtime.stop_server(name) context.lsp_error = None - except LspNotAvailableError as exc: + except (LspNotAvailableError, LspServerNotFoundError, LspError) as exc: context.lsp_error = exc @@ -203,7 +203,7 @@ def step_runtime_diagnostics(context: Context, name: str, file_path: str) -> Non try: context.lsp_result = context.lsp_runtime.get_diagnostics(name, file_path) context.lsp_error = None - except LspNotAvailableError as exc: + except (LspNotAvailableError, LspServerNotFoundError, LspError) as exc: context.lsp_error = exc @@ -219,7 +219,7 @@ def step_runtime_completions( name, file_path, line, col ) context.lsp_error = None - except LspNotAvailableError as exc: + except (LspNotAvailableError, LspServerNotFoundError, LspError) as exc: context.lsp_error = exc @@ -379,6 +379,14 @@ def step_not_available_error(context: Context) -> None: ) +@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: diff --git a/robot/helper_lsp_functional_runtime.py b/robot/helper_lsp_functional_runtime.py new file mode 100644 index 00000000..9f1937fd --- /dev/null +++ b/robot/helper_lsp_functional_runtime.py @@ -0,0 +1,144 @@ +"""Robot Framework helper for LSP functional runtime integration tests.""" + +from __future__ import annotations + +import importlib +import sys +from pathlib import Path + +# Ensure local source tree is importable +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +import cleveragents # noqa: E402 + +importlib.reload(cleveragents) + +from cleveragents.lsp.discovery import LanguageDiscovery # noqa: E402 +from cleveragents.lsp.errors import LspServerNotFoundError # noqa: E402 +from cleveragents.lsp.lifecycle import LspLifecycleManager # noqa: E402 +from cleveragents.lsp.models import LspCapability, LspServerConfig # noqa: E402 +from cleveragents.lsp.runtime import LspRuntime # noqa: E402 +from cleveragents.lsp.tool_adapter import LspToolAdapter # noqa: E402 +from cleveragents.lsp.transport import StdioTransport # noqa: E402 + + +def _run_transport_lifecycle() -> None: + """Test transport create/alive check.""" + t = StdioTransport(command="cat") + assert not t.is_alive + t.start() + assert t.is_alive + t.stop() + assert not t.is_alive + print("transport-lifecycle-ok") + + +def _run_transport_empty_command() -> None: + """Test transport rejects empty command.""" + try: + StdioTransport(command="") + print("FAIL: expected ValueError") + sys.exit(1) + except ValueError: + pass + print("transport-empty-command-ok") + + +def _run_lifecycle_manager_empty() -> None: + """Test lifecycle manager starts empty.""" + mgr = LspLifecycleManager() + assert len(mgr.list_running()) == 0 + assert not mgr.health_check("local/test") + try: + mgr.stop_server("local/test") + print("FAIL: expected LspServerNotFoundError") + sys.exit(1) + except LspServerNotFoundError: + pass + print("lifecycle-manager-empty-ok") + + +def _run_runtime_validation() -> None: + """Test runtime input validation.""" + rt = LspRuntime() + errors = 0 + + try: + rt.start_server("", "/tmp") + except ValueError: + errors += 1 + + try: + rt.start_server("local/test", "") + except ValueError: + errors += 1 + + try: + rt.start_server("local/missing", "/tmp") + except LspServerNotFoundError: + errors += 1 + + assert errors == 3, f"Expected 3 validation errors, got {errors}" + print("runtime-validation-ok") + + +def _run_discovery() -> None: + """Test language discovery.""" + d = LanguageDiscovery() + assert d.detect_file_language("test.py") == "python" + assert d.detect_file_language("app.ts") == "typescript" + assert d.detect_file_language("main.rs") == "rust" + assert d.detect_file_language("unknown.xyz") == "plaintext" + assert d.detect_file_language("Dockerfile") == "dockerfile" + + d2 = LanguageDiscovery(project_languages=["go"]) + assert d2.detect_file_language("no_ext") == "go" + + print("discovery-ok") + + +def _run_tool_adapter_modes() -> None: + """Test tool adapter local vs runtime mode.""" + config = LspServerConfig( + name="local/test", + command="echo", + languages=["python"], + capabilities=[LspCapability.DIAGNOSTICS, LspCapability.COMPLETIONS], + ) + + # Local mode + adapter_local = LspToolAdapter() + specs_local = adapter_local.generate_tool_specs(config) + assert len(specs_local) == 2 + assert specs_local[0]["name"] == "local/test/diagnostics" + + # Runtime mode + adapter_rt = LspToolAdapter(runtime=LspRuntime()) + specs_rt = adapter_rt.generate_tool_specs(config) + assert len(specs_rt) == 2 + assert specs_rt[0]["name"] == "local/test/diagnostics" + + print("tool-adapter-modes-ok") + + +if __name__ == "__main__": + cmd = sys.argv[1] if len(sys.argv) > 1 else "all" + dispatch = { + "transport-lifecycle": _run_transport_lifecycle, + "transport-empty-command": _run_transport_empty_command, + "lifecycle-manager-empty": _run_lifecycle_manager_empty, + "runtime-validation": _run_runtime_validation, + "discovery": _run_discovery, + "tool-adapter-modes": _run_tool_adapter_modes, + } + + if cmd == "all": + for fn in dispatch.values(): + fn() + elif cmd in dispatch: + dispatch[cmd]() + else: + print(f"Unknown command: {cmd}") + sys.exit(1) diff --git a/robot/helper_lsp_registry.py b/robot/helper_lsp_registry.py index 8fb889ba..03d8bb9e 100644 --- a/robot/helper_lsp_registry.py +++ b/robot/helper_lsp_registry.py @@ -21,7 +21,11 @@ src_dir = str(Path(__file__).resolve().parents[1] / "src") if src_dir not in sys.path: sys.path.insert(0, src_dir) -from cleveragents.lsp.errors import LspNotAvailableError # noqa: E402 +from cleveragents.lsp.errors import ( # noqa: E402 + LspError, + LspNotAvailableError, + LspServerNotFoundError, +) from cleveragents.lsp.models import ( # noqa: E402 LspCapability, LspServerConfig, @@ -88,6 +92,8 @@ def cmd_remove_server() -> None: def cmd_runtime_stub() -> None: runtime = LspRuntime() + # The functional runtime raises LspServerNotFoundError for + # unregistered servers (not LspNotAvailableError like the old stub). for fn_name, args in [ ("start_server", ("local/pyright", "/tmp")), ("stop_server", ("local/pyright",)), @@ -96,9 +102,9 @@ def cmd_runtime_stub() -> None: ]: try: getattr(runtime, fn_name)(*args) - print(f"FAIL: {fn_name} should have raised LspNotAvailableError") + print(f"FAIL: {fn_name} should have raised an error") sys.exit(1) - except LspNotAvailableError: + except (LspNotAvailableError, LspServerNotFoundError, LspError): pass print("lsp-runtime-stub-ok") diff --git a/robot/lsp_functional_runtime.robot b/robot/lsp_functional_runtime.robot new file mode 100644 index 00000000..5ab2b488 --- /dev/null +++ b/robot/lsp_functional_runtime.robot @@ -0,0 +1,47 @@ +*** Settings *** +Documentation Integration tests for LSP functional runtime +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Variables *** +${HELPER} ${CURDIR}/helper_lsp_functional_runtime.py + +*** Test Cases *** +Transport Lifecycle Start And Stop + [Documentation] StdioTransport can start and stop a process + ${result}= Run Process ${PYTHON} ${HELPER} transport-lifecycle cwd=${WORKSPACE} on_timeout=kill timeout=30s + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} transport-lifecycle-ok + +Transport Rejects Empty Command + [Documentation] StdioTransport rejects empty command + ${result}= Run Process ${PYTHON} ${HELPER} transport-empty-command cwd=${WORKSPACE} on_timeout=kill timeout=30s + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} transport-empty-command-ok + +Lifecycle Manager Empty State + [Documentation] LspLifecycleManager starts with no servers + ${result}= Run Process ${PYTHON} ${HELPER} lifecycle-manager-empty cwd=${WORKSPACE} on_timeout=kill timeout=30s + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} lifecycle-manager-empty-ok + +Runtime Input Validation + [Documentation] LspRuntime validates inputs correctly + ${result}= Run Process ${PYTHON} ${HELPER} runtime-validation cwd=${WORKSPACE} on_timeout=kill timeout=30s + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} runtime-validation-ok + +Language Discovery + [Documentation] LanguageDiscovery detects languages from extensions + ${result}= Run Process ${PYTHON} ${HELPER} discovery cwd=${WORKSPACE} on_timeout=kill timeout=30s + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} discovery-ok + +Tool Adapter Modes + [Documentation] LspToolAdapter generates specs in both modes + ${result}= Run Process ${PYTHON} ${HELPER} tool-adapter-modes cwd=${WORKSPACE} on_timeout=kill timeout=30s + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} tool-adapter-modes-ok diff --git a/src/cleveragents/lsp/__init__.py b/src/cleveragents/lsp/__init__.py index b706ebb7..48647ce3 100644 --- a/src/cleveragents/lsp/__init__.py +++ b/src/cleveragents/lsp/__init__.py @@ -1,16 +1,13 @@ """LSP (Language Server Protocol) integration package. -Provides the registry, runtime stub, tool adapter, server stub, -and domain models for managing language servers within CleverAgents. +Provides the registry, runtime, tool adapter, server stub, protocol +client, transport, and lifecycle manager for managing language servers +within CleverAgents. -In **local mode** the :class:`LspRuntime` and :class:`LspToolAdapter` -are stubs: the runtime raises :class:`LspNotAvailableError` for every -operation and the tool adapter generates tool specs whose handlers -also raise immediately. The :class:`LspServer` provides a minimal -JSON-RPC server stub over stdin/stdout. - -When server mode is implemented the concrete classes will replace -these stubs while preserving the public API. +The :class:`LspRuntime` manages LSP server processes and provides +code intelligence (diagnostics, completions) to actors. When no +runtime is supplied to :class:`LspToolAdapter`, tool handlers fall +back to raising :class:`LspNotAvailableError`. Usage:: @@ -21,14 +18,20 @@ Usage:: LspToolAdapter, LspServerConfig, LspCapability, + LspClient, + LspLifecycleManager, + StdioTransport, ) """ +from cleveragents.lsp.client import LspClient +from cleveragents.lsp.discovery import LanguageDiscovery from cleveragents.lsp.errors import ( LspError, LspNotAvailableError, LspServerNotFoundError, ) +from cleveragents.lsp.lifecycle import LspLifecycleManager from cleveragents.lsp.models import ( LspBinding, LspCapability, @@ -38,11 +41,15 @@ from cleveragents.lsp.registry import LspRegistry from cleveragents.lsp.runtime import LspRuntime from cleveragents.lsp.server import LspServer from cleveragents.lsp.tool_adapter import LspToolAdapter +from cleveragents.lsp.transport import StdioTransport __all__ = [ + "LanguageDiscovery", "LspBinding", "LspCapability", + "LspClient", "LspError", + "LspLifecycleManager", "LspNotAvailableError", "LspRegistry", "LspRuntime", @@ -50,4 +57,5 @@ __all__ = [ "LspServerConfig", "LspServerNotFoundError", "LspToolAdapter", + "StdioTransport", ] diff --git a/src/cleveragents/lsp/client.py b/src/cleveragents/lsp/client.py new file mode 100644 index 00000000..cfa0a390 --- /dev/null +++ b/src/cleveragents/lsp/client.py @@ -0,0 +1,398 @@ +"""LSP protocol client for communicating with language servers. + +Implements the client side of the LSP protocol over a +:class:`StdioTransport`. Handles: + +- ``initialize`` / ``initialized`` handshake. +- ``shutdown`` / ``exit`` lifecycle. +- ``textDocument/publishDiagnostics`` notification handling. +- ``textDocument/completion`` requests. +- ``textDocument/didOpen`` / ``textDocument/didClose`` notifications. +- Request ID tracking and response correlation. + +The client is **synchronous** — each request blocks until the response +arrives. Notifications received while waiting for a response are +queued and processed later. + +Based on ``docs/specification.md`` LSP Server Lifecycle (lines 20744-20758) +and issue #826. +""" + +from __future__ import annotations + +import contextlib +import threading +import time +from collections import deque +from typing import Any + +import structlog + +from cleveragents.lsp.errors import LspError +from cleveragents.lsp.transport import StdioTransport + +logger = structlog.get_logger(__name__) + +# Default timeout for a single JSON-RPC request/response cycle. +_REQUEST_TIMEOUT = 60.0 + + +class LspClient: + """Synchronous LSP protocol client. + + Wraps a :class:`StdioTransport` and provides typed methods for + the LSP protocol operations needed by the runtime. + + Args: + transport: The stdio transport connected to the server. + server_name: Human-readable name for logging. + """ + + def __init__( + self, + transport: StdioTransport, + server_name: str = "", + ) -> None: + self._transport = transport + self._server_name = server_name + self._request_id = 0 + self._id_lock = threading.Lock() + self._initialized = False + self._server_capabilities: dict[str, Any] = {} + self._diagnostics: dict[str, list[dict[str, Any]]] = {} + self._pending_notifications: deque[dict[str, Any]] = deque(maxlen=1000) + + @property + def is_initialized(self) -> bool: + """Whether the initialize handshake has completed.""" + return self._initialized + + @property + def server_capabilities(self) -> dict[str, Any]: + """Capabilities returned by the server during initialize.""" + return self._server_capabilities + + def _next_id(self) -> int: + with self._id_lock: + self._request_id += 1 + return self._request_id + + def _send_request( + self, + method: str, + params: dict[str, Any] | None = None, + *, + timeout: float = _REQUEST_TIMEOUT, + ) -> dict[str, Any]: + """Send a JSON-RPC request and wait for the response. + + Args: + method: LSP method name. + params: Optional parameters. + timeout: Maximum seconds to wait for the response. + + Returns: + The ``result`` field from the response. + + Raises: + LspError: On JSON-RPC error, transport failure, or timeout. + """ + req_id = self._next_id() + message: dict[str, Any] = { + "jsonrpc": "2.0", + "id": req_id, + "method": method, + } + if params is not None: + message["params"] = params + + self._transport.send_message(message) + + deadline = time.monotonic() + timeout + + # Read messages until we get our response or deadline expires + while time.monotonic() < deadline: + remaining = max(0.1, deadline - time.monotonic()) + msg = self._transport.read_message(timeout=remaining) + if msg is None: + # Distinguish EOF (server died) from select timeout: + # if the transport process is still alive, select just + # timed out — retry until the outer deadline expires. + if self._transport.is_alive: + continue + raise LspError( + f"LSP server closed connection while waiting for " + f"response to '{method}' (id={req_id})", + details={"method": method, "id": req_id}, + ) + + # Check if this is a response to our request + if "id" in msg and msg["id"] == req_id: + if "error" in msg: + err = msg["error"] + raise LspError( + f"LSP error for '{method}': {err.get('message', 'unknown')}", + details={ + "method": method, + "code": err.get("code"), + "data": err.get("data"), + }, + ) + return msg.get("result", {}) + + # Not our response — queue as notification + self._handle_notification(msg) + + raise LspError( + f"Timed out waiting for response to '{method}' " + f"(id={req_id}, timeout={timeout}s)", + details={"method": method, "id": req_id}, + ) + + def _send_notification( + self, + method: str, + params: dict[str, Any] | None = None, + ) -> None: + """Send a JSON-RPC notification (no response expected). + + Args: + method: LSP method name. + params: Optional parameters. + """ + message: dict[str, Any] = { + "jsonrpc": "2.0", + "method": method, + } + if params is not None: + message["params"] = params + self._transport.send_message(message) + + def _handle_notification(self, msg: dict[str, Any]) -> None: + """Process an incoming notification from the server.""" + method = msg.get("method", "") + params = msg.get("params", {}) + + if method == "textDocument/publishDiagnostics": + uri = params.get("uri", "") + diagnostics = params.get("diagnostics", []) + self._diagnostics[uri] = diagnostics + logger.debug( + "lsp.client.diagnostics_received", + server=self._server_name, + uri=uri, + count=len(diagnostics), + ) + else: + self._pending_notifications.append(msg) + + # -- LSP Lifecycle ------------------------------------------------------- + + def initialize( + self, + workspace_path: str, + initialization_options: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Perform the LSP ``initialize`` handshake. + + Args: + workspace_path: Root URI for the workspace. + initialization_options: Server-specific init options. + + Returns: + The server's ``InitializeResult``. + + Raises: + LspError: If already initialized or handshake fails. + """ + if self._initialized: + raise LspError( + "LSP client already initialized", + details={"server": self._server_name}, + ) + + # Convert path to file URI + workspace_uri = workspace_path + if not workspace_uri.startswith("file://"): + workspace_uri = f"file://{workspace_path}" + + params: dict[str, Any] = { + "processId": None, + "clientInfo": { + "name": "cleveragents", + "version": "0.1.0", + }, + "rootUri": workspace_uri, + "workspaceFolders": [ + {"uri": workspace_uri, "name": "workspace"}, + ], + "capabilities": { + "textDocument": { + "publishDiagnostics": {"relatedInformation": True}, + "completion": { + "completionItem": {"snippetSupport": False}, + }, + "synchronization": { + "didSave": True, + "willSave": False, + }, + }, + "workspace": { + "workspaceFolders": True, + }, + }, + } + if initialization_options: + params["initializationOptions"] = initialization_options + + logger.info( + "lsp.client.initializing", + server=self._server_name, + workspace=workspace_path, + ) + + result = self._send_request("initialize", params) + self._server_capabilities = result.get("capabilities", {}) + self._initialized = True + + # Send initialized notification + self._send_notification("initialized", {}) + + logger.info( + "lsp.client.initialized", + server=self._server_name, + capabilities=list(self._server_capabilities.keys()), + ) + + return result + + def shutdown(self) -> None: + """Send ``shutdown`` request followed by ``exit`` notification. + + Raises: + LspError: If not initialized. + """ + if not self._initialized: + logger.warning( + "lsp.client.shutdown_not_initialized", + server=self._server_name, + ) + return + + logger.info("lsp.client.shutting_down", server=self._server_name) + + try: + self._send_request("shutdown") + except LspError: + logger.warning( + "lsp.client.shutdown_error", + server=self._server_name, + exc_info=True, + ) + + with contextlib.suppress(BrokenPipeError, RuntimeError): + self._send_notification("exit") + + self._initialized = False + + # -- Text Document Operations -------------------------------------------- + + def did_open( + self, + uri: str, + language_id: str, + version: int, + text: str, + ) -> None: + """Notify the server that a document was opened. + + Args: + uri: Document URI (``file://...``). + language_id: Language identifier (e.g., ``"python"``). + version: Document version number. + text: Full document text. + """ + self._send_notification( + "textDocument/didOpen", + { + "textDocument": { + "uri": uri, + "languageId": language_id, + "version": version, + "text": text, + }, + }, + ) + + def did_close(self, uri: str) -> None: + """Notify the server that a document was closed. + + Args: + uri: Document URI. + """ + self._send_notification( + "textDocument/didClose", + {"textDocument": {"uri": uri}}, + ) + # Clear cached diagnostics for this URI + self._diagnostics.pop(uri, None) + + def get_diagnostics(self, uri: str) -> list[dict[str, Any]]: + """Return cached diagnostics for *uri*. + + Diagnostics are pushed by the server via + ``textDocument/publishDiagnostics`` notifications and cached + by the client. This method returns the latest cached set. + + Args: + uri: Document URI. + + Returns: + List of LSP Diagnostic objects. + """ + # Drain pending messages (capped to prevent infinite loop if + # server is flooding notifications). + max_drain = 200 + for _ in range(max_drain): + msg = self._transport.read_message(timeout=0.05) + if msg is None: + break + self._handle_notification(msg) + + return list(self._diagnostics.get(uri, [])) + + def get_completions( + self, + uri: str, + line: int, + character: int, + ) -> list[dict[str, Any]]: + """Request completions at a position. + + Args: + uri: Document URI. + line: 0-based line number. + character: 0-based character offset. + + Returns: + List of CompletionItem dicts. + """ + result = self._send_request( + "textDocument/completion", + { + "textDocument": {"uri": uri}, + "position": {"line": line, "character": character}, + }, + ) + + # Result may be a CompletionList or a list of CompletionItems + if isinstance(result, dict) and "items" in result: + return result["items"] + if isinstance(result, list): + return result + return [] + + +__all__ = [ + "LspClient", +] diff --git a/src/cleveragents/lsp/discovery.py b/src/cleveragents/lsp/discovery.py new file mode 100644 index 00000000..5ba5b38c --- /dev/null +++ b/src/cleveragents/lsp/discovery.py @@ -0,0 +1,307 @@ +"""4-layer language discovery for LSP server matching. + +Determines which programming languages a resource (file, directory, +or project) contains, so the LSP runtime can start the right language +servers. + +## Discovery Layers (priority order) + +1. **File extension analysis** -- Map file extensions to language IDs + using a built-in extension table. Fastest and most common. +2. **Content analysis** -- Inspect shebang lines, magic comments, and + structural patterns for files without/ambiguous extensions. +3. **UKO classification** -- Use Universal Knowledge Ontology layer-3 + classification when available (persisted across sessions). +4. **Explicit project declaration** -- Project-level language config + overrides or supplements automatic discovery. + +Results are cached per file path and invalidated on content change. + +Based on ``docs/specification.md`` Resource Language Discovery +(lines 20557-20566) and issue #826. +""" + +from __future__ import annotations + +import os +from typing import Any + +import structlog + +logger = structlog.get_logger(__name__) + +# --------------------------------------------------------------------------- +# Layer 1: Extension-to-language mapping +# --------------------------------------------------------------------------- + +_EXTENSION_MAP: dict[str, str] = { + ".py": "python", + ".pyi": "python", + ".pyw": "python", + ".ts": "typescript", + ".tsx": "typescriptreact", + ".js": "javascript", + ".jsx": "javascriptreact", + ".mjs": "javascript", + ".cjs": "javascript", + ".rs": "rust", + ".go": "go", + ".java": "java", + ".kt": "kotlin", + ".kts": "kotlin", + ".scala": "scala", + ".c": "c", + ".h": "c", + ".cpp": "cpp", + ".cxx": "cpp", + ".cc": "cpp", + ".hpp": "cpp", + ".hxx": "cpp", + ".cs": "csharp", + ".fs": "fsharp", + ".rb": "ruby", + ".php": "php", + ".swift": "swift", + ".lua": "lua", + ".r": "r", + ".R": "r", + ".jl": "julia", + ".zig": "zig", + ".nim": "nim", + ".ex": "elixir", + ".exs": "elixir", + ".erl": "erlang", + ".hrl": "erlang", + ".hs": "haskell", + ".ml": "ocaml", + ".mli": "ocaml", + ".pl": "perl", + ".pm": "perl", + ".dart": "dart", + ".v": "v", + ".sh": "shellscript", + ".bash": "shellscript", + ".zsh": "shellscript", + ".fish": "shellscript", + ".ps1": "powershell", + ".psm1": "powershell", + ".json": "json", + ".jsonc": "jsonc", + ".yaml": "yaml", + ".yml": "yaml", + ".toml": "toml", + ".xml": "xml", + ".html": "html", + ".htm": "html", + ".css": "css", + ".scss": "scss", + ".less": "less", + ".sql": "sql", + ".md": "markdown", + ".rst": "restructuredtext", + ".tex": "latex", + ".dockerfile": "dockerfile", + ".proto": "proto3", + ".graphql": "graphql", + ".gql": "graphql", + ".vue": "vue", + ".svelte": "svelte", +} + +# --------------------------------------------------------------------------- +# Layer 2: Shebang / content analysis +# --------------------------------------------------------------------------- + +_SHEBANG_MAP: dict[str, str] = { + "python": "python", + "python3": "python", + "node": "javascript", + "ruby": "ruby", + "perl": "perl", + "bash": "shellscript", + "sh": "shellscript", + "zsh": "shellscript", + "fish": "shellscript", + "lua": "lua", + "php": "php", +} + + +def _detect_from_shebang(first_line: str) -> str | None: + """Parse a shebang line to determine language. + + Handles both ``#!/usr/bin/env python3`` and ``#!/usr/bin/python3``. + + Args: + first_line: The first line of the file. + + Returns: + Language ID or ``None`` if not detectable. + """ + if not first_line.startswith("#!"): + return None + + parts = first_line[2:].strip().split() + if not parts: + return None + + # Handle /usr/bin/env + executable = parts[-1] if parts[0].endswith("/env") else parts[0] + basename = os.path.basename(executable) + + # Strip version suffixes (python3.11 -> python3 -> python) + for key, lang in _SHEBANG_MAP.items(): + if basename == key or basename.startswith(key): + return lang + + return None + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +class LanguageDiscovery: + """4-layer language discovery engine. + + Detects programming languages for files and directories using + extension mapping, content analysis, UKO classification, and + explicit project declarations. + + Args: + project_languages: Explicit language declarations from the + project configuration (layer 4). + uko_classifications: Pre-loaded UKO language classifications + keyed by file path (layer 3). + """ + + def __init__( + self, + project_languages: list[str] | None = None, + uko_classifications: dict[str, str] | None = None, + ) -> None: + self._project_languages = [lang.lower() for lang in (project_languages or [])] + self._uko: dict[str, str] = uko_classifications or {} + self._cache: dict[str, str] = {} + + def detect_file_language(self, file_path: str) -> str: + """Detect the language of a single file. + + Applies the 4-layer process in priority order: + extension -> content -> UKO -> project default -> ``plaintext``. + + Args: + file_path: Path to the file. + + Returns: + Language identifier (e.g., ``"python"``). + """ + if file_path in self._cache: + return self._cache[file_path] + + lang = self._detect_uncached(file_path) + self._cache[file_path] = lang + return lang + + def detect_directory_languages(self, directory: str) -> list[str]: + """Discover all languages present in a directory tree. + + Walks the directory, detects each file's language, and returns + a deduplicated sorted list. + + Args: + directory: Root directory to scan. + + Returns: + Sorted list of unique language identifiers. + """ + languages: set[str] = set() + try: + for root, _dirs, files in os.walk(directory): + for fname in files: + fpath = os.path.join(root, fname) + lang = self.detect_file_language(fpath) + if lang != "plaintext": + languages.add(lang) + except OSError: + logger.warning( + "lsp.discovery.walk_error", + directory=directory, + exc_info=True, + ) + + return sorted(languages) + + def invalidate(self, file_path: str) -> None: + """Remove a file from the cache (e.g., after content change). + + Args: + file_path: Path to invalidate. + """ + self._cache.pop(file_path, None) + + def invalidate_all(self) -> None: + """Clear the entire cache.""" + self._cache.clear() + + def _detect_uncached(self, file_path: str) -> str: + """Run the 4-layer detection without cache.""" + # Layer 1: Extension + ext = os.path.splitext(file_path)[1].lower() + # Handle Dockerfile (no extension but specific name) + basename = os.path.basename(file_path).lower() + if basename == "dockerfile" or basename.startswith("dockerfile."): + return "dockerfile" + if basename == "makefile": + return "makefile" + if basename == "cmakelists.txt": + return "cmake" + + if ext in _EXTENSION_MAP: + return _EXTENSION_MAP[ext] + + # Layer 2: Content analysis (shebang) + try: + with open(file_path, encoding="utf-8", errors="replace") as f: + first_line = f.readline(512) + lang = _detect_from_shebang(first_line) + if lang is not None: + return lang + except OSError: + pass + + # Layer 3: UKO classification + if file_path in self._uko: + return self._uko[file_path] + + # Layer 4: Project-level declaration (return first match if any) + if self._project_languages: + return self._project_languages[0] + + return "plaintext" + + def get_servers_for_language( + self, + language: str, + available_configs: list[Any], + ) -> list[Any]: + """Find LSP server configs that serve a given language. + + Args: + language: Language identifier. + available_configs: List of ``LspServerConfig`` objects. + + Returns: + Configs whose ``languages`` list includes *language*. + """ + return [ + cfg + for cfg in available_configs + if language in getattr(cfg, "languages", []) + ] + + +__all__ = [ + "LanguageDiscovery", +] diff --git a/src/cleveragents/lsp/lifecycle.py b/src/cleveragents/lsp/lifecycle.py new file mode 100644 index 00000000..46c7c73e --- /dev/null +++ b/src/cleveragents/lsp/lifecycle.py @@ -0,0 +1,300 @@ +"""LSP server lifecycle manager. + +Manages running LSP server instances with: + +- Reference-counted shared instances (multiple actors can share one server). +- Health checks via process liveness. +- Automatic restart on crash with re-initialization. +- Graceful shutdown when the last reference is released. + +Based on ``docs/specification.md`` LSP Server Lifecycle (lines 20744-20758) +and issue #826. +""" + +from __future__ import annotations + +import threading +from typing import Any + +import structlog + +from cleveragents.lsp.client import LspClient +from cleveragents.lsp.errors import LspServerNotFoundError +from cleveragents.lsp.models import LspServerConfig +from cleveragents.lsp.transport import StdioTransport + +logger = structlog.get_logger(__name__) + + +class _ManagedServer: + """Internal state for a running LSP server instance. + + Attributes: + config: The server configuration. + transport: The stdio transport. + client: The LSP protocol client. + workspace_path: The workspace root path. + ref_count: Number of active references. + """ + + __slots__ = ("client", "config", "ref_count", "transport", "workspace_path") + + def __init__( + self, + config: LspServerConfig, + transport: StdioTransport, + client: LspClient, + workspace_path: str, + ) -> None: + self.config = config + self.transport = transport + self.client = client + self.workspace_path = workspace_path + self.ref_count = 1 + + +class LspLifecycleManager: + """Manages running LSP server instances with reference counting. + + Thread-safe. Each server is identified by its namespaced name. + Multiple callers can acquire/release the same server; the process + is only stopped when the last reference is released. + """ + + def __init__(self) -> None: + self._servers: dict[str, _ManagedServer] = {} + self._lock = threading.Lock() + + def start_server( + self, + config: LspServerConfig, + workspace_path: str, + ) -> LspClient: + """Start or acquire a reference to an LSP server. + + If a server with the same name is already running for the same + workspace, its reference count is incremented and the existing + client is returned. + + Args: + config: Server configuration. + workspace_path: Root directory for the workspace. + + Returns: + An :class:`LspClient` connected to the running server. + + Raises: + LspError: If the server process cannot be started. + """ + # Phase 1: Check for existing server (short lock) + with self._lock: + existing = self._servers.get(config.name) + if existing is not None and existing.transport.is_alive: + existing.ref_count += 1 + logger.info( + "lsp.lifecycle.reused", + server=config.name, + ref_count=existing.ref_count, + ) + return existing.client + + # Phase 2: Start and initialize OUTSIDE the lock (may take seconds) + transport = StdioTransport( + command=config.command, + args=config.args, + env=config.env, + cwd=workspace_path, + ) + transport.start() + + client = LspClient(transport, server_name=config.name) + try: + client.initialize(workspace_path) + except Exception: + transport.stop() + raise + + # Phase 3: Register the running server (short lock) + managed = _ManagedServer( + config=config, + transport=transport, + client=client, + workspace_path=workspace_path, + ) + with self._lock: + # Another thread may have started the same server concurrently + race_winner = self._servers.get(config.name) + if race_winner is not None and race_winner.transport.is_alive: + # We lost the race — stop our instance, reuse theirs + transport.stop() + race_winner.ref_count += 1 + return race_winner.client + + self._servers[config.name] = managed + + logger.info( + "lsp.lifecycle.started", + server=config.name, + workspace=workspace_path, + ) + return client + + def stop_server(self, name: str) -> None: + """Release a reference to an LSP server. + + Decrements the reference count. When it reaches zero, the + server is shut down gracefully. + + Args: + name: Namespaced server name. + + Raises: + LspServerNotFoundError: If no server with *name* is running. + """ + with self._lock: + managed = self._servers.get(name) + if managed is None: + raise LspServerNotFoundError(name) + + managed.ref_count -= 1 + if managed.ref_count > 0: + logger.info( + "lsp.lifecycle.released", + server=name, + ref_count=managed.ref_count, + ) + return + + # Last reference — shut down + self._shutdown_server(managed) + del self._servers[name] + + def _shutdown_server(self, managed: _ManagedServer) -> None: + """Gracefully shut down a managed server.""" + name = managed.config.name + logger.info("lsp.lifecycle.shutting_down", server=name) + try: + managed.client.shutdown() + except Exception: + logger.warning( + "lsp.lifecycle.shutdown_error", + server=name, + exc_info=True, + ) + managed.transport.stop() + + def get_client(self, name: str) -> LspClient: + """Get the client for a running server. + + Args: + name: Namespaced server name. + + Returns: + The :class:`LspClient` for the server. + + Raises: + LspServerNotFoundError: If no server with *name* is running. + """ + with self._lock: + managed = self._servers.get(name) + if managed is None: + raise LspServerNotFoundError(name) + return managed.client + + def health_check(self, name: str) -> bool: + """Check whether the named server is alive. + + Args: + name: Namespaced server name. + + Returns: + ``True`` if the server process is running. + """ + with self._lock: + managed = self._servers.get(name) + if managed is None: + return False + return managed.transport.is_alive + + def restart_server(self, name: str) -> LspClient: + """Restart a crashed or unresponsive server. + + Stops the current process (if alive), starts a fresh one, and + re-initializes the LSP handshake. + + Args: + name: Namespaced server name. + + Returns: + A new :class:`LspClient` for the restarted server. + + Raises: + LspServerNotFoundError: If the server was never started. + LspError: If the restart fails. + """ + with self._lock: + managed = self._servers.get(name) + if managed is None: + raise LspServerNotFoundError(name) + + logger.warning("lsp.lifecycle.restarting", server=name) + + # Stop old transport + managed.transport.stop() + + # Start fresh + transport = StdioTransport( + command=managed.config.command, + args=managed.config.args, + env=managed.config.env, + cwd=managed.workspace_path, + ) + transport.start() + + client = LspClient(transport, server_name=name) + try: + client.initialize(managed.workspace_path) + except Exception: + transport.stop() + raise + + managed.transport = transport + managed.client = client + + logger.info("lsp.lifecycle.restarted", server=name) + return client + + def stop_all(self) -> None: + """Shut down all running servers. Used during cleanup.""" + with self._lock: + for name, managed in list(self._servers.items()): + try: + self._shutdown_server(managed) + except Exception: + logger.warning( + "lsp.lifecycle.stop_all_error", + server=name, + exc_info=True, + ) + self._servers.clear() + + def list_running(self) -> list[dict[str, Any]]: + """Return status info for all running servers.""" + with self._lock: + result: list[dict[str, Any]] = [] + for name, managed in self._servers.items(): + result.append( + { + "name": name, + "workspace": managed.workspace_path, + "alive": managed.transport.is_alive, + "ref_count": managed.ref_count, + "initialized": managed.client.is_initialized, + } + ) + return result + + +__all__ = [ + "LspLifecycleManager", +] diff --git a/src/cleveragents/lsp/runtime.py b/src/cleveragents/lsp/runtime.py index b97eae52..26ebf46f 100644 --- a/src/cleveragents/lsp/runtime.py +++ b/src/cleveragents/lsp/runtime.py @@ -1,104 +1,175 @@ -"""Local-mode LSP Runtime stub. +"""Functional LSP Runtime for managing language server processes. -In local mode the LSP Runtime does **not** start real language servers. -Every method raises :class:`LspNotAvailableError` with a clear message -directing the user to server mode. This stub ensures that the -public API surface exists and can be imported, type-checked, and -tested even when no actual LSP transport is available. +The ``LspRuntime`` manages LSP server processes and provides code +intelligence (diagnostics, completions) to actors. It delegates +process lifecycle to :class:`LspLifecycleManager` and protocol +communication to :class:`LspClient`. -When server mode is implemented the concrete runtime will replace -these stub implementations while keeping the same interface. +When a server is started, the runtime: + +1. Looks up the :class:`LspServerConfig` from the :class:`LspRegistry`. +2. Delegates to the lifecycle manager to spawn (or reuse) the process. +3. Tracks running servers for query operations. + +When a query (diagnostics, completions) is issued, the runtime: + +1. Verifies the server is running and healthy. +2. If crashed, attempts automatic restart. +3. Delegates to the :class:`LspClient` for the protocol call. + +Based on ``docs/specification.md`` LSP Server Lifecycle (lines 20744-20758) +and issue #826. """ from __future__ import annotations -import logging +import contextlib +import os from typing import Any -from cleveragents.lsp.errors import LspNotAvailableError +import structlog -logger = logging.getLogger(__name__) +from cleveragents.lsp.errors import LspError, LspServerNotFoundError +from cleveragents.lsp.lifecycle import LspLifecycleManager +from cleveragents.lsp.registry import LspRegistry -_LOCAL_MODE_MSG = ( - "LSP server start deferred to server mode - " - "language-server features are not available in local mode" -) +logger = structlog.get_logger(__name__) class LspRuntime: - """Stub runtime for LSP servers in local mode. + """Functional runtime for LSP servers. - All operations raise :class:`LspNotAvailableError`. The runtime - logs a warning on each call to aid debugging. + Manages the full lifecycle of language servers: start, query, + health-check, restart, and stop. + + Args: + registry: The LSP server registry to look up configurations. + lifecycle_manager: Optional lifecycle manager (created if not + provided). """ + def __init__( + self, + registry: LspRegistry | None = None, + lifecycle_manager: LspLifecycleManager | None = None, + ) -> None: + self._registry = registry or LspRegistry() + self._lifecycle = lifecycle_manager or LspLifecycleManager() + + @property + def registry(self) -> LspRegistry: + """The underlying LSP server registry.""" + return self._registry + + @property + def lifecycle(self) -> LspLifecycleManager: + """The underlying lifecycle manager.""" + return self._lifecycle + def start_server(self, name: str, workspace_path: str) -> None: """Start an LSP server for *name* in *workspace_path*. - In local mode this is a no-op that raises immediately. + Looks up the server configuration from the registry and + delegates to the lifecycle manager to spawn (or reuse) the + process. The LSP ``initialize`` handshake is performed + automatically. Args: - name: Namespaced server name. + name: Namespaced server name (must be registered). workspace_path: Root directory for the language server. Raises: - LspNotAvailableError: Always, in local mode. ValueError: If *name* or *workspace_path* is empty. + LspServerNotFoundError: If *name* is not in the registry. + LspError: If the server process cannot be started. """ if not name: raise ValueError("name must be a non-empty string") if not workspace_path: raise ValueError("workspace_path must be a non-empty string") - logger.warning("%s (server=%s)", _LOCAL_MODE_MSG, name) - raise LspNotAvailableError( - _LOCAL_MODE_MSG, - details={"server": name, "workspace": workspace_path}, + + config = self._registry.get_or_raise(name) + + logger.info( + "lsp.runtime.starting_server", + server=name, + workspace=workspace_path, + command=config.command, ) + self._lifecycle.start_server(config, workspace_path) + def stop_server(self, name: str) -> None: """Stop the LSP server identified by *name*. + Releases one reference to the server. The process is only + terminated when all references are released. + Args: name: Namespaced server name. Raises: - LspNotAvailableError: Always, in local mode. ValueError: If *name* is empty. + LspServerNotFoundError: If the server is not running. """ if not name: raise ValueError("name must be a non-empty string") - logger.warning("%s (server=%s)", _LOCAL_MODE_MSG, name) - raise LspNotAvailableError( - _LOCAL_MODE_MSG, - details={"server": name}, - ) + + logger.info("lsp.runtime.stopping_server", server=name) + self._lifecycle.stop_server(name) def get_diagnostics(self, name: str, file_path: str) -> list[Any]: """Retrieve diagnostics for *file_path* from the named server. - In local mode this always raises; the return type annotation - is kept for interface compatibility. + Opens the file, sends ``textDocument/didOpen`` to the server, + reads cached diagnostics (pushed asynchronously by the server), + then closes the document. Args: name: Namespaced server name. file_path: Path to the file being diagnosed. Returns: - Empty list (never reached in local mode). + List of LSP Diagnostic dicts. Raises: - LspNotAvailableError: Always, in local mode. ValueError: If *name* or *file_path* is empty. + LspServerNotFoundError: If the server is not running. + LspError: If the server has crashed. """ if not name: raise ValueError("name must be a non-empty string") if not file_path: raise ValueError("file_path must be a non-empty string") - logger.warning("%s (server=%s, file=%s)", _LOCAL_MODE_MSG, name, file_path) - raise LspNotAvailableError( - _LOCAL_MODE_MSG, - details={"server": name, "file": file_path}, + + client = self._get_healthy_client(name) + uri = self._path_to_uri(file_path) + + # Open the file so the server analyses it + try: + text = self._read_file(file_path) + except OSError as exc: + raise LspError( + f"Cannot read file for diagnostics: {file_path}", + details={"file": file_path, "error": str(exc)}, + ) from exc + + language_id = self._detect_language(file_path) + client.did_open(uri, language_id, version=1, text=text) + + # Give the server time to process and push diagnostics + diagnostics = client.get_diagnostics(uri) + + # Close the document + client.did_close(uri) + + logger.info( + "lsp.runtime.diagnostics", + server=name, + file=file_path, + count=len(diagnostics), ) + return diagnostics def get_completions( self, @@ -109,9 +180,6 @@ class LspRuntime: ) -> list[Any]: """Retrieve completions for a position in *file_path*. - In local mode this always raises; the return type annotation - is kept for interface compatibility. - Args: name: Namespaced server name. file_path: Path to the file. @@ -119,12 +187,12 @@ class LspRuntime: column: 1-based column number. Returns: - Empty list (never reached in local mode). + List of CompletionItem dicts. Raises: - LspNotAvailableError: Always, in local mode. - ValueError: If *name* or *file_path* is empty, or - *line*/*column* are not positive. + ValueError: If inputs are invalid. + LspServerNotFoundError: If the server is not running. + LspError: If the server has crashed. """ if not name: raise ValueError("name must be a non-empty string") @@ -134,23 +202,144 @@ class LspRuntime: raise ValueError("line must be >= 1") if column < 1: raise ValueError("column must be >= 1") - logger.warning( - "%s (server=%s, file=%s, pos=%d:%d)", - _LOCAL_MODE_MSG, - name, - file_path, - line, - column, - ) - raise LspNotAvailableError( - _LOCAL_MODE_MSG, - details={ - "server": name, - "file": file_path, - "line": line, - "column": column, - }, + + client = self._get_healthy_client(name) + uri = self._path_to_uri(file_path) + + # Open the file + try: + text = self._read_file(file_path) + except OSError as exc: + raise LspError( + f"Cannot read file for completions: {file_path}", + details={"file": file_path, "error": str(exc)}, + ) from exc + + language_id = self._detect_language(file_path) + client.did_open(uri, language_id, version=1, text=text) + + # LSP uses 0-based line/character + completions = client.get_completions(uri, line - 1, column - 1) + + client.did_close(uri) + + logger.info( + "lsp.runtime.completions", + server=name, + file=file_path, + position=f"{line}:{column}", + count=len(completions), ) + return completions + + def _get_healthy_client(self, name: str) -> Any: + """Get the client for a server, restarting if crashed. + + Args: + name: Server name. + + Returns: + An :class:`LspClient` connected to a healthy server. + + Raises: + LspServerNotFoundError: If the server was never started. + LspError: If restart fails. + """ + if not self._lifecycle.health_check(name): + logger.warning("lsp.runtime.server_crashed", server=name) + try: + return self._lifecycle.restart_server(name) + except (LspError, LspServerNotFoundError): + raise + + return self._lifecycle.get_client(name) + + @staticmethod + def _path_to_uri(file_path: str) -> str: + """Convert a filesystem path to a ``file://`` URI.""" + if file_path.startswith("file://"): + return file_path + return f"file://{file_path}" + + @staticmethod + def _read_file(file_path: str) -> str: + """Read file contents as UTF-8 text. + + Raises: + LspError: If the resolved path is a directory or device. + """ + resolved = os.path.realpath(file_path) + if not os.path.isfile(resolved): + raise LspError( + f"Not a regular file: {file_path}", + details={"file": file_path, "resolved": resolved}, + ) + with open(resolved, encoding="utf-8") as f: + return f.read() + + @staticmethod + def _detect_language(file_path: str) -> str: + """Detect the language ID from a file extension. + + Delegates to :class:`LanguageDiscovery` for the full 4-layer + detection process. + """ + from cleveragents.lsp.discovery import LanguageDiscovery + + return LanguageDiscovery().detect_file_language(file_path) + + def activate_bindings( + self, + bindings: list[Any], + workspace_path: str, + ) -> list[str]: + """Start LSP servers required by actor compilation bindings. + + For each binding, looks up the server config in the registry + and starts it. Returns the names of servers that were + successfully started. + + Args: + bindings: List of :class:`LspBinding` objects from the + actor compiler's :class:`CompilationMetadata`. + workspace_path: Workspace root for all servers. + + Returns: + Names of servers that were started (or already running). + """ + started: list[str] = [] + for binding in bindings: + server_name = getattr(binding, "lsp_server_name", "") + if not server_name: + continue + try: + self.start_server(server_name, workspace_path) + started.append(server_name) + except (LspError, LspServerNotFoundError) as exc: + logger.warning( + "lsp.runtime.binding_start_failed", + server=server_name, + node=getattr(binding, "node_name", ""), + error=str(exc), + ) + return started + + def deactivate_bindings(self, bindings: list[Any]) -> None: + """Release LSP servers acquired by actor compilation bindings. + + Args: + bindings: List of :class:`LspBinding` objects. + """ + for binding in bindings: + server_name = getattr(binding, "lsp_server_name", "") + if not server_name: + continue + with contextlib.suppress(LspError, LspServerNotFoundError): + self.stop_server(server_name) + + def stop_all(self) -> None: + """Shut down all running LSP servers.""" + self._lifecycle.stop_all() __all__ = [ diff --git a/src/cleveragents/lsp/tool_adapter.py b/src/cleveragents/lsp/tool_adapter.py index 729486ec..74fff4b7 100644 --- a/src/cleveragents/lsp/tool_adapter.py +++ b/src/cleveragents/lsp/tool_adapter.py @@ -1,25 +1,32 @@ -"""LSP Tool Adapter stub for local mode. +"""LSP Tool Adapter — bridges LSP capabilities to the tool interface. The :class:`LspToolAdapter` translates LSP capabilities into :class:`~cleveragents.tool.runtime.ToolSpec` entries so that actors can discover and invoke language-server features through the standard tool interface. -In **local mode** the adapter generates tool specs whose handlers -always raise :class:`LspNotAvailableError`. In server mode a -concrete adapter will replace the stub handlers with real LSP calls. +When an :class:`LspRuntime` is provided, generated handlers delegate +to the runtime for real LSP protocol calls. Without a runtime, handlers +raise :class:`LspNotAvailableError` (local-mode fallback). + +Based on ``docs/specification.md`` LSP Capability Exposure (lines 20695-20741) +and issue #826. """ from __future__ import annotations -import logging from collections.abc import Callable -from typing import Any +from typing import TYPE_CHECKING, Any + +import structlog from cleveragents.lsp.errors import LspNotAvailableError from cleveragents.lsp.models import LspCapability, LspServerConfig -logger = logging.getLogger(__name__) +if TYPE_CHECKING: + from cleveragents.lsp.runtime import LspRuntime + +logger = structlog.get_logger(__name__) # Maps each capability to the tool name suffix and description used # when generating ToolSpec entries. @@ -65,6 +72,8 @@ def _make_local_mode_handler( ) -> Callable[..., Any]: """Create a handler that raises :class:`LspNotAvailableError`. + Used as fallback when no runtime is provided. + Args: server_name: The namespaced LSP server name. capability: The capability this handler represents. @@ -83,13 +92,61 @@ def _make_local_mode_handler( return _handler +def _make_runtime_handler( + runtime: LspRuntime, + server_name: str, + capability: LspCapability, +) -> Callable[..., Any]: + """Create a handler that delegates to the LSP runtime. + + Args: + runtime: The functional LSP runtime. + server_name: The namespaced LSP server name. + capability: The capability this handler represents. + + Returns: + A callable that invokes the runtime method. + """ + + def _handler(**kwargs: Any) -> Any: + file_path: str = kwargs.get("file_path", "") + if not file_path: + return {"error": "file_path is required"} + + if capability == LspCapability.DIAGNOSTICS: + items = runtime.get_diagnostics(server_name, file_path) + return {"diagnostics": items} + + if capability == LspCapability.COMPLETIONS: + line: int = kwargs.get("line", 1) + column: int = kwargs.get("column", 1) + items = runtime.get_completions(server_name, file_path, line, column) + return {"completions": items} + + # Capabilities not yet implemented raise an explicit error + raise LspNotAvailableError( + f"LSP capability '{capability.value}' is not yet implemented " + f"for server '{server_name}'", + details={"server": server_name, "capability": capability.value}, + ) + + return _handler + + class LspToolAdapter: """Generates tool specs from LSP server capabilities. - In local mode every tool handler raises + When *runtime* is provided, generated handlers delegate to the + runtime. Without a runtime, handlers raise :class:`LspNotAvailableError`. + + Args: + runtime: Optional functional LSP runtime for real protocol calls. """ + def __init__(self, runtime: LspRuntime | None = None) -> None: + self._runtime = runtime + def generate_tool_specs( self, config: LspServerConfig, @@ -101,7 +158,7 @@ class LspToolAdapter: - ``name``: ``/`` - ``description``: Human-readable description. - - ``handler``: Callable (raises in local mode). + - ``handler``: Callable (delegates to runtime or raises). - ``input_schema``: Minimal JSON Schema stub. Args: @@ -124,7 +181,16 @@ class LspToolAdapter: (capability.value, f"LSP {capability.value} operation"), ) tool_name = f"{config.name}/{suffix}" - handler = _make_local_mode_handler(config.name, capability) + + if self._runtime is not None: + handler = _make_runtime_handler( + self._runtime, + config.name, + capability, + ) + else: + handler = _make_local_mode_handler(config.name, capability) + spec: dict[str, Any] = { "name": tool_name, "description": description, @@ -132,7 +198,12 @@ class LspToolAdapter: "input_schema": _input_schema_for(capability), } specs.append(spec) - logger.debug("Generated tool spec: %s (local-mode stub)", tool_name) + mode = "runtime" if self._runtime else "local-stub" + logger.debug( + "lsp.tool_adapter.generated", + tool=tool_name, + mode=mode, + ) return specs diff --git a/src/cleveragents/lsp/transport.py b/src/cleveragents/lsp/transport.py new file mode 100644 index 00000000..c85262cf --- /dev/null +++ b/src/cleveragents/lsp/transport.py @@ -0,0 +1,281 @@ +"""LSP stdio transport for subprocess-based language servers. + +Manages the lifecycle of an LSP server subprocess communicating via +JSON-RPC 2.0 over stdin/stdout. The transport handles: + +- Spawning the server process with the configured command and args. +- Writing JSON-RPC messages with ``Content-Length`` headers to stdin. +- Reading JSON-RPC responses from stdout. +- Graceful and forced process termination. + +The transport is **not** thread-safe by itself; callers (e.g., +:class:`LspClient`) must serialise access when sharing a transport +across threads. + +Based on ``docs/specification.md`` LSP Server Lifecycle (lines 20744-20758) +and issue #826. +""" + +from __future__ import annotations + +import json +import os +import select +import subprocess +import threading +from typing import Any + +import structlog + +logger = structlog.get_logger(__name__) + +# Maximum time (seconds) to wait for a graceful process termination +# before sending SIGKILL. +_GRACEFUL_SHUTDOWN_TIMEOUT = 5.0 + +# Default read timeout in seconds. Prevents indefinite blocking on +# readline() when the server is slow or unresponsive. +_DEFAULT_READ_TIMEOUT = 30.0 + +# Maximum size of a single JSON-RPC message body (10 MiB, matching +# the existing ``server.py`` limit). +_MAX_CONTENT_LENGTH = 10 * 1024 * 1024 + + +class StdioTransport: + """Stdio transport for an LSP server subprocess. + + Manages the full lifecycle of a language server process: + spawn, message I/O, and termination. + + Note: + Uses ``select.select()`` for non-blocking reads, which requires + Unix-like pipe file descriptors. Not supported on Windows where + ``select`` only works with sockets. + + Args: + command: Executable command (e.g., ``"pyright-langserver"``). + args: Additional CLI arguments (e.g., ``["--stdio"]``). + env: Extra environment variables merged with the current env. + cwd: Working directory for the subprocess. + """ + + def __init__( + self, + command: str, + args: list[str] | None = None, + env: dict[str, str] | None = None, + cwd: str | None = None, + ) -> None: + if not command: + raise ValueError("command must be a non-empty string") + self._command = command + self._args = args or [] + self._env = env or {} + self._cwd = cwd + self._process: subprocess.Popen[bytes] | None = None + self._read_lock = threading.Lock() + self._write_lock = threading.Lock() + + @property + def is_alive(self) -> bool: + """Return ``True`` if the subprocess is still running.""" + return self._process is not None and self._process.poll() is None + + def start(self) -> None: + """Spawn the LSP server subprocess. + + Raises: + LspError: If the process cannot be started. + RuntimeError: If already started. + """ + if self._process is not None and self.is_alive: + raise RuntimeError("Transport already started") + + merged_env = {**os.environ, **self._env} + cmd = [self._command, *self._args] + + logger.info( + "lsp.transport.starting", + command=self._command, + args=self._args, + cwd=self._cwd, + ) + + try: + self._process = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=merged_env, + cwd=self._cwd, + ) + except FileNotFoundError as exc: + from cleveragents.lsp.errors import LspError + + raise LspError( + f"LSP server command not found: {self._command}", + details={"command": self._command, "args": self._args}, + ) from exc + except OSError as exc: + from cleveragents.lsp.errors import LspError + + raise LspError( + f"Failed to start LSP server: {exc}", + details={"command": self._command, "error": str(exc)}, + ) from exc + + logger.info( + "lsp.transport.started", + pid=self._process.pid, + command=self._command, + ) + + def stop(self, timeout: float = _GRACEFUL_SHUTDOWN_TIMEOUT) -> int | None: + """Terminate the subprocess gracefully, then force-kill if needed. + + Args: + timeout: Seconds to wait for graceful exit before SIGKILL. + + Returns: + The process exit code, or ``None`` if not started. + """ + if self._process is None: + return None + + if self._process.poll() is not None: + code = self._process.returncode + self._process = None + return code + + logger.info( + "lsp.transport.stopping", + pid=self._process.pid, + ) + + self._process.terminate() + try: + self._process.wait(timeout=timeout) + except subprocess.TimeoutExpired: + logger.warning( + "lsp.transport.force_killing", + pid=self._process.pid, + ) + self._process.kill() + self._process.wait(timeout=2.0) + + code = self._process.returncode + self._process = None + logger.info("lsp.transport.stopped", exit_code=code) + return code + + def send_message(self, body: dict[str, Any]) -> None: + """Write a JSON-RPC message to the server's stdin. + + Args: + body: JSON-serialisable message body. + + Raises: + RuntimeError: If the transport is not started. + BrokenPipeError: If the server process has exited. + """ + if self._process is None or self._process.stdin is None: + raise RuntimeError("Transport not started") + + payload = json.dumps(body, separators=(",", ":")).encode("utf-8") + header = f"Content-Length: {len(payload)}\r\n\r\n".encode("ascii") + + with self._write_lock: + try: + self._process.stdin.write(header + payload) + self._process.stdin.flush() + except BrokenPipeError: + logger.error("lsp.transport.write_broken_pipe") + raise + + def read_message(self, timeout: float | None = None) -> dict[str, Any] | None: + """Read the next JSON-RPC message from the server's stdout. + + Uses ``select`` to wait for data with a timeout. Returns + ``None`` if no complete message arrives before *timeout* + seconds or the stream ends. + + Args: + timeout: Maximum seconds to wait for data. ``None`` + (default) waits up to ``_DEFAULT_READ_TIMEOUT``. + + Returns: + Parsed JSON body, or ``None`` if the stream ended or + timed out. + + Raises: + RuntimeError: If the transport is not started. + ValueError: If the message exceeds ``_MAX_CONTENT_LENGTH``. + """ + if self._process is None or self._process.stdout is None: + raise RuntimeError("Transport not started") + + effective_timeout = timeout if timeout is not None else _DEFAULT_READ_TIMEOUT + + with self._read_lock: + return self._read_one_message(effective_timeout) + + def _read_one_message(self, timeout: float) -> dict[str, Any] | None: + """Parse a single ``Content-Length`` framed JSON-RPC message.""" + assert self._process is not None + assert self._process.stdout is not None + + stdout = self._process.stdout + content_length: int | None = None + + # Read headers (with select-based timeout) + while True: + ready, _, _ = select.select([stdout], [], [], timeout) + if not ready: + return None # Timed out waiting for data + line = stdout.readline() + if not line: + return None # EOF — server exited + decoded = line.decode("ascii", errors="replace").strip() + if not decoded: + break # Empty line = end of headers + if decoded.lower().startswith("content-length:"): + try: + content_length = int(decoded.split(":", 1)[1].strip()) + except ValueError: + logger.warning( + "lsp.transport.invalid_content_length", + header=decoded, + ) + return None + + if content_length is None: + return None + + if content_length > _MAX_CONTENT_LENGTH: + raise ValueError( + f"LSP message too large: {content_length} > {_MAX_CONTENT_LENGTH}" + ) + + # Read body (wait for remaining data) + ready, _, _ = select.select([stdout], [], [], timeout) + if not ready: + return None + body_bytes = stdout.read(content_length) + if len(body_bytes) < content_length: + return None # Truncated — server exited mid-message + + try: + return json.loads(body_bytes) + except json.JSONDecodeError: + logger.warning( + "lsp.transport.invalid_json", + body_length=len(body_bytes), + ) + return None + + +__all__ = [ + "StdioTransport", +] diff --git a/vulture_whitelist.py b/vulture_whitelist.py index 442ff59e..e47a0c35 100644 --- a/vulture_whitelist.py +++ b/vulture_whitelist.py @@ -1177,3 +1177,16 @@ timeout_seconds # noqa: B018, F821 workspace_folder # noqa: B018, F821 execute_tool # noqa: B018, F821 wrap_service_method # noqa: B018, F821 + +# LSP functional runtime — public API (#826) +StdioTransport # noqa: B018, F821 +LspClient # noqa: B018, F821 +LspLifecycleManager # noqa: B018, F821 +LanguageDiscovery # noqa: B018, F821 +activate_bindings # noqa: B018, F821 +deactivate_bindings # noqa: B018, F821 +detect_file_language # noqa: B018, F821 +detect_directory_languages # noqa: B018, F821 +get_servers_for_language # noqa: B018, F821 +restart_server # noqa: B018, F821 +_make_runtime_handler # noqa: B018, F821