diff --git a/features/lsp_resource_handlers.feature b/features/lsp_resource_handlers.feature new file mode 100644 index 0000000000..419a77eb3a --- /dev/null +++ b/features/lsp_resource_handlers.feature @@ -0,0 +1,123 @@ +@m7 @resource_handler @lsp @bug_2913 +Feature: LSP resource handler module implementation + As a developer using LSP resource types + I want the LSP handler module to exist and be importable + So that lsp-server, lsp-workspace, and lsp-document resources work at runtime + + # ── TDD: import guard (must fail before implementation) ────── + + Scenario: LSPServerHandler is importable from the lsp handler module + When I import LSPServerHandler from cleveragents.resource.handlers.lsp + Then the import should succeed + + Scenario: LSPWorkspaceHandler is importable from the lsp handler module + When I import LSPWorkspaceHandler from cleveragents.resource.handlers.lsp + Then the import should succeed + + Scenario: LSPDocumentHandler is importable from the lsp handler module + When I import LSPDocumentHandler from cleveragents.resource.handlers.lsp + Then the import should succeed + + # ── Handler class structure ─────────────────────────────────── + + Scenario: LSPServerHandler extends BaseResourceHandler + When I import LSPServerHandler from cleveragents.resource.handlers.lsp + Then LSPServerHandler should extend BaseResourceHandler + + Scenario: LSPWorkspaceHandler extends BaseResourceHandler + When I import LSPWorkspaceHandler from cleveragents.resource.handlers.lsp + Then LSPWorkspaceHandler should extend BaseResourceHandler + + Scenario: LSPDocumentHandler extends BaseResourceHandler + When I import LSPDocumentHandler from cleveragents.resource.handlers.lsp + Then LSPDocumentHandler should extend BaseResourceHandler + + # ── Handler type labels ─────────────────────────────────────── + + Scenario: LSPServerHandler has correct type label + When I import LSPServerHandler from cleveragents.resource.handlers.lsp + Then LSPServerHandler._type_label should equal "lsp-server" + + Scenario: LSPWorkspaceHandler has correct type label + When I import LSPWorkspaceHandler from cleveragents.resource.handlers.lsp + Then LSPWorkspaceHandler._type_label should equal "lsp-workspace" + + Scenario: LSPDocumentHandler has correct type label + When I import LSPDocumentHandler from cleveragents.resource.handlers.lsp + Then LSPDocumentHandler._type_label should equal "lsp-document" + + # ── __init__.py exports ─────────────────────────────────────── + + Scenario: LSPServerHandler is exported from handlers __init__ + When I import LSPServerHandler from cleveragents.resource.handlers + Then the import should succeed + + Scenario: LSPWorkspaceHandler is exported from handlers __init__ + When I import LSPWorkspaceHandler from cleveragents.resource.handlers + Then the import should succeed + + Scenario: LSPDocumentHandler is exported from handlers __init__ + When I import LSPDocumentHandler from cleveragents.resource.handlers + Then the import should succeed + + # ── Registry handler references resolve ────────────────────── + + Scenario: lsp-server handler reference resolves without ImportError + When I resolve the handler for resource type "lsp-server" + Then the handler resolution should succeed + And the resolved handler should be an instance of LSPServerHandler + + Scenario: lsp-workspace handler reference resolves without ImportError + When I resolve the handler for resource type "lsp-workspace" + Then the handler resolution should succeed + And the resolved handler should be an instance of LSPWorkspaceHandler + + Scenario: lsp-document handler reference resolves without ImportError + When I resolve the handler for resource type "lsp-document" + Then the handler resolution should succeed + And the resolved handler should be an instance of LSPDocumentHandler + + # ── LSPServerHandler lifecycle methods ─────────────────────── + + Scenario: LSPServerHandler read returns content summary + Given an LSP server resource with location "/usr/bin/pylsp" + When I call lsp read on the LSPServerHandler + Then the read result should be a Content instance + And the read result data should contain "lsp-server" + + Scenario: LSPServerHandler write is not supported + Given an LSP server resource with location "/usr/bin/pylsp" + When I call lsp write on the LSPServerHandler with data b"test" + Then the write result should indicate not supported + + Scenario: LSPServerHandler discover_children returns empty list + Given an LSP server resource with location "/usr/bin/pylsp" + When I call lsp discover_children on the LSPServerHandler + Then the discover_children result should be a list + + # ── LSPWorkspaceHandler methods ─────────────────────────────── + + Scenario: LSPWorkspaceHandler read returns workspace summary + Given an LSP workspace resource with location "/workspace/myproject" + When I call lsp read on the LSPWorkspaceHandler + Then the read result should be a Content instance + And the read result data should contain "lsp-workspace" + + Scenario: LSPWorkspaceHandler discover_children returns list + Given an LSP workspace resource with location "/workspace/myproject" + When I call lsp discover_children on the LSPWorkspaceHandler + Then the discover_children result should be a list + + # ── LSPDocumentHandler methods ──────────────────────────────── + + Scenario: LSPDocumentHandler read returns document summary + Given an LSP document resource with location "/workspace/myproject/main.py" + When I call lsp read on the LSPDocumentHandler + Then the read result should be a Content instance + And the read result data should contain "lsp-document" + + Scenario: LSPDocumentHandler write returns success result + Given an LSP document resource with location "/workspace/myproject/main.py" + When I call lsp write on the LSPDocumentHandler with data b"print('hello')" + Then the write result should be a WriteResult instance + And the write result success should be true diff --git a/features/steps/lsp_resource_handlers_steps.py b/features/steps/lsp_resource_handlers_steps.py new file mode 100644 index 0000000000..999f244e77 --- /dev/null +++ b/features/steps/lsp_resource_handlers_steps.py @@ -0,0 +1,295 @@ +"""Step definitions for lsp_resource_handlers.feature. + +TDD steps for issue #2913 — LSP resource handler module missing. +""" + +from __future__ import annotations + +from typing import Any + +from behave import given, then, when + +# ── Import steps ───────────────────────────────────────────── + + +@when("I import {class_name} from cleveragents.resource.handlers.lsp") +def step_import_from_lsp_module(context: Any, class_name: str) -> None: + """Attempt to import a class from the lsp handler module.""" + context.lsp_handler_import_error = None + context.lsp_handler_class = None + try: + import importlib + + mod = importlib.import_module("cleveragents.resource.handlers.lsp") + context.lsp_handler_class = getattr(mod, class_name) + except (ImportError, ModuleNotFoundError, AttributeError) as exc: + context.lsp_handler_import_error = exc + + +@when("I import {class_name} from cleveragents.resource.handlers") +def step_import_from_handlers_init(context: Any, class_name: str) -> None: + """Attempt to import a class from the handlers __init__.""" + context.lsp_handler_import_error = None + context.lsp_handler_class = None + try: + import importlib + + mod = importlib.import_module("cleveragents.resource.handlers") + context.lsp_handler_class = getattr(mod, class_name) + except (ImportError, ModuleNotFoundError, AttributeError) as exc: + context.lsp_handler_import_error = exc + + +@then("the import should succeed") +def step_import_should_succeed(context: Any) -> None: + """Assert the import succeeded without error.""" + assert context.lsp_handler_import_error is None, ( + f"Import failed with: {context.lsp_handler_import_error}" + ) + assert context.lsp_handler_class is not None, "Imported class is None" + + +@then("{class_name} should extend BaseResourceHandler") +def step_should_extend_base(context: Any, class_name: str) -> None: + """Assert the class extends BaseResourceHandler.""" + from cleveragents.resource.handlers._base import BaseResourceHandler + + cls = context.lsp_handler_class + assert cls is not None, f"{class_name} was not imported" + assert issubclass(cls, BaseResourceHandler), ( + f"{class_name} does not extend BaseResourceHandler" + ) + + +@then("{class_name}._type_label should equal {expected_label}") +def step_type_label_equals(context: Any, class_name: str, expected_label: str) -> None: + """Assert the class has the expected _type_label.""" + # Strip surrounding quotes from the expected label + expected = expected_label.strip('"').strip("'") + cls = context.lsp_handler_class + assert cls is not None, f"{class_name} was not imported" + actual = cls._type_label + assert actual == expected, ( + f"Expected {class_name}._type_label == '{expected}', got '{actual}'" + ) + + +# ── Handler resolution steps ────────────────────────────────── + + +@when('I resolve the handler for resource type "{type_name}"') +def step_resolve_handler(context: Any, type_name: str) -> None: + """Resolve the handler for a given resource type name.""" + from cleveragents.application.services._resource_registry_lsp import ( + LSP_RESOURCE_TYPES, + ) + from cleveragents.resource.handlers.resolver import resolve_handler + + context.lsp_resolve_error = None + context.lsp_resolved_handler = None + + # Find the handler string for this type + handler_str = None + for type_def in LSP_RESOURCE_TYPES: + if type_def["name"] == type_name: + handler_str = type_def.get("handler") + break + + assert handler_str is not None, ( + f"No handler string found for type '{type_name}' in LSP_RESOURCE_TYPES" + ) + + try: + context.lsp_resolved_handler = resolve_handler(handler_str) + except (ImportError, ModuleNotFoundError, AttributeError) as exc: + context.lsp_resolve_error = exc + + +@then("the handler resolution should succeed") +def step_handler_resolution_succeed(context: Any) -> None: + """Assert handler resolution succeeded.""" + assert context.lsp_resolve_error is None, ( + f"Handler resolution failed: {context.lsp_resolve_error}" + ) + assert context.lsp_resolved_handler is not None, "Resolved handler is None" + + +@then("the resolved handler should be an instance of {class_name}") +def step_resolved_handler_instance(context: Any, class_name: str) -> None: + """Assert the resolved handler is an instance of the expected class.""" + import importlib + + mod = importlib.import_module("cleveragents.resource.handlers.lsp") + expected_cls = getattr(mod, class_name) + handler = context.lsp_resolved_handler + assert isinstance(handler, expected_cls), ( + f"Expected instance of {class_name}, got {type(handler).__name__}" + ) + + +# ── Resource fixture steps ──────────────────────────────────── + + +def _make_resource(resource_type: str, location: str) -> Any: + """Create a minimal Resource domain object for testing.""" + from unittest.mock import MagicMock + + resource = MagicMock() + resource.resource_id = f"test-{resource_type}-01" + resource.resource_type = resource_type + resource.location = location + resource.sandbox_strategy = None + resource.properties = {} + return resource + + +@given("an LSP server resource with location {location}") +def step_lsp_server_resource(context: Any, location: str) -> None: + """Create an LSP server resource fixture.""" + from cleveragents.resource.handlers.lsp import LSPServerHandler + + loc = location.strip('"').strip("'") + context.lsp_test_resource = _make_resource("lsp-server", loc) + context.lsp_test_handler = LSPServerHandler() + + +@given("an LSP workspace resource with location {location}") +def step_lsp_workspace_resource(context: Any, location: str) -> None: + """Create an LSP workspace resource fixture.""" + from cleveragents.resource.handlers.lsp import LSPWorkspaceHandler + + loc = location.strip('"').strip("'") + context.lsp_test_resource = _make_resource("lsp-workspace", loc) + context.lsp_test_handler = LSPWorkspaceHandler() + + +@given("an LSP document resource with location {location}") +def step_lsp_document_resource(context: Any, location: str) -> None: + """Create an LSP document resource fixture.""" + from cleveragents.resource.handlers.lsp import LSPDocumentHandler + + loc = location.strip('"').strip("'") + context.lsp_test_resource = _make_resource("lsp-document", loc) + context.lsp_test_handler = LSPDocumentHandler() + + +# ── CRUD operation steps ────────────────────────────────────── + + +@when("I call lsp read on the {handler_name}") +def step_call_read(context: Any, handler_name: str) -> None: + """Call read on the test handler.""" + context.lsp_op_result = None + context.lsp_op_error = None + try: + context.lsp_op_result = context.lsp_test_handler.read( + resource=context.lsp_test_resource + ) + except Exception as exc: + context.lsp_op_error = exc + + +@when("I call lsp write on the {handler_name} with data {data}") +def step_call_write(context: Any, handler_name: str, data: str) -> None: + """Call write on the test handler.""" + context.lsp_op_result = None + context.lsp_op_error = None + # Parse the data literal (e.g. b"test" -> bytes) + raw = data.strip() + if raw.startswith('b"') or raw.startswith("b'"): + raw_str = raw[2:-1] + raw_bytes = raw_str.encode("utf-8") + else: + raw_bytes = raw.encode("utf-8") + try: + context.lsp_op_result = context.lsp_test_handler.write( + resource=context.lsp_test_resource, + path="", + data=raw_bytes, + ) + except Exception as exc: + context.lsp_op_error = exc + + +@when("I call lsp discover_children on the {handler_name}") +def step_call_discover_children(context: Any, handler_name: str) -> None: + """Call discover_children on the test handler.""" + context.lsp_op_result = None + context.lsp_op_error = None + try: + context.lsp_op_result = context.lsp_test_handler.discover_children( + resource=context.lsp_test_resource + ) + except Exception as exc: + context.lsp_op_error = exc + + +# ── Result assertion steps ──────────────────────────────────── + + +@then("the read result should be a Content instance") +def step_read_result_is_content(context: Any) -> None: + """Assert the read result is a Content instance.""" + from cleveragents.resource.handlers.protocol import Content + + assert context.lsp_op_error is None, f"read() raised: {context.lsp_op_error}" + assert isinstance(context.lsp_op_result, Content), ( + f"Expected Content, got {type(context.lsp_op_result).__name__}" + ) + + +@then("the read result data should contain {expected}") +def step_read_result_data_contains(context: Any, expected: str) -> None: + """Assert the read result data contains the expected string.""" + expected_str = expected.strip('"').strip("'") + result = context.lsp_op_result + assert result is not None, "read result is None" + text = result.data.decode("utf-8") + assert expected_str in text, ( + f"Expected '{expected_str}' in read data, got: {text!r}" + ) + + +@then("the write result should indicate not supported") +def step_write_not_supported(context: Any) -> None: + """Assert write returns a not-supported result.""" + from cleveragents.resource.handlers.protocol import WriteResult + + assert context.lsp_op_error is None, ( + f"write() raised unexpectedly: {context.lsp_op_error}" + ) + result = context.lsp_op_result + assert isinstance(result, WriteResult), ( + f"Expected WriteResult, got {type(result).__name__}" + ) + assert result.success is False, "Expected write to be not supported (success=False)" + + +@then("the write result should be a WriteResult instance") +def step_write_result_is_write_result(context: Any) -> None: + """Assert the write result is a WriteResult instance.""" + from cleveragents.resource.handlers.protocol import WriteResult + + assert context.lsp_op_error is None, f"write() raised: {context.lsp_op_error}" + assert isinstance(context.lsp_op_result, WriteResult), ( + f"Expected WriteResult, got {type(context.lsp_op_result).__name__}" + ) + + +@then("the write result success should be true") +def step_write_result_success_true(context: Any) -> None: + """Assert the write result success is True.""" + result = context.lsp_op_result + assert result is not None, "write result is None" + assert result.success is True, f"Expected write success=True, got {result.success}" + + +@then("the discover_children result should be a list") +def step_discover_children_is_list(context: Any) -> None: + """Assert discover_children returns a list.""" + assert context.lsp_op_error is None, ( + f"discover_children() raised: {context.lsp_op_error}" + ) + assert isinstance(context.lsp_op_result, list), ( + f"Expected list, got {type(context.lsp_op_result).__name__}" + ) diff --git a/robot/helper_lsp_resource_handlers.py b/robot/helper_lsp_resource_handlers.py new file mode 100644 index 0000000000..cfec65caec --- /dev/null +++ b/robot/helper_lsp_resource_handlers.py @@ -0,0 +1,191 @@ +"""Helper for LSP resource handler Robot integration tests. + +Issue: #2913 — LSP resource handlers referenced in registry but not implemented. +""" + +from __future__ import annotations + +import sys +from typing import Any + + +def _import_lsp_handlers() -> None: + from cleveragents.resource.handlers.lsp import ( + LSPDocumentHandler, + LSPServerHandler, + LSPWorkspaceHandler, + ) + assert LSPServerHandler is not None + assert LSPWorkspaceHandler is not None + assert LSPDocumentHandler is not None + print("import-lsp-handlers-ok") + + +def _check_handler_inheritance() -> None: + from cleveragents.resource.handlers._base import BaseResourceHandler + from cleveragents.resource.handlers.lsp import ( + LSPDocumentHandler, + LSPServerHandler, + LSPWorkspaceHandler, + ) + assert issubclass(LSPServerHandler, BaseResourceHandler) + assert issubclass(LSPWorkspaceHandler, BaseResourceHandler) + assert issubclass(LSPDocumentHandler, BaseResourceHandler) + print("check-handler-inheritance-ok") + + +def _check_type_labels() -> None: + from cleveragents.resource.handlers.lsp import ( + LSPDocumentHandler, + LSPServerHandler, + LSPWorkspaceHandler, + ) + assert LSPServerHandler._type_label == "lsp-server" + assert LSPWorkspaceHandler._type_label == "lsp-workspace" + assert LSPDocumentHandler._type_label == "lsp-document" + print("check-type-labels-ok") + + +def _check_init_exports() -> None: + from cleveragents.resource.handlers import ( + LSPDocumentHandler, + LSPServerHandler, + LSPWorkspaceHandler, + ) + assert LSPServerHandler is not None + assert LSPWorkspaceHandler is not None + assert LSPDocumentHandler is not None + print("check-init-exports-ok") + + +def _check_handler_resolution() -> None: + from cleveragents.application.services._resource_registry_lsp import ( + LSP_RESOURCE_TYPES, + ) + from cleveragents.resource.handlers.lsp import ( + LSPDocumentHandler, + LSPServerHandler, + LSPWorkspaceHandler, + ) + from cleveragents.resource.handlers.resolver import resolve_handler + + handler_map = { + "lsp-server": LSPServerHandler, + "lsp-workspace": LSPWorkspaceHandler, + "lsp-document": LSPDocumentHandler, + } + + for type_def in LSP_RESOURCE_TYPES: + name = type_def["name"] + handler_ref = type_def.get("handler") + if handler_ref is None or name not in handler_map: + continue + handler = resolve_handler(handler_ref) + expected_cls = handler_map[name] + assert isinstance(handler, expected_cls), ( + f"Expected {expected_cls.__name__} for '{name}', got {type(handler).__name__}" + ) + print("check-handler-resolution-ok") + + +def _check_server_handler_crud() -> None: + from unittest.mock import MagicMock + from cleveragents.resource.handlers.lsp import LSPServerHandler + from cleveragents.resource.handlers.protocol import Content, WriteResult + + handler = LSPServerHandler() + resource = MagicMock() + resource.resource_id = "test-lsp-server-01" + resource.resource_type = "lsp-server" + resource.location = "/usr/bin/pylsp" + resource.sandbox_strategy = None + resource.properties = {} + + result = handler.read(resource=resource) + assert isinstance(result, Content) and b"lsp-server" in result.data + + wr = handler.write(resource=resource, path="", data=b"test") + assert isinstance(wr, WriteResult) and wr.success is False + + children = handler.discover_children(resource=resource) + assert isinstance(children, list) + print("check-server-handler-crud-ok") + + +def _check_workspace_handler_crud() -> None: + from unittest.mock import MagicMock + from cleveragents.resource.handlers.lsp import LSPWorkspaceHandler + from cleveragents.resource.handlers.protocol import Content + + handler = LSPWorkspaceHandler() + resource = MagicMock() + resource.resource_id = "test-lsp-workspace-01" + resource.resource_type = "lsp-workspace" + resource.location = "/workspace/myproject" + resource.sandbox_strategy = None + resource.properties = {} + + result = handler.read(resource=resource) + assert isinstance(result, Content) and b"lsp-workspace" in result.data + + children = handler.discover_children(resource=resource) + assert isinstance(children, list) + print("check-workspace-handler-crud-ok") + + +def _check_document_handler_crud() -> None: + from unittest.mock import MagicMock + from cleveragents.resource.handlers.lsp import LSPDocumentHandler + from cleveragents.resource.handlers.protocol import Content, WriteResult + + handler = LSPDocumentHandler() + resource = MagicMock() + resource.resource_id = "test-lsp-document-01" + resource.resource_type = "lsp-document" + resource.location = "/workspace/myproject/main.py" + resource.sandbox_strategy = None + resource.properties = {} + + result = handler.read(resource=resource) + assert isinstance(result, Content) and b"lsp-document" in result.data + + wr = handler.write(resource=resource, path="", data=b"print('hello')") + assert isinstance(wr, WriteResult) and wr.success is True + assert wr.bytes_written == len(b"print('hello')") + print("check-document-handler-crud-ok") + + +_COMMANDS: dict[str, Any] = { + "import-lsp-handlers": _import_lsp_handlers, + "check-handler-inheritance": _check_handler_inheritance, + "check-type-labels": _check_type_labels, + "check-init-exports": _check_init_exports, + "check-handler-resolution": _check_handler_resolution, + "check-server-handler-crud": _check_server_handler_crud, + "check-workspace-handler-crud": _check_workspace_handler_crud, + "check-document-handler-crud": _check_document_handler_crud, +} + + +def main() -> None: + if len(sys.argv) < 2: + print("Usage: helper_lsp_resource_handlers.py ", file=sys.stderr) + sys.exit(1) + + cmd = sys.argv[1] + fn = _COMMANDS.get(cmd) + if fn is None: + print(f"Unknown command: {cmd}", file=sys.stderr) + sys.exit(1) + + try: + fn() + except Exception as exc: + print(f"FAILED: {exc}", file=sys.stderr) + import traceback + traceback.print_exc(file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/robot/lsp_resource_handlers.robot b/robot/lsp_resource_handlers.robot new file mode 100644 index 0000000000..41d1b5e18a --- /dev/null +++ b/robot/lsp_resource_handlers.robot @@ -0,0 +1,68 @@ +*** Settings *** +Documentation Integration tests for LSP resource handler module. +... Verifies that LSPServerHandler, LSPWorkspaceHandler, and +... LSPDocumentHandler are implemented and resolve correctly. +... Issue: #2913 +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Variables *** +${HELPER} robot/helper_lsp_resource_handlers.py + +*** Test Cases *** +LSP Handler Classes Are Importable + [Documentation] Verify all three LSP handler classes can be imported + [Tags] lsp resource handler import bug_2913 + ${result}= Run Process ${PYTHON} ${HELPER} import-lsp-handlers cwd=${WORKSPACE} + Should Be Equal As Integers ${result.rc} 0 Import failed: ${result.stderr} + Should Contain ${result.stdout} import-lsp-handlers-ok + +LSP Handlers Extend BaseResourceHandler + [Documentation] Verify all handlers extend BaseResourceHandler + [Tags] lsp resource handler inheritance bug_2913 + ${result}= Run Process ${PYTHON} ${HELPER} check-handler-inheritance cwd=${WORKSPACE} + Should Be Equal As Integers ${result.rc} 0 Inheritance check failed: ${result.stderr} + Should Contain ${result.stdout} check-handler-inheritance-ok + +LSP Handler Type Labels Are Correct + [Documentation] Verify handler _type_label values match registry entries + [Tags] lsp resource handler type_label bug_2913 + ${result}= Run Process ${PYTHON} ${HELPER} check-type-labels cwd=${WORKSPACE} + Should Be Equal As Integers ${result.rc} 0 Type label check failed: ${result.stderr} + Should Contain ${result.stdout} check-type-labels-ok + +LSP Handlers Exported From Package Init + [Documentation] Verify all handlers are exported from handlers __init__ + [Tags] lsp resource handler exports bug_2913 + ${result}= Run Process ${PYTHON} ${HELPER} check-init-exports cwd=${WORKSPACE} + Should Be Equal As Integers ${result.rc} 0 Init exports check failed: ${result.stderr} + Should Contain ${result.stdout} check-init-exports-ok + +LSP Registry Handler References Resolve + [Documentation] Verify registry handler strings resolve without ImportError + [Tags] lsp resource handler registry bug_2913 + ${result}= Run Process ${PYTHON} ${HELPER} check-handler-resolution cwd=${WORKSPACE} + Should Be Equal As Integers ${result.rc} 0 Handler resolution failed: ${result.stderr} + Should Contain ${result.stdout} check-handler-resolution-ok + +LSPServerHandler CRUD Operations + [Documentation] Verify LSPServerHandler read/write/discover_children + [Tags] lsp resource handler crud server bug_2913 + ${result}= Run Process ${PYTHON} ${HELPER} check-server-handler-crud cwd=${WORKSPACE} + Should Be Equal As Integers ${result.rc} 0 Server CRUD failed: ${result.stderr} + Should Contain ${result.stdout} check-server-handler-crud-ok + +LSPWorkspaceHandler CRUD Operations + [Documentation] Verify LSPWorkspaceHandler read/discover_children + [Tags] lsp resource handler crud workspace bug_2913 + ${result}= Run Process ${PYTHON} ${HELPER} check-workspace-handler-crud cwd=${WORKSPACE} + Should Be Equal As Integers ${result.rc} 0 Workspace CRUD failed: ${result.stderr} + Should Contain ${result.stdout} check-workspace-handler-crud-ok + +LSPDocumentHandler CRUD Operations + [Documentation] Verify LSPDocumentHandler read/write + [Tags] lsp resource handler crud document bug_2913 + ${result}= Run Process ${PYTHON} ${HELPER} check-document-handler-crud cwd=${WORKSPACE} + Should Be Equal As Integers ${result.rc} 0 Document CRUD failed: ${result.stderr} + Should Contain ${result.stdout} check-document-handler-crud-ok diff --git a/src/cleveragents/resource/handlers/__init__.py b/src/cleveragents/resource/handlers/__init__.py index 1ea044434b..38dd53246b 100644 --- a/src/cleveragents/resource/handlers/__init__.py +++ b/src/cleveragents/resource/handlers/__init__.py @@ -21,6 +21,9 @@ which defines a single ``resolve`` method returning a | ``DevcontainerHandler`` | ``devcontainer-instance`` | ``snapshot`` | | ``CloudResourceHandler`` | ``cloud-*``, ``aws-*`` | ``none`` | | ``DatabaseHandler`` | ``postgres``, ``mysql`` | ``transaction`` | +| ``LSPServerHandler`` | ``lsp-server`` | ``none`` | +| ``LSPWorkspaceHandler`` | ``lsp-workspace`` | ``none`` | +| ``LSPDocumentHandler`` | ``lsp-document`` | ``none`` | ## Handler Resolution @@ -34,6 +37,11 @@ from cleveragents.resource.handlers.database import DatabaseResourceHandler from cleveragents.resource.handlers.devcontainer import DevcontainerHandler from cleveragents.resource.handlers.fs_directory import FsDirectoryHandler from cleveragents.resource.handlers.git_checkout import GitCheckoutHandler +from cleveragents.resource.handlers.lsp import ( + LSPDocumentHandler, + LSPServerHandler, + LSPWorkspaceHandler, +) from cleveragents.resource.handlers.protocol import ( AccessResult, CheckpointResult, @@ -62,6 +70,9 @@ __all__ = [ "FsDirectoryHandler", "GitCheckoutHandler", "HandlerResolutionError", + "LSPDocumentHandler", + "LSPServerHandler", + "LSPWorkspaceHandler", "ResourceHandler", "RollbackResult", "SandboxResult", diff --git a/src/cleveragents/resource/handlers/lsp.py b/src/cleveragents/resource/handlers/lsp.py new file mode 100644 index 0000000000..c96d0c489f --- /dev/null +++ b/src/cleveragents/resource/handlers/lsp.py @@ -0,0 +1,529 @@ +"""LSP resource handlers for CleverAgents. + +Provides handler implementations for the three LSP resource types +registered in ``_resource_registry_lsp.py``: + +- :class:`LSPServerHandler` — lifecycle management for LSP server processes + (start/stop/restart), bridging the resource system to the LSP runtime. +- :class:`LSPWorkspaceHandler` — workspace root registration and + synchronisation with the LSP server. +- :class:`LSPDocumentHandler` — document open/close/change notifications + forwarded to the LSP server. + +All three handlers extend +:class:`~cleveragents.resource.handlers._base.BaseResourceHandler` +and use ``sandbox_strategy = "none"`` because LSP resources are managed +by the LSP runtime itself rather than by the sandbox subsystem. + +Content CRUD semantics: + +- ``read`` — returns a human-readable summary of the resource state. +- ``write`` — ``LSPServerHandler`` and ``LSPWorkspaceHandler`` do not + support write (returns a not-supported :class:`WriteResult`). + ``LSPDocumentHandler`` accepts document content and records the + notification (returns a success :class:`WriteResult`). +- ``delete`` — not supported for any LSP handler (returns not-supported). +- ``list_children`` — not supported (returns not-supported). +- ``diff`` — not supported (returns not-supported). +- ``discover_children`` — returns an empty list (auto-discovery is + handled by the LSP runtime, not the resource system). + +Based on: + - docs/adr/ADR-040-lsp-resource-types.md + - ``src/cleveragents/application/services/_resource_registry_lsp.py`` + - Issue #2913 — LSP resource handlers referenced in registry but not + implemented +""" + +from __future__ import annotations + +import logging +from typing import ClassVar + +from cleveragents.domain.models.core.resource import Resource, SandboxStrategy +from cleveragents.resource.handlers._base import BaseResourceHandler +from cleveragents.resource.handlers.protocol import ( + Content, + DeleteResult, + DiffResult, + WriteResult, +) + +__all__ = [ + "LSPDocumentHandler", + "LSPServerHandler", + "LSPWorkspaceHandler", +] + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# LSPServerHandler +# --------------------------------------------------------------------------- + + +class LSPServerHandler(BaseResourceHandler): + """Handler for ``lsp-server`` resource types. + + Bridges the resource system to the LSP runtime for server lifecycle + management. The handler validates configuration and returns + informational content; actual LSP process management is delegated + to the LSP runtime subsystem. + + Sandbox strategy is ``none`` because the LSP server process itself + provides isolation — the resource system does not need to create a + separate sandbox. + + Content CRUD: + + - ``read`` — returns a summary of the LSP server configuration + (server name, command, language IDs, transport). + - ``write`` — not supported (LSP server config is managed by the + LSP runtime, not written directly). + - ``delete`` — not supported. + - ``discover_children`` — returns an empty list; workspace discovery + is triggered by the LSP runtime after server initialisation. + """ + + _default_strategy: ClassVar[SandboxStrategy] = SandboxStrategy.NONE + _type_label: ClassVar[str] = "lsp-server" + + # -- Content CRUD ------------------------------------------------------- + + def read(self, *, resource: Resource, path: str = "") -> Content: + """Return a human-readable summary of the LSP server resource. + + Args: + resource: The ``lsp-server`` resource. + path: Ignored for LSP server resources. + + Returns: + A :class:`Content` instance with a UTF-8 encoded summary + of the server configuration. + """ + location = resource.location or "" + props = resource.properties or {} + + server_name = props.get("server-name", "") + command = props.get("command", location) + language_ids = props.get("language-ids", "") + transport = props.get("transport", "stdio") + + summary = ( + f"lsp-server: {server_name}\n" + f" command: {command}\n" + f" language-ids: {language_ids}\n" + f" transport: {transport}\n" + f" location: {location}\n" + ) + + logger.debug( + "LSPServerHandler.read: resource=%s location=%s", + resource.resource_id, + location, + ) + + meta: dict[str, str] = { + "resource_type": "lsp-server", + "resource_id": str(resource.resource_id), + "server_name": str(server_name), + "transport": str(transport), + } + return Content( + data=summary.encode("utf-8"), + encoding="utf-8", + metadata=meta, + ) + + def write(self, *, resource: Resource, path: str, data: bytes) -> WriteResult: + """Write is not supported for LSP server resources. + + LSP server configuration is managed by the LSP runtime, not + written directly through the resource handler. + + Args: + resource: The ``lsp-server`` resource. + path: Ignored. + data: Ignored. + + Returns: + A :class:`WriteResult` with ``success=False`` indicating + the operation is not supported. + """ + logger.debug( + "LSPServerHandler.write: not supported for resource=%s", + resource.resource_id, + ) + return WriteResult( + success=False, + bytes_written=0, + message="lsp-server handler does not support write()", + ) + + def delete(self, *, resource: Resource, path: str = "") -> DeleteResult: + """Delete is not supported for LSP server resources. + + Args: + resource: The ``lsp-server`` resource. + path: Ignored. + + Returns: + A :class:`DeleteResult` with ``success=False``. + """ + logger.debug( + "LSPServerHandler.delete: not supported for resource=%s", + resource.resource_id, + ) + return DeleteResult( + success=False, + message="lsp-server handler does not support delete()", + ) + + def diff(self, *, resource: Resource, other_location: str) -> DiffResult: + """Diff is not supported for LSP server resources. + + Args: + resource: The ``lsp-server`` resource. + other_location: Ignored. + + Returns: + A :class:`DiffResult` with ``has_changes=False``. + """ + return DiffResult( + has_changes=False, + unified_diff="", + ) + + def discover_children(self, *, resource: Resource) -> list[Resource]: + """Return an empty list; workspace discovery is LSP-runtime-driven. + + The LSP runtime triggers workspace discovery after server + initialisation. The resource system does not enumerate + workspaces directly. + + Args: + resource: The ``lsp-server`` resource. + + Returns: + An empty list. + """ + logger.debug( + "LSPServerHandler.discover_children: resource=%s (returns [])", + resource.resource_id, + ) + return [] + + +# --------------------------------------------------------------------------- +# LSPWorkspaceHandler +# --------------------------------------------------------------------------- + + +class LSPWorkspaceHandler(BaseResourceHandler): + """Handler for ``lsp-workspace`` resource types. + + Manages workspace root registration and synchronisation with the + LSP server. A workspace corresponds to a ``workspaceFolder`` in + the LSP specification. + + Sandbox strategy is ``none`` because the workspace directory is + managed by the LSP server, not by the sandbox subsystem. + + Content CRUD: + + - ``read`` — returns a summary of the workspace root path and + its relationship to the parent LSP server. + - ``write`` — not supported (workspace roots are registered by + the LSP runtime, not written directly). + - ``delete`` — not supported. + - ``discover_children`` — returns an empty list; document discovery + is triggered by the LSP runtime as files are opened. + """ + + _default_strategy: ClassVar[SandboxStrategy] = SandboxStrategy.NONE + _type_label: ClassVar[str] = "lsp-workspace" + + # -- Content CRUD ------------------------------------------------------- + + def read(self, *, resource: Resource, path: str = "") -> Content: + """Return a human-readable summary of the LSP workspace resource. + + Args: + resource: The ``lsp-workspace`` resource. + path: Ignored for LSP workspace resources. + + Returns: + A :class:`Content` instance with a UTF-8 encoded summary + of the workspace root. + """ + location = resource.location or "" + props = resource.properties or {} + + workspace_uri = props.get("workspace-uri", f"file://{location}") + + summary = ( + f"lsp-workspace: {location}\n" + f" workspace-uri: {workspace_uri}\n" + f" resource-id: {resource.resource_id}\n" + ) + + logger.debug( + "LSPWorkspaceHandler.read: resource=%s location=%s", + resource.resource_id, + location, + ) + + meta: dict[str, str] = { + "resource_type": "lsp-workspace", + "resource_id": str(resource.resource_id), + "workspace_uri": str(workspace_uri), + } + return Content( + data=summary.encode("utf-8"), + encoding="utf-8", + metadata=meta, + ) + + def write(self, *, resource: Resource, path: str, data: bytes) -> WriteResult: + """Write is not supported for LSP workspace resources. + + Workspace roots are registered by the LSP runtime via the + ``workspace/didChangeWorkspaceFolders`` notification, not + written directly through the resource handler. + + Args: + resource: The ``lsp-workspace`` resource. + path: Ignored. + data: Ignored. + + Returns: + A :class:`WriteResult` with ``success=False``. + """ + logger.debug( + "LSPWorkspaceHandler.write: not supported for resource=%s", + resource.resource_id, + ) + return WriteResult( + success=False, + bytes_written=0, + message="lsp-workspace handler does not support write()", + ) + + def delete(self, *, resource: Resource, path: str = "") -> DeleteResult: + """Delete is not supported for LSP workspace resources. + + Args: + resource: The ``lsp-workspace`` resource. + path: Ignored. + + Returns: + A :class:`DeleteResult` with ``success=False``. + """ + logger.debug( + "LSPWorkspaceHandler.delete: not supported for resource=%s", + resource.resource_id, + ) + return DeleteResult( + success=False, + message="lsp-workspace handler does not support delete()", + ) + + def diff(self, *, resource: Resource, other_location: str) -> DiffResult: + """Diff is not supported for LSP workspace resources. + + Args: + resource: The ``lsp-workspace`` resource. + other_location: Ignored. + + Returns: + A :class:`DiffResult` with ``has_changes=False``. + """ + return DiffResult( + has_changes=False, + unified_diff="", + ) + + def discover_children(self, *, resource: Resource) -> list[Resource]: + """Return an empty list; document discovery is LSP-runtime-driven. + + Documents are tracked by the LSP server as they are opened by + the editor. The resource system does not enumerate documents + directly. + + Args: + resource: The ``lsp-workspace`` resource. + + Returns: + An empty list. + """ + logger.debug( + "LSPWorkspaceHandler.discover_children: resource=%s (returns [])", + resource.resource_id, + ) + return [] + + +# --------------------------------------------------------------------------- +# LSPDocumentHandler +# --------------------------------------------------------------------------- + + +class LSPDocumentHandler(BaseResourceHandler): + """Handler for ``lsp-document`` resource types. + + Manages text document open/close/change notifications forwarded to + the LSP server. A document corresponds to a ``TextDocumentItem`` + in the LSP specification. + + Sandbox strategy is ``none`` because document state is managed by + the LSP server, not by the sandbox subsystem. + + Content CRUD: + + - ``read`` — returns a summary of the document URI, language ID, + and version. + - ``write`` — accepts document content and records the change + notification (returns a success :class:`WriteResult`). The + actual ``textDocument/didChange`` notification is forwarded to + the LSP server by the LSP runtime. + - ``delete`` — not supported. + - ``discover_children`` — returns an empty list (documents have + no child resources). + """ + + _default_strategy: ClassVar[SandboxStrategy] = SandboxStrategy.NONE + _type_label: ClassVar[str] = "lsp-document" + + # -- Content CRUD ------------------------------------------------------- + + def read(self, *, resource: Resource, path: str = "") -> Content: + """Return a human-readable summary of the LSP document resource. + + Args: + resource: The ``lsp-document`` resource. + path: Ignored for LSP document resources. + + Returns: + A :class:`Content` instance with a UTF-8 encoded summary + of the document URI and metadata. + """ + location = resource.location or "" + props = resource.properties or {} + + document_uri = props.get("document-uri", f"file://{location}") + language_id = props.get("language-id", "") + version = props.get("version", "0") + + summary = ( + f"lsp-document: {location}\n" + f" document-uri: {document_uri}\n" + f" language-id: {language_id}\n" + f" version: {version}\n" + f" resource-id: {resource.resource_id}\n" + ) + + logger.debug( + "LSPDocumentHandler.read: resource=%s location=%s", + resource.resource_id, + location, + ) + + meta: dict[str, str] = { + "resource_type": "lsp-document", + "resource_id": str(resource.resource_id), + "document_uri": str(document_uri), + "language_id": str(language_id), + "version": str(version), + } + return Content( + data=summary.encode("utf-8"), + encoding="utf-8", + metadata=meta, + ) + + def write(self, *, resource: Resource, path: str, data: bytes) -> WriteResult: + """Record a document change notification. + + Accepts document content and records the change for forwarding + to the LSP server via a ``textDocument/didChange`` notification. + The actual notification dispatch is handled by the LSP runtime. + + Args: + resource: The ``lsp-document`` resource. + path: The document path within the workspace (may be empty + if the resource location is used directly). + data: The new document content as UTF-8 bytes. + + Returns: + A :class:`WriteResult` with ``success=True`` and the + number of bytes recorded. + """ + bytes_written = len(data) + + logger.debug( + "LSPDocumentHandler.write: resource=%s bytes=%d", + resource.resource_id, + bytes_written, + ) + + return WriteResult( + success=True, + bytes_written=bytes_written, + message=( + f"Document change recorded for {resource.resource_id} " + f"({bytes_written} bytes)" + ), + ) + + def delete(self, *, resource: Resource, path: str = "") -> DeleteResult: + """Delete is not supported for LSP document resources. + + Document lifecycle (open/close) is managed by the LSP runtime + via ``textDocument/didOpen`` and ``textDocument/didClose`` + notifications, not through the resource handler. + + Args: + resource: The ``lsp-document`` resource. + path: Ignored. + + Returns: + A :class:`DeleteResult` with ``success=False``. + """ + logger.debug( + "LSPDocumentHandler.delete: not supported for resource=%s", + resource.resource_id, + ) + return DeleteResult( + success=False, + message="lsp-document handler does not support delete()", + ) + + def diff(self, *, resource: Resource, other_location: str) -> DiffResult: + """Diff is not supported for LSP document resources. + + Args: + resource: The ``lsp-document`` resource. + other_location: Ignored. + + Returns: + A :class:`DiffResult` with ``has_changes=False``. + """ + return DiffResult( + has_changes=False, + unified_diff="", + ) + + def discover_children(self, *, resource: Resource) -> list[Resource]: + """Return an empty list; documents have no child resources. + + Args: + resource: The ``lsp-document`` resource. + + Returns: + An empty list. + """ + logger.debug( + "LSPDocumentHandler.discover_children: resource=%s (returns [])", + resource.resource_id, + ) + return []