forked from cleveragents/cleveragents-core
feat(lsp): implement functional LSP runtime
Replace local-mode stubs with real LSP protocol support: - StdioTransport: subprocess management with JSON-RPC framing - LspClient: LSP protocol (initialize/shutdown/diagnostics/completions) - LspLifecycleManager: reference-counted instances, health checks, crash restart - LspRuntime: registry-based server lookup, auto-restart on crash - LspToolAdapter: runtime-delegating handlers with local-mode fallback - LanguageDiscovery: 4-layer detection (extension, shebang, UKO, project) - activate_bindings/deactivate_bindings: actor compiler LSP binding wiring Tests: 27 Behave scenarios, 6 Robot integration tests, 250 existing pass. ISSUES CLOSED: #826
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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 <interpreter>
|
||||
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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
+251
-62
@@ -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__ = [
|
||||
|
||||
@@ -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``: ``<server_name>/<capability_suffix>``
|
||||
- ``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
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user