Skip to content

fix: use lsprotocol-aware converter for jdtls + comprehensive e2e test suite#43

Merged
aviadshiber merged 14 commits intomainfrom
fix/lsp-camelcase-serialization
Apr 10, 2026
Merged

fix: use lsprotocol-aware converter for jdtls + comprehensive e2e test suite#43
aviadshiber merged 14 commits intomainfrom
fix/lsp-camelcase-serialization

Conversation

@aviadshiber
Copy link
Copy Markdown
Owner

@aviadshiber aviadshiber commented Apr 10, 2026

Summary

Fixes broken jdtls request forwarding (every go-to-definition, find-references, hover, document symbol, and completion request NPE'd) and adds a comprehensive end-to-end test suite that would have caught this before release.

The Bug

server.py used a vanilla cattrs.Converter() to serialize LSP request parameters before forwarding them to jdtls. This emitted snake_case field names (text_document) instead of the LSP-required camelCase (textDocument). jdtls's handlers then saw null for TextDocumentPositionParams.getTextDocument() and threw NullPointerException on every forwarded request.

This has been broken since the jdtls proxy feature was introduced. Custom tree-sitter rules worked fine (they don't touch jdtls), but completions, hover, go-to-def, references, and document symbol were all dead.

The Fix

One-line change: cattrs.Converter() -> lsprotocol.converters.get_converter(). The LSP-aware converter handles camelCase, discriminated unions, and None-field pruning correctly.

Test Architecture (the real payload of this PR)

We had two bugs slip through in the same release cycle (Java 21 detection in #42 + this camelCase bug) because all tests used mocked subprocesses that never parsed the actual bytes. This PR adds a layered test architecture that makes such regressions impossible:

Tier File Tests Mocks What it catches
Unit test_fixes.py, test_*_checker.py, test_proxy.py 292 Minimal Analyzer logic, fix generators, proxy helpers
Server internals test_server.py: TestServerInternals 16 1 (transport publish) Config loading, diagnostic conversion, code action pipeline, camelCase serialization, excludes, jdtls callback
LSP lifecycle test_server.py: TestLspLifecycle 9 Zero Real server subprocess via pygls LanguageClient over stdio: initialize -> didOpen -> publishDiagnostics -> codeAction -> didChange. Catches serialization bugs, transport framing, workspace wiring.
jdtls e2e test_e2e_jdtls.py 7 Zero Real jdtls subprocess: definition, references, hover, completion, documentSymbol, didOpen. Catches Java detection + request-shape bugs against the actual Eclipse JDT LS.

Verified the tests catch the bug

Temporarily reverted _converter to cattrs.Converter() -- the first LSP lifecycle test failed immediately:

AssertionError: _serialize_params emitted wrong field names:
  dict_keys(['text_document', 'work_done_token', 'partial_result_token'])

CI Changes

  • Unit matrix (test job): 8 combos (Ubuntu + macOS x 4 Python versions), runs -m "not e2e", enforces 80% coverage (up from 60%)
  • Integration job: Ubuntu + macOS, Python 3.12, installs Java 21 + jdtls 1.57.0 (sha256-verified against Homebrew formula), runs the full suite (unit + e2e), enforces 80% coverage
  • Linux jdtls install: direct tarball download from Eclipse milestones with sha256 verification
  • macOS jdtls install: brew install jdtls

Numbers

  • 331 tests (was 292 before this PR)
  • 83% coverage (was 77%)
  • Coverage bar raised from 60% to 80%
  • LSP lifecycle tests run in ~3s (real server subprocess)
  • jdtls e2e tests run in ~70s (real jdtls cold start)

Files Changed

File Change
src/java_functional_lsp/server.py cattrs.Converter() -> get_converter(), drop import cattrs
tests/test_server.py New: 16 server-internal tests + 9 mock-free LSP lifecycle tests
tests/test_e2e_jdtls.py New: 7 jdtls e2e tests (definition, references, hover, completion, documentSymbol, didOpen, initialize)
tests/test_proxy.py 7 camelCase regression tests in TestLspConverterCamelCase
.github/workflows/test.yml New integration job (Ubuntu + macOS) with jdtls install + sha256 verification
pyproject.toml Coverage threshold 60% -> 80%, e2e pytest marker
Version files 0.7.1 -> 0.7.2

Post-merge

Cut v0.7.2 release -> PyPI -> Homebrew tap auto-updates -> brew upgrade.

🤖 Generated with Claude Code

aviadsTaboola and others added 9 commits April 10, 2026 06:35
The server used a vanilla cattrs.Converter() to serialize LSP request
parameters before forwarding them to jdtls. This emits snake_case field
names verbatim from the pygls/lsprotocol models:

    DefinitionParams(text_document=..., position=...)
    → {"text_document": {...}, "position": {...}}

But the LSP JSON wire format requires camelCase:

    → {"textDocument": {...}, "position": {...}}

jdtls's NavigateToDefinitionHandler (and every other position-based
handler) then called TextDocumentPositionParams.getTextDocument(),
which returned null because the JSON field was "text_document" not
"textDocument", and blew up with:

    java.lang.NullPointerException: Cannot invoke
    "org.eclipse.lsp4j.TextDocumentIdentifier.getUri()" because the
    return value of "TextDocumentPositionParams.getTextDocument()"
    is null
        at NavigateToDefinitionHandler.definition(...:70)

This broke every go-to-definition, find-references, hover, document
symbol, and completion request forwarded to jdtls since the proxy
feature was introduced. Users saw "loading..." / no results and the
LSP log filled with stack traces.

The fix is a one-line change: use lsprotocol.converters.get_converter()
instead of cattrs.Converter(). The lsprotocol-aware converter:
- Converts snake_case attributes to camelCase JSON fields
- Handles lsprotocol's discriminated unions correctly
- Drops None-valued optional fields (matching LSP convention)

Also adds 7 regression tests in TestLspConverterCamelCase pinning the
JSON shape of DefinitionParams, ReferenceParams (with nested
context.includeDeclaration), HoverParams, DocumentSymbolParams,
CompletionParams, DidOpenTextDocumentParams (with nested languageId),
and the None-field-pruning behavior. A future regression to a vanilla
cattrs converter would fail every one of these.

Bumps version 0.7.1 -> 0.7.2.

Verification: 299 passed (was 292), 77% coverage, pyright+ruff clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a new e2e test module (tests/test_e2e_jdtls.py) that spawns a real
jdtls subprocess and exercises every forwarded LSP request through the
actual wire. Unit tests with mocked subprocesses cannot catch JSON-shape
regressions because the mocks never parse the bytes — we learned this
the hard way twice in the last two releases:

- v0.7.1: jdtls silently failed to start because inherited JAVA_HOME
  pointed at Java 8. Every unit test of find_jdtls_java_home passed
  while users had zero working jdtls features.

- v0.7.2: every forwarded request NPE'd because a vanilla cattrs
  Converter emitted snake_case field names (text_document) instead of
  camelCase (textDocument). Every unit test passed while go-to-def,
  references, hover, completion, and document symbol were all broken.

The new module has 7 tests covering every forwarded LSP method:

- test_initialize_handshake_succeeds — proxy fixture asserts start() ok
- test_document_symbol_round_trip — canonical camelCase regression
- test_definition_request_does_not_npe — the exact v0.7.2 scenario
- test_references_request_does_not_npe — exercises nested ReferenceContext
  with include_declaration → includeDeclaration
- test_hover_request_does_not_npe
- test_completion_request_does_not_npe — distinct content-assist path
- test_did_open_notification_does_not_npe — notification path, catches
  TextDocumentItem.language_id → languageId regressions

Each test:
1. Builds pygls-typed params (e.g. lsp.DefinitionParams(text_document=...))
2. Passes them through _serialize_params — the real production path
3. Asserts the serialized dict has camelCase keys (wire-format sanity)
4. Sends via JdtlsProxy.send_request/send_notification
5. Captures jdtls stderr via caplog and asserts no NullPointerException
   appeared (catches regressions where send_request returns None from
   an error response, or where jdtls logs but recovers)

Verified the tests actually catch the camelCase bug by temporarily
reverting _converter to cattrs.Converter() — the first test failed
immediately with:

    AssertionError: _serialize_params emitted wrong field names:
      dict_keys(['text_document', 'work_done_token', 'partial_result_token'])

CI additions (.github/workflows/test.yml):

- The unit matrix now passes `-m "not e2e"` to skip the new e2e tests.
- New `e2e-test` job runs on both ubuntu-latest and macos-latest with
  a single Python version (3.12). Installs Java 21 via setup-java and
  jdtls via `brew install jdtls` on macOS or a direct tarball download
  from download.eclipse.org on Linux (wrapped in a shell launcher on
  $PATH).

pyproject.toml registers the `e2e` pytest marker.

Local verification: 306 tests pass (299 unit + 7 e2e), 80% coverage,
68s for e2e runs end-to-end. pyright + ruff clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The previous commit pinned 1.47.0 arbitrarily — that's 10 minor versions
behind the current milestone. Switch to 1.57.0 (build 202602261110) to
match what Homebrew ships on macOS, so the Linux and macOS e2e jobs
exercise the same jdtls version.

Also switch to the canonical Eclipse mirror download URL used by the
Homebrew formula, which redirects through a mirror selector and gives
more reliable downloads than hitting download.eclipse.org directly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Without a hash check, a compromised Eclipse mirror could ship a
tampered tarball and our CI would execute arbitrary code inside the
e2e jdtls subprocess. Pin and verify the sha256 from the Homebrew
formula — it's the canonical upstream hash for this milestone build.

The bump instructions in the comment now list all three values that
must be updated together (VERSION, BUILD, SHA256) to prevent future
maintainers from introducing a hash/version skew.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The e2e job runs only 7 tests (``-m e2e``) which exercise the proxy +
server layers but none of the analyzers, so total coverage lands at
~27% — below the 60% fail-under threshold enforced by pyproject.toml's
default addopts. That made CI report "7 passed" and then fail with
exit 1.

Pass --no-cov to the e2e pytest invocation. The unit matrix already
produces the authoritative coverage report, so the e2e job doesn't
need its own.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ion CI

Adds 4 new e2e tests in TestMajorFeatureSanity that verify major
product features end-to-end through the real LanguageServer instance:

- test_null_return_diagnostic_fires — analyzer pipeline produces
  null-return diagnostic for a Java file with return null
- test_null_check_to_monadic_diagnostic_fires — if(x != null)
  pattern triggers the monadic-flow hint
- test_code_action_returns_quickfix_with_edit — CodeActionParams
  with a null-return diagnostic returns a QuickFix with
  Option.none() edit + auto-import
- test_code_action_ignores_non_java_functional_lsp_diagnostics —
  jdtls diagnostics are filtered out, not processed by our handler

These tests bootstrap a real pygls workspace via
server.protocol._workspace, inject documents via put_text_document,
and call the on_code_action handler directly. They exercise the full
pipeline: pygls workspace → analyzer chain → fix generator → lsp
CodeAction → WorkspaceEdit.

CI changes:
- Renamed e2e-test job to "integration" and changed it to run the
  FULL test suite (uv run pytest -v) rather than just -m e2e. This
  means the integration job runs all 310 tests (unit + e2e) with
  jdtls installed, and coverage naturally reaches 82% without any
  --no-cov hack.
- The unit matrix (test job) still excludes e2e tests via -m "not e2e"
  for fast cross-version feedback.

Verification: 310 passed (was 306), 82% coverage, pyright + ruff clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The test suite now achieves 82% coverage consistently (310 tests:
unit + e2e). Raise the fail-under bar so future changes that drop
coverage below 80% are caught by CI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Moves the major-feature sanity tests from test_e2e_jdtls.py (gated by
jdtls skipif) to a new tests/test_server.py (no jdtls dependency). This
lets them run in the regular unit matrix, boosting coverage from 77% to
80% without needing jdtls installed.

New test file tests/test_server.py contains:

TestServerHelpers (11 tests):
- _load_config: no workspace, missing file, valid JSON, invalid JSON
- _to_lsp_diagnostic: with/without DiagnosticData
- _analyze_document: with excludes config, without excludes
- _serialize_params: camelCase regression guard
- _handle_exception: logs uncaught exceptions
- _jdtls_raw_to_lsp_diagnostics: valid + malformed raw dicts
- _on_jdtls_diagnostics callback: mocked publish verifies analyzer runs

TestAnalyzerPipeline (4 tests):
- null-return, null-check-to-monadic, try-catch-to-monadic diagnostics
- Clean file produces no diagnostics

TestCodeActionPipeline (4 tests):
- null-return QuickFix → Option.none() + auto-import
- try-catch-to-monadic QuickFix → Try.of() + auto-import
- Non-java-functional-lsp diagnostics filtered out
- Empty diagnostic context → no actions

Coverage threshold raised to 80% in pyproject.toml. Unit matrix
(without e2e) reaches 80.13%; full suite (with e2e) reaches 83.25%.

Verification: 327 passed (320 unit + 7 e2e), pyright + ruff clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…port

Rewrites test_server.py with two complementary test tiers:

**TestServerInternals** (16 tests) — direct-call tests exercising server.py
helpers in-process for coverage: _load_config, _to_lsp_diagnostic,
_analyze_document (with/without excludes), _handle_exception,
_jdtls_raw_to_lsp_diagnostics, _on_jdtls_diagnostics callback,
_serialize_params camelCase guard, and on_code_action for null-return,
try-catch-to-monadic, and foreign-diagnostic filtering. One mock
remains for text_document_publish_diagnostics (transport boundary).

**TestLspLifecycle** (9 tests) — ZERO mocks. Spawns the real
java-functional-lsp server as a subprocess via pygls LanguageClient,
connects over stdio pipes, and drives the full LSP lifecycle:
initialize → didOpen → wait for publishDiagnostics → codeAction.

Tests cover:
- initialize handshake
- null-return / null-check-to-monadic / try-catch-to-monadic diagnostic publishing
- Clean file → zero diagnostics
- null-return QuickFix → Option.none() + auto-import
- try-catch-to-monadic QuickFix → Try.of() + auto-import
- Foreign-diagnostic filtering (jdtls diagnostics → no actions)
- didChange with clean source → diagnostics cleared

The subprocess tests run in ~3.4s total (real server startup + 9 requests).
Coverage is provided by the direct-call tier (subprocess coverage cannot
be captured by pytest-cov in the parent process).

Unit matrix: 324 passed, 80.01% coverage (meets 80% bar).
Full suite with e2e: 331 passed, 83%+ coverage.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@aviadshiber aviadshiber changed the title fix: use lsprotocol-aware converter for jdtls request forwarding fix: use lsprotocol-aware converter for jdtls + comprehensive e2e test suite Apr 10, 2026
…est architecture

- README: add try-catch-to-monadic to rules table (15 → 16 rules) and
  code actions table (3 patterns documented). Update jdtls section to
  mention auto-detection of Java 21+ via JDTLS_JAVA_HOME / java_home.
  Update Spring-aware suppression list.

- CONTRIBUTING: add Test Architecture section documenting the 4-tier
  test suite (unit, server internals, LSP lifecycle, jdtls e2e) and
  the 80% coverage threshold. Document _FIX_TITLES assertion guard.

- SKILL: same rule table + code action updates as README, plus updated
  @bean suppression list.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@aviadshiber aviadshiber self-assigned this Apr 10, 2026
aviadsTaboola and others added 4 commits April 10, 2026 07:58
…precations

- Fix heredoc indentation in CI (SHELL_EOF was indented, bash requires col 0)
  Replaced with printf to avoid heredoc pitfalls entirely
- Replace false-confidence test_initialize_reports_capabilities with real
  assertions on code_action_provider and text_document_sync
- Fix deprecated asyncio.get_event_loop() → get_running_loop() (Python 3.10+)
- Add explicit timeout failure instead of silent .get() fallback in
  test_diagnostics_update_on_file_change
- Log proxy cleanup errors instead of silently swallowing in e2e fixture
- Assert required fields survive None-field pruning in test_none_fields_are_omitted

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Promote proxy and workspace fixtures to scope="class" with
loop_scope="class" so all TestJdtlsEndToEnd tests share a single
jdtls subprocess and event loop. Open the document once in fixture
setup instead of per-test.

The loop_scope parameter is required because pytest-asyncio creates
a new event loop per test by default — without it, the asyncio
subprocess handles from proxy setup break in subsequent tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@aviadshiber aviadshiber merged commit 150d553 into main Apr 10, 2026
14 checks passed
@aviadshiber aviadshiber deleted the fix/lsp-camelcase-serialization branch April 10, 2026 05:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants