Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "java-functional-lsp"
version = "0.7.2"
version = "0.7.3"
description = "Java LSP server enforcing functional programming best practices — null safety, immutability, no exceptions"
readme = "README.md"
license = { text = "MIT" }
Expand Down
2 changes: 1 addition & 1 deletion src/java_functional_lsp/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""java-functional-lsp: A Java LSP server enforcing functional programming best practices."""

__version__ = "0.7.2"
__version__ = "0.7.3"
105 changes: 89 additions & 16 deletions src/java_functional_lsp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,11 +228,11 @@ def on_initialize(params: lsp.InitializeParams) -> lsp.InitializeResult:
change=lsp.TextDocumentSyncKind.Full,
save=lsp.SaveOptions(include_text=True),
),
completion_provider=lsp.CompletionOptions(trigger_characters=["."]),
hover_provider=True,
definition_provider=True,
references_provider=True,
document_symbol_provider=True,
# Only advertise capabilities we own (custom diagnostics + code actions).
# jdtls-dependent features (hover, definition, references, completion,
# documentSymbol) are registered dynamically after jdtls starts — see
# on_initialized(). This prevents us from claiming hover when jdtls
# isn't ready, which would suppress the IDE's diagnostic tooltips.
code_action_provider=lsp.CodeActionOptions(
code_action_kinds=[lsp.CodeActionKind.QuickFix],
),
Expand All @@ -250,10 +250,71 @@ async def on_initialized(params: lsp.InitializedParams) -> None:
started = await server._proxy.start(server._init_params)
if started:
logger.info("jdtls proxy active — full Java language support enabled")
await _register_jdtls_capabilities()
else:
logger.info("jdtls proxy unavailable — running with custom rules only")


_JAVA_SELECTOR = [lsp.TextDocumentFilterLanguage(language="java")]

_JDTLS_REG_PREFIX = "jdtls-"

# jdtls-dependent capabilities registered dynamically after the proxy starts.
# Each entry: (id_suffix, LSP method, registration options class, extra kwargs).
_JDTLS_CAPABILITIES: list[tuple[str, str, type[Any], dict[str, Any]]] = [
("completion", lsp.TEXT_DOCUMENT_COMPLETION, lsp.CompletionRegistrationOptions, {"trigger_characters": ["."]}),
("hover", lsp.TEXT_DOCUMENT_HOVER, lsp.HoverRegistrationOptions, {}),
("definition", lsp.TEXT_DOCUMENT_DEFINITION, lsp.DefinitionRegistrationOptions, {}),
("references", lsp.TEXT_DOCUMENT_REFERENCES, lsp.ReferenceRegistrationOptions, {}),
("document-symbol", lsp.TEXT_DOCUMENT_DOCUMENT_SYMBOL, lsp.DocumentSymbolRegistrationOptions, {}),
]

# Maps LSP method → handler function for dynamic registration.
_JDTLS_HANDLERS: dict[str, Any] = {}

# Set after first successful registration to prevent FeatureAlreadyRegisteredError.
_jdtls_capabilities_registered = False


def _build_jdtls_registrations() -> list[lsp.Registration]:
"""Build LSP Registration objects for jdtls-dependent capabilities."""
return [
lsp.Registration(
id=f"{_JDTLS_REG_PREFIX}{suffix}",
method=method,
register_options=_converter.unstructure(opts_cls(document_selector=_JAVA_SELECTOR, **extra)),
)
for suffix, method, opts_cls, extra in _JDTLS_CAPABILITIES
]


async def _register_jdtls_capabilities() -> None:
"""Dynamically register jdtls-dependent capabilities after the proxy starts.

We don't advertise these in the static InitializeResult because doing so
would make the IDE defer hover/definition/etc to us even before jdtls is
ready, which suppresses the IDE's built-in diagnostic tooltips.

Idempotent: safe to call multiple times (e.g., proxy restart).
"""
global _jdtls_capabilities_registered
if _jdtls_capabilities_registered:
return

try:
# Register handlers so pygls dispatches incoming requests to them.
for method, handler in _JDTLS_HANDLERS.items():
server.feature(method)(handler)

# Tell the client we now support these capabilities.
registrations = _build_jdtls_registrations()
await server.client_register_capability_async(lsp.RegistrationParams(registrations=registrations))
_jdtls_capabilities_registered = True
logger.info("Dynamically registered jdtls capabilities (hover, definition, references, completion, symbol)")
except Exception:
logger.warning("Failed to dynamically register jdtls capabilities", exc_info=True)


# --- Document sync (forward to jdtls + run custom analyzers) ---


Expand Down Expand Up @@ -311,11 +372,15 @@ async def on_did_close(params: lsp.DidCloseTextDocumentParams) -> None:
await server._proxy.send_notification("textDocument/didClose", _serialize_params(params))


# --- Forwarded features (jdtls passthrough) ---
# --- jdtls passthrough handlers (registered dynamically, NOT at module level) ---
#
# These are NOT decorated with @server.feature because pygls auto-advertises
# capabilities for decorated handlers. Instead, they are collected in
# _JDTLS_HANDLERS and registered inside _register_jdtls_capabilities() so
# they only activate after jdtls starts.


@server.feature(lsp.TEXT_DOCUMENT_COMPLETION)
async def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList | None:
async def _on_completion(params: lsp.CompletionParams) -> lsp.CompletionList | None:
"""Forward completion request to jdtls."""
if not server._proxy.is_available:
return None
Expand All @@ -328,8 +393,7 @@ async def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList | No
return None


@server.feature(lsp.TEXT_DOCUMENT_HOVER)
async def on_hover(params: lsp.HoverParams) -> lsp.Hover | None:
async def _on_hover(params: lsp.HoverParams) -> lsp.Hover | None:
"""Forward hover request to jdtls."""
if not server._proxy.is_available:
return None
Expand All @@ -342,8 +406,7 @@ async def on_hover(params: lsp.HoverParams) -> lsp.Hover | None:
return None


@server.feature(lsp.TEXT_DOCUMENT_DEFINITION)
async def on_definition(params: lsp.DefinitionParams) -> list[lsp.Location] | None:
async def _on_definition(params: lsp.DefinitionParams) -> list[lsp.Location] | None:
"""Forward go-to-definition request to jdtls."""
if not server._proxy.is_available:
return None
Expand All @@ -358,8 +421,7 @@ async def on_definition(params: lsp.DefinitionParams) -> list[lsp.Location] | No
return None


@server.feature(lsp.TEXT_DOCUMENT_REFERENCES)
async def on_references(params: lsp.ReferenceParams) -> list[lsp.Location] | None:
async def _on_references(params: lsp.ReferenceParams) -> list[lsp.Location] | None:
"""Forward find-references request to jdtls."""
if not server._proxy.is_available:
return None
Expand All @@ -372,8 +434,7 @@ async def on_references(params: lsp.ReferenceParams) -> list[lsp.Location] | Non
return None


@server.feature(lsp.TEXT_DOCUMENT_DOCUMENT_SYMBOL)
async def on_document_symbol(params: lsp.DocumentSymbolParams) -> list[lsp.DocumentSymbol] | None:
async def _on_document_symbol(params: lsp.DocumentSymbolParams) -> list[lsp.DocumentSymbol] | None:
"""Forward document symbol request to jdtls."""
if not server._proxy.is_available:
return None
Expand All @@ -386,6 +447,18 @@ async def on_document_symbol(params: lsp.DocumentSymbolParams) -> list[lsp.Docum
return None


# Populate handler map for dynamic registration.
_JDTLS_HANDLERS.update(
{
lsp.TEXT_DOCUMENT_COMPLETION: _on_completion,
lsp.TEXT_DOCUMENT_HOVER: _on_hover,
lsp.TEXT_DOCUMENT_DEFINITION: _on_definition,
lsp.TEXT_DOCUMENT_REFERENCES: _on_references,
lsp.TEXT_DOCUMENT_DOCUMENT_SYMBOL: _on_document_symbol,
}
)


# --- Code actions (quick fixes) ---

# Human-readable titles for code actions
Expand Down
137 changes: 136 additions & 1 deletion tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,130 @@ def test_on_jdtls_diagnostics_callback(self) -> None:
finally:
server.workspace.remove_text_document(uri)

def test_init_capabilities_exclude_jdtls_features(self) -> None:
"""Static capabilities must NOT include hover/definition/references/completion/documentSymbol.

These are registered dynamically after jdtls starts, so the IDE doesn't
suppress diagnostic tooltips while jdtls is unavailable.
"""
from java_functional_lsp.server import on_initialize

result = on_initialize(
lsp.InitializeParams(
process_id=1,
root_uri="file:///tmp",
capabilities=lsp.ClientCapabilities(),
)
)
caps = result.capabilities
assert caps.code_action_provider is not None
assert caps.text_document_sync is not None
assert caps.hover_provider is None
assert caps.definition_provider is None
assert caps.references_provider is None
assert caps.completion_provider is None
assert caps.document_symbol_provider is None

def test_build_jdtls_registrations(self) -> None:
"""_build_jdtls_registrations returns one Registration per jdtls capability, each scoped to java files."""
from java_functional_lsp.server import _JDTLS_REG_PREFIX, _build_jdtls_registrations

regs = _build_jdtls_registrations()
assert len(regs) == 5
methods = {r.method for r in regs}
assert lsp.TEXT_DOCUMENT_HOVER in methods
assert lsp.TEXT_DOCUMENT_DEFINITION in methods
assert lsp.TEXT_DOCUMENT_REFERENCES in methods
assert lsp.TEXT_DOCUMENT_COMPLETION in methods
assert lsp.TEXT_DOCUMENT_DOCUMENT_SYMBOL in methods
# All IDs are unique and use the shared prefix
ids = {r.id for r in regs}
assert len(ids) == 5
assert all(rid.startswith(_JDTLS_REG_PREFIX) for rid in ids)
# All have java document selector with correct language
for r in regs:
assert r.register_options is not None
selectors = r.register_options["documentSelector"]
assert any(s.get("language") == "java" for s in selectors)
# Completion has triggerCharacters
comp = next(r for r in regs if r.method == lsp.TEXT_DOCUMENT_COMPLETION)
assert comp.register_options.get("triggerCharacters") == ["."]

async def test_register_jdtls_capabilities_logs_on_failure(self, caplog: Any) -> None:
"""_register_jdtls_capabilities logs a warning when the client rejects."""
import logging
from unittest.mock import AsyncMock, MagicMock, patch

import java_functional_lsp.server as srv_mod
from java_functional_lsp.server import server as srv

# Patch both server.feature (to avoid FeatureAlreadyRegisteredError on
# the shared singleton) and client_register_capability_async (to trigger error).
mock_reg = AsyncMock(side_effect=Exception("no"))
mock_feature = MagicMock(return_value=lambda fn: fn)
old_flag = srv_mod._jdtls_capabilities_registered
srv_mod._jdtls_capabilities_registered = False
try:
with (
caplog.at_level(logging.WARNING, logger="java_functional_lsp.server"),
patch.object(srv, "client_register_capability_async", mock_reg),
patch.object(srv, "feature", mock_feature),
):
await srv_mod._register_jdtls_capabilities()
finally:
srv_mod._jdtls_capabilities_registered = old_flag
assert any("Failed to dynamically register" in r.getMessage() for r in caplog.records)

async def test_register_jdtls_capabilities_happy_path(self, caplog: Any) -> None:
"""On success, handlers are registered and info log is emitted."""
import logging
from unittest.mock import AsyncMock, MagicMock, patch

import java_functional_lsp.server as srv_mod
from java_functional_lsp.server import server as srv

mock_reg = AsyncMock(return_value=None)
registered_methods: list[str] = []
mock_feature = MagicMock(side_effect=lambda m: registered_methods.append(m) or (lambda fn: fn))
old_flag = srv_mod._jdtls_capabilities_registered
srv_mod._jdtls_capabilities_registered = False
try:
with (
caplog.at_level(logging.INFO, logger="java_functional_lsp.server"),
patch.object(srv, "client_register_capability_async", mock_reg),
patch.object(srv, "feature", mock_feature),
):
await srv_mod._register_jdtls_capabilities()
finally:
srv_mod._jdtls_capabilities_registered = old_flag
# Handlers were registered for all 5 methods
assert lsp.TEXT_DOCUMENT_HOVER in registered_methods
assert lsp.TEXT_DOCUMENT_COMPLETION in registered_methods
assert lsp.TEXT_DOCUMENT_DEFINITION in registered_methods
assert lsp.TEXT_DOCUMENT_REFERENCES in registered_methods
assert lsp.TEXT_DOCUMENT_DOCUMENT_SYMBOL in registered_methods
# client_register_capability_async was called
mock_reg.assert_called_once()
# Success log emitted
assert any("Dynamically registered" in r.getMessage() for r in caplog.records)

async def test_register_jdtls_capabilities_idempotent(self) -> None:
"""Second call is a no-op (idempotency guard)."""
from unittest.mock import AsyncMock, patch

import java_functional_lsp.server as srv_mod
from java_functional_lsp.server import server as srv

mock_reg = AsyncMock()
old_flag = srv_mod._jdtls_capabilities_registered
srv_mod._jdtls_capabilities_registered = True
try:
with patch.object(srv, "client_register_capability_async", mock_reg):
await srv_mod._register_jdtls_capabilities()
finally:
srv_mod._jdtls_capabilities_registered = old_flag
mock_reg.assert_not_called()

def test_serialize_params_camelcase(self) -> None:
from java_functional_lsp.server import _serialize_params

Expand Down Expand Up @@ -396,11 +520,22 @@ class TestLspLifecycle:
"""Full LSP lifecycle tests via real stdio transport — zero mocks."""

async def test_initialize_reports_capabilities(self, lsp_client: LanguageClient) -> None:
"""Server advertises codeActionProvider and textDocumentSync."""
"""Server advertises codeActionProvider and textDocumentSync but NOT jdtls features.

jdtls-dependent capabilities (hover, definition, references, completion,
documentSymbol) are registered dynamically after jdtls starts, so they
should NOT appear in the static InitializeResult.
"""
caps = lsp_client._server_capabilities # type: ignore[attr-defined]
assert caps is not None
assert caps.code_action_provider is not None
assert caps.text_document_sync is not None
# jdtls features are NOT statically advertised (registered dynamically)
assert caps.hover_provider is None
assert caps.definition_provider is None
assert caps.references_provider is None
assert caps.completion_provider is None
assert caps.document_symbol_provider is None

async def test_null_return_diagnostic_published(self, lsp_client: LanguageClient) -> None:
"""didOpen a file with ``return null`` → server publishes null-return diagnostic."""
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading