diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8992c96..a621786 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,6 +31,79 @@ jobs: - name: Type check run: uv run mypy src/ - name: Test - run: uv run pytest -q + run: uv run pytest -q -m "not e2e" + env: + NO_COLOR: "1" + + integration: + name: Integration (with jdtls) / ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + # Runs the FULL test suite (unit + e2e) with jdtls installed, on one + # Python version per OS. This is a comprehensive sanity check that + # exercises: custom analyzer diagnostics, code action generation, + # AND jdtls request forwarding end-to-end. The unit matrix above + # provides fast Python-version-specific feedback without jdtls. + steps: + - uses: actions/checkout@v6 + - name: Set up Python 3.12 + uses: actions/setup-python@v6 + with: + python-version: "3.12" + - name: Install Java 21 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "21" + - name: Install jdtls (macOS) + if: runner.os == 'macOS' + run: brew install jdtls + - name: Install jdtls (Linux) + if: runner.os == 'Linux' + run: | + set -euo pipefail + # Download the Eclipse JDT Language Server milestone build directly. + # Pinned to match the Homebrew formula so Linux + macOS exercise the + # same version. When bumping, update ALL THREE of JDTLS_VERSION, + # JDTLS_BUILD, and JDTLS_SHA256 together — the canonical source is + # the Homebrew formula: + # https://github.com/Homebrew/homebrew-core/blob/HEAD/Formula/j/jdtls.rb + # It has `url "...jdt-language-server--.tar.gz"` + # and `sha256 ""` which you can copy verbatim. + JDTLS_VERSION="1.57.0" + JDTLS_BUILD="202602261110" + JDTLS_SHA256="f7ffa93fe1bbbea95dac13dd97cdcd25c582d6e56db67258da0dcceb2302601e" + JDTLS_URL="https://www.eclipse.org/downloads/download.php?file=/jdtls/milestones/${JDTLS_VERSION}/jdt-language-server-${JDTLS_VERSION}-${JDTLS_BUILD}.tar.gz&r=1" + JDTLS_DIR="$HOME/.local/share/jdtls" + BIN_DIR="$HOME/.local/bin" + mkdir -p "$JDTLS_DIR" "$BIN_DIR" + # -L follows Eclipse's mirror redirect; -f fails loudly on 404. + curl -sSLf -o /tmp/jdtls.tar.gz "$JDTLS_URL" + # Verify the tarball integrity against the hash pinned in the + # Homebrew formula. This protects against mirror tampering — + # without it, a compromised Eclipse mirror could ship arbitrary + # code that our e2e tests would then execute. + echo "${JDTLS_SHA256} /tmp/jdtls.tar.gz" | sha256sum -c - + tar -xzf /tmp/jdtls.tar.gz -C "$JDTLS_DIR" + # Wrapper script on PATH that invokes the bundled Python launcher + # with python3 (the tarball ships jdtls.py in bin/). + printf '#!/bin/bash\nexec python3 "%s/bin/jdtls" "$@"\n' "$JDTLS_DIR" > "$BIN_DIR/jdtls" + chmod +x "$BIN_DIR/jdtls" + echo "$BIN_DIR" >> "$GITHUB_PATH" + - name: Install uv + uses: astral-sh/setup-uv@v7 + - name: Install dependencies + run: uv sync + - name: Verify jdtls is available + run: | + which jdtls + jdtls --help | head -5 || true + java -version + echo "JAVA_HOME=$JAVA_HOME" + - name: Run full test suite (unit + e2e) + run: uv run pytest -v env: NO_COLOR: "1" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9edaa27..81b2f8a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,9 +48,20 @@ uv run pytest 4. Add a `DiagnosticData` entry to the module's `_DATA` dict with `fix_type`, `target_library`, and `rationale` 5. Pass `data=_DATA["rule-id"]` when creating the `Diagnostic` 6. Add tests in `tests/test_.py` (including a test verifying the `data` field) -7. Optionally add a quick fix generator in `src/java_functional_lsp/fixes.py` and register it in `_FIX_REGISTRY` +7. Optionally add a quick fix generator in `src/java_functional_lsp/fixes.py` and register it in `_FIX_REGISTRY` + add its title to `_FIX_TITLES` in `server.py` (an import-time assertion catches mismatches) 8. Update the rules table in `README.md` +## Test Architecture + +The project has a layered test suite: + +- **Unit tests** (`tests/test_*_checker.py`, `tests/test_fixes.py`, `tests/test_proxy.py`) — fast, focused, run in the main CI matrix across Python 3.10-3.13 on Ubuntu + macOS +- **Server integration tests** (`tests/test_server.py: TestServerInternals`) — exercise the server pipeline (config loading, diagnostic conversion, code actions) in-process +- **LSP lifecycle tests** (`tests/test_server.py: TestLspLifecycle`) — **zero mocks** — spawn the real server as a subprocess via pygls `LanguageClient`, connect over stdio, exercise the full LSP round-trip (initialize, didOpen, publishDiagnostics, codeAction, didChange) +- **jdtls e2e tests** (`tests/test_e2e_jdtls.py`) — **zero mocks** — spawn real jdtls, exercise definition/references/hover/completion/documentSymbol forwarding. Auto-skip when jdtls is not installed. Run in a dedicated CI integration job. + +Coverage threshold is **80%**. Bump the version in both `pyproject.toml` and `src/java_functional_lsp/__init__.py` when making source changes (a pre-commit hook enforces this). + ## Reporting Issues - Use the [bug report template](https://github.com/aviadshiber/java-functional-lsp/issues/new?template=bug-report.md) diff --git a/README.md b/README.md index 5fa2aa3..0891e67 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A Java Language Server that provides two things in one: 1. **Full Java language support** — completions, hover, go-to-definition, compile errors, missing imports — by proxying [Eclipse jdtls](https://github.com/eclipse-jdtls/eclipse.jdt.ls) under the hood -2. **15 functional programming rules** — catches anti-patterns and suggests Vavr/Lombok/Spring alternatives, all before compilation +2. **16 functional programming rules** — catches anti-patterns and suggests Vavr/Lombok/Spring alternatives, all before compilation 3. **Code actions (quick fixes)** — automated refactoring via LSP `textDocument/codeAction`, with machine-readable diagnostic metadata for AI agents Designed for teams using **Vavr**, **Lombok**, and **Spring** with a functional-first approach. @@ -24,7 +24,7 @@ When [jdtls](https://github.com/eclipse-jdtls/eclipse.jdt.ls) is installed, the - Type mismatches - Completions, hover, go-to-definition, find references -Install jdtls separately: `brew install jdtls` (requires JDK 21+). Without jdtls, the server runs in standalone mode — the 12 custom rules still work, but you won't get compile errors or completions. +Install jdtls separately: `brew install jdtls` (requires JDK 21+). The server auto-detects a Java 21+ installation even when the IDE's project SDK is older (e.g., Java 8) by probing `JDTLS_JAVA_HOME`, `JAVA_HOME`, `/usr/libexec/java_home -v 21+` (macOS), and `java` on PATH. Without jdtls, the server runs in standalone mode — the 16 custom rules still work, but you won't get compile errors or completions. ### Functional programming rules @@ -44,6 +44,7 @@ Install jdtls separately: `brew install jdtls` (requires JDK 21+). Without jdtls | `component-annotation` | `@Component`/`@Service`/`@Repository` | `@Configuration` + `@Bean` | — | | `frozen-mutation` | Mutation on `List.of()`/`Collections.unmodifiable*` | `io.vavr.collection.List` | ✅ | | `null-check-to-monadic` | `if (x != null) { return x.foo(); }` | `Option.of(x).map(...)` | ✅ | +| `try-catch-to-monadic` | `try { return x(); } catch (E e) { return d; }` | `Try.of(() -> x()).getOrElse(d)` | ✅ | | `impure-method` | Method mixing pure logic with side-effects | Extract pure logic; wrap IO in `Try` | — | ## Install @@ -203,7 +204,7 @@ Create `.java-functional-lsp.json` in your project root to customize rules: - `rules` — per-rule severity: `error`, `warning` (default), `info`, `hint`, `off` **Spring-aware behavior:** -- `throw-statement` and `catch-rethrow` are automatically suppressed inside `@Bean` methods +- `throw-statement`, `catch-rethrow`, and `try-catch-to-monadic` are automatically suppressed inside `@Bean` methods - `mutable-dto` suggests `@ConstructorBinding` instead of `@Value` when the class has `@ConfigurationProperties` **Inline suppression** with `@SuppressWarnings`: @@ -231,8 +232,9 @@ The server provides LSP code actions (`textDocument/codeAction`) that automatica | Rule | Code Action | What it does | |------|-------------|--------------| | `frozen-mutation` | Switch to Vavr Immutable Collection | Rewrites `List.of()` → `io.vavr.collection.List.of()`, `.add(x)` → `= list.append(x)`, adds import | -| `null-check-to-monadic` | Convert to Option monadic flow | Rewrites `if (x != null) { return x.foo(); }` → `Option.of(x).map(...).getOrNull()`, adds import | +| `null-check-to-monadic` | Convert to Option monadic flow | Rewrites `if (x != null) { return x.foo(); }` → `Option.of(x).map(...)`, supports chained fallbacks via `.orElse()`, adds import | | `null-return` | Replace with Option.none() | Rewrites `return null` → `return Option.none()`, adds import | +| `try-catch-to-monadic` | Convert try/catch to Try monadic flow | Rewrites `try { return expr; } catch (E e) { return default; }` → `Try.of(() -> expr).getOrElse(default)`. Supports 3 patterns: simple default (eager/lazy `.getOrElse`), logging + default (`.onFailure().getOrElse`), and exception-dependent recovery (`.recover(E.class, ...).get()`). Skips try-with-resources, finally, multi-catch, and union types. Adds import. | Quick fixes automatically add the required Vavr import if it's not already present. Disable auto-import with `"autoImportVavr": false` in config. diff --git a/SKILL.md b/SKILL.md index 879bdce..7190580 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,13 +1,13 @@ --- name: java-functional-lsp -description: Java LSP with full language support (completions, hover, go-to-def, compile errors) plus 15 functional programming rules with automated quick fixes. Auto-invoke when setting up Java language support or discussing Java linting configuration. +description: Java LSP with full language support (completions, hover, go-to-def, compile errors) plus 16 functional programming rules with automated quick fixes. Auto-invoke when setting up Java language support or discussing Java linting configuration. allowed-tools: Bash disable-model-invocation: true --- # Java Functional LSP -A Java LSP server that wraps jdtls and adds 15 functional programming rules with code actions (quick fixes). Gives you **full Java language support** (completions, hover, go-to-def, compile errors) **plus** custom diagnostics with machine-readable metadata for AI agents — all before compilation. +A Java LSP server that wraps jdtls and adds 16 functional programming rules with code actions (quick fixes). Gives you **full Java language support** (completions, hover, go-to-def, compile errors) **plus** custom diagnostics with machine-readable metadata for AI agents — all before compilation. ## Prerequisites @@ -22,7 +22,7 @@ brew install jdtls Without jdtls, the server runs in standalone mode — custom rules still work, but no completions/hover/compile errors. -## Rules (15 checks) +## Rules (16 checks) | Rule | Detects | Suggests | Quick Fix | |------|---------|----------|-----------| @@ -40,6 +40,7 @@ Without jdtls, the server runs in standalone mode — custom rules still work, b | `component-annotation` | `@Component`/`@Service`/`@Repository` | `@Configuration` + `@Bean` | — | | `frozen-mutation` | Mutation on `List.of()`/`Collections.unmodifiable*` | `io.vavr.collection.List` | ✅ | | `null-check-to-monadic` | `if (x != null) { return x.foo(); }` | `Option.of(x).map(...)` | ✅ | +| `try-catch-to-monadic` | `try { return x(); } catch (E e) { return d; }` | `Try.of(() -> x()).getOrElse(d)` | ✅ | | `impure-method` | Method mixing pure logic with side-effects | Extract pure logic; wrap IO in `Try` | — | ## Code Actions (Quick Fixes) @@ -47,8 +48,9 @@ Without jdtls, the server runs in standalone mode — custom rules still work, b Rules marked ✅ provide automated `textDocument/codeAction` fixes: - **frozen-mutation** → "Switch to Vavr Immutable Collection" — rewrites type, init, and mutation call to Vavr persistent API, adds import -- **null-check-to-monadic** → "Convert to Option monadic flow" — rewrites `if (x != null)` to `Option.of(x).map(...)`, adds import +- **null-check-to-monadic** → "Convert to Option monadic flow" — rewrites `if (x != null)` to `Option.of(x).map(...)`, supports chained fallbacks via `.orElse()`, adds import - **null-return** → "Replace with Option.none()" — replaces `null` with `Option.none()`, adds import +- **try-catch-to-monadic** → "Convert try/catch to Try monadic flow" — rewrites `try { return expr; } catch (E e) { return default; }` to `Try.of(() -> expr).getOrElse(default)`. Supports 3 patterns: simple default, logging + default (`.onFailure().getOrElse`), and exception-dependent recovery (`.recover(E.class, ...).get()`). Skips try-with-resources, finally, multi-catch, union types. Adds import. ## Agent-Ready Diagnostics @@ -85,7 +87,7 @@ Create `.java-functional-lsp.json` in your project root: - `rules` — per-rule severity: `error`, `warning` (default), `info`, `hint`, `off` - `autoImportVavr` — quick fixes auto-add Vavr imports (default: `true`) - `strictPurity` — `impure-method` uses WARNING instead of HINT (default: `false`) -- `throw-statement`/`catch-rethrow` auto-suppressed in `@Bean` methods +- `throw-statement`/`catch-rethrow`/`try-catch-to-monadic` auto-suppressed in `@Bean` methods - `mutable-dto` suggests `@ConstructorBinding` for `@ConfigurationProperties` classes - Inline suppression: `@SuppressWarnings("java-functional-lsp:rule-id")` on any declaration diff --git a/pyproject.toml b/pyproject.toml index 6c81b9a..32ea578 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "java-functional-lsp" -version = "0.7.1" +version = "0.7.2" description = "Java LSP server enforcing functional programming best practices — null safety, immutability, no exceptions" readme = "README.md" license = { text = "MIT" } @@ -63,8 +63,11 @@ testpaths = ["tests"] python_files = ["test_*.py"] python_classes = ["Test*"] python_functions = ["test_*"] -addopts = "--cov=java_functional_lsp --cov-report=term-missing --cov-fail-under=60" +addopts = "--cov=java_functional_lsp --cov-report=term-missing --cov-fail-under=80" asyncio_mode = "auto" +markers = [ + "e2e: end-to-end tests that spawn a real jdtls subprocess (require jdtls + Java 21+; skipped when unavailable)", +] [tool.ruff] line-length = 120 diff --git a/src/java_functional_lsp/__init__.py b/src/java_functional_lsp/__init__.py index a24fac3..71a66c6 100644 --- a/src/java_functional_lsp/__init__.py +++ b/src/java_functional_lsp/__init__.py @@ -1,3 +1,3 @@ """java-functional-lsp: A Java LSP server enforcing functional programming best practices.""" -__version__ = "0.7.1" +__version__ = "0.7.2" diff --git a/src/java_functional_lsp/server.py b/src/java_functional_lsp/server.py index 493efbb..266e450 100644 --- a/src/java_functional_lsp/server.py +++ b/src/java_functional_lsp/server.py @@ -13,8 +13,8 @@ from pathlib import Path from typing import Any -import cattrs from lsprotocol import types as lsp +from lsprotocol.converters import get_converter from pygls.lsp.server import LanguageServer from pygls.uris import to_fs_path @@ -45,7 +45,14 @@ FunctionalChecker(), ] -_converter = cattrs.Converter() +#: LSP-aware cattrs converter. Unstructures to the LSP JSON shape +#: (camelCase field names, discriminated unions, None-field pruning) and +#: correspondingly structures from the same shape. Using a vanilla +#: ``cattrs.Converter()`` here emits snake_case field names (``text_document`` +#: instead of ``textDocument``), which breaks request forwarding to jdtls — +#: jdtls then sees a null ``TextDocumentIdentifier`` and throws NPEs during +#: go-to-definition, references, etc. +_converter = get_converter() class JavaFunctionalLspServer(LanguageServer): diff --git a/tests/test_e2e_jdtls.py b/tests/test_e2e_jdtls.py new file mode 100644 index 0000000..da20761 --- /dev/null +++ b/tests/test_e2e_jdtls.py @@ -0,0 +1,451 @@ +"""End-to-end tests that spawn a real jdtls subprocess. + +These tests exercise the full request/response pipeline: + +1. Python LSP server builds pygls-typed params (``lsp.DefinitionParams(...)``) +2. ``server._serialize_params`` converts them via the lsprotocol converter +3. ``JdtlsProxy.send_request`` frames + writes bytes to the jdtls stdin +4. Real jdtls subprocess parses the JSON and handles the request +5. Response comes back through the proxy and is deserialized + +Unit tests with mocked subprocesses **cannot** catch JSON-shape regressions +because the mocks never parse the bytes. We learned this the hard way: + +- **v0.7.1 fix** (``fix/jdtls-java-home-detection``): got jdtls to start at all + after it was silently failing because the inherited ``JAVA_HOME`` pointed at + Java 8. Every unit test of ``find_jdtls_java_home`` passed while users had + no working jdtls. +- **v0.7.2 fix** (``fix/lsp-camelcase-serialization``): every forwarded request + to jdtls was failing with ``NullPointerException`` because a vanilla + ``cattrs.Converter()`` emitted snake_case field names (``text_document``) + instead of camelCase (``textDocument``). Every unit test passed while + go-to-definition, references, hover, and document symbol were all broken. + +These tests guard the end-to-end pipeline so the next such bug is caught +before release. + +Skip rules: the entire module is skipped when ``jdtls`` is not on PATH or +no Java 21+ installation can be found. Local developers with jdtls installed +see the tests run; CI installs jdtls in the dedicated ``e2e-test`` job. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import shutil +from collections.abc import AsyncIterator +from pathlib import Path + +import pytest +import pytest_asyncio +from lsprotocol import types as lsp + +from java_functional_lsp.proxy import JdtlsProxy, find_jdtls_java_home +from java_functional_lsp.server import _serialize_params + +# Module-level skip: jdtls and Java 21+ are required. Check once at import +# time so the skip reason is clear and the entire test file is skipped at +# collection time rather than failing each test independently. +_JDTLS_ON_PATH = shutil.which("jdtls") is not None +_JAVA_21_AVAILABLE = find_jdtls_java_home() is not None + +pytestmark = [ + pytest.mark.e2e, + pytest.mark.skipif( + not _JDTLS_ON_PATH, + reason="jdtls binary not found on PATH — install via `brew install jdtls` or equivalent", + ), + pytest.mark.skipif( + not _JAVA_21_AVAILABLE, + reason="no Java 21+ found — install via `brew install openjdk@21` or equivalent", + ), +] + +# jdtls cold-start is 10-20s; give each individual request a generous budget +# beyond the default REQUEST_TIMEOUT, and wrap the whole test in pytest-timeout +# so a hung jdtls doesn't wedge the suite. +_E2E_TEST_TIMEOUT_SEC = 120 +_JDTLS_PARSE_WAIT_SEC = 2.5 + +_HELLO_JAVA = """\ +public class Hello { + private String greeting; + + public Hello(String greeting) { + this.greeting = greeting; + } + + public String greet() { + return this.greeting; + } + + public static void main(String[] args) { + Hello h = new Hello("world"); + System.out.println(h.greet()); + } +} +""" + + +@pytest.fixture(scope="class") +def workspace(tmp_path_factory: pytest.TempPathFactory) -> tuple[Path, Path]: + """Create a minimal standalone Java workspace with a single Hello.java file. + + Class-scoped so the same workspace is reused across all tests in + TestJdtlsEndToEnd, avoiding redundant tmp_path creation per test. + + jdtls operates in "default project" mode for orphan files (no build config), + which is enough to parse the file and serve document symbols. No pom.xml, + no build.gradle, no .classpath — we don't need full classpath resolution + to verify that the request shape reaches jdtls correctly. + """ + tmp_path = tmp_path_factory.mktemp("jdtls_workspace") + src_file = tmp_path / "Hello.java" + src_file.write_text(_HELLO_JAVA) + return tmp_path, src_file + + +@pytest_asyncio.fixture(scope="class", loop_scope="class") +async def proxy(workspace: tuple[Path, Path]) -> AsyncIterator[JdtlsProxy]: + """Start a real JdtlsProxy bound to the workspace fixture. + + Class-scoped with ``loop_scope="class"`` so all tests in the class share + a single jdtls subprocess AND a single event loop. This cuts e2e wall-clock + time by ~6x (1 cold-start instead of 7). Without ``loop_scope``, + pytest-asyncio creates a new event loop per test, breaking the asyncio + subprocess handles created during proxy setup. + + Yields an initialized proxy and tears it down on class exit. Uses a + minimal ``InitializeParams`` dict that includes only the capabilities + we exercise in the tests below, to keep jdtls's initial workspace scan cheap. + """ + tmp_path, _ = workspace + p = JdtlsProxy() + + init_params = { + "processId": os.getpid(), + "rootUri": tmp_path.as_uri(), + "rootPath": str(tmp_path), + "capabilities": { + "textDocument": { + "synchronization": { + "dynamicRegistration": False, + "willSave": False, + "willSaveWaitUntil": False, + "didSave": True, + }, + "definition": {"dynamicRegistration": False, "linkSupport": False}, + "references": {"dynamicRegistration": False}, + "documentSymbol": { + "dynamicRegistration": False, + "hierarchicalDocumentSymbolSupport": True, + }, + "hover": {"dynamicRegistration": False, "contentFormat": ["plaintext"]}, + "completion": { + "dynamicRegistration": False, + "completionItem": {"snippetSupport": False}, + }, + }, + "workspace": {"configuration": False, "workspaceFolders": False}, + }, + "initializationOptions": {}, + "trace": "off", + } + + started = await p.start(init_params) + if not started: + pytest.fail( + "JdtlsProxy.start() returned False — jdtls failed to initialize. " + "Check the logs above for Java version / classpath issues." + ) + + # Open the workspace file once so all tests can use it immediately. + _, src_file = workspace + uri = src_file.as_uri() + await p.send_notification( + "textDocument/didOpen", + { + "textDocument": { + "uri": uri, + "languageId": "java", + "version": 1, + "text": src_file.read_text(), + } + }, + ) + await asyncio.sleep(_JDTLS_PARSE_WAIT_SEC) + + try: + yield p + finally: + try: + await p.stop() + except Exception: + logging.getLogger(__name__).warning("proxy.stop() failed during teardown", exc_info=True) + + +def _document_uri(src_file: Path) -> str: + """Return the URI for the workspace document. + + With class-scoped fixtures the document is opened once in the proxy fixture + setup, so individual tests just need the URI. + """ + return src_file.as_uri() + + +def _assert_no_npe_in_logs(caplog: pytest.LogCaptureFixture) -> None: + """Fail the test if any NullPointerException appeared in jdtls stderr. + + The proxy's ``_stderr_reader`` logs each stderr line via + ``logger.error("jdtls stderr: %s", line)``. We scan the captured records + for NPE markers — this catches regressions where the wire format is + wrong even when send_request returns a non-error response (e.g., when + jdtls falls back to a default value after logging the exception). + """ + npe_lines = [record.getMessage() for record in caplog.records if "NullPointerException" in record.getMessage()] + assert not npe_lines, ( + "jdtls threw NullPointerException — probably a request-shape bug " + "(camelCase vs snake_case). Captured lines:\n" + "\n".join(npe_lines) + ) + + +@pytest.mark.timeout(_E2E_TEST_TIMEOUT_SEC) +@pytest.mark.asyncio(loop_scope="class") +class TestJdtlsEndToEnd: + """End-to-end request/response tests against a real jdtls subprocess.""" + + async def test_initialize_handshake_succeeds(self, proxy: JdtlsProxy) -> None: + """Proves jdtls came up and announced its capabilities. + + This is implicit in the ``proxy`` fixture (which asserts start() + returned True), but an explicit test here makes the boundary clear: + if this fails, every other test will fail too, and the fixture is + to blame. + """ + assert proxy.is_available + caps = proxy.capabilities + # jdtls always advertises these — their presence confirms a clean init. + assert "definitionProvider" in caps + assert "documentSymbolProvider" in caps + + async def test_document_symbol_round_trip( + self, + workspace: tuple[Path, Path], + proxy: JdtlsProxy, + caplog: pytest.LogCaptureFixture, + ) -> None: + """Send textDocument/documentSymbol via _serialize_params and verify response. + + Document-symbol only needs the file to be parsed (no classpath resolution), + so it's the most reliable cross-environment e2e check. This is the + canonical camelCase regression test: if ``_serialize_params`` emits + ``text_document`` instead of ``textDocument``, jdtls logs NPE and + returns an error response, which we detect via caplog. + """ + _, src_file = workspace + uri = _document_uri(src_file) + + with caplog.at_level(logging.ERROR, logger="java_functional_lsp.proxy"): + params = lsp.DocumentSymbolParams( + text_document=lsp.TextDocumentIdentifier(uri=uri), + ) + serialized = _serialize_params(params) + + # Wire-format sanity: the serialization layer we send to jdtls MUST + # use camelCase field names. This is redundant with the unit tests + # in TestLspConverterCamelCase but pins the exact dict shape at the + # boundary of the real subprocess call. + assert "textDocument" in serialized, f"_serialize_params emitted wrong field names: {serialized.keys()}" + assert "text_document" not in serialized + + result = await proxy.send_request("textDocument/documentSymbol", serialized) + + # Primary assertion: jdtls returned something. With a correctly-shaped + # request, jdtls always returns a list (possibly empty) for a parsable + # file. None here means our proxy surfaced an error or timeout — both + # are regression signals. + assert result is not None, ( + "jdtls returned None for documentSymbol on a valid file. " + "This usually means the request shape was rejected — check caplog " + "for jdtls stderr output." + ) + assert isinstance(result, list) + # Hello.java declares a top-level class, so we expect at least one symbol. + assert len(result) > 0, ( + "jdtls returned an empty symbol list for a file with a top-level class. " + "Either jdtls didn't finish parsing or the file wasn't opened correctly." + ) + + _assert_no_npe_in_logs(caplog) + + async def test_definition_request_does_not_npe( + self, + workspace: tuple[Path, Path], + proxy: JdtlsProxy, + caplog: pytest.LogCaptureFixture, + ) -> None: + """The exact scenario from the v0.7.2 bug report: textDocument/definition. + + The NPE surfaced in ``NavigateToDefinitionHandler.definition`` when + ``TextDocumentPositionParams.getTextDocument()`` returned null. This + test sends a real definition request through the real serialization + path and asserts no NPE appears in jdtls stderr, regardless of whether + jdtls actually resolves the symbol (which depends on classpath state). + """ + _, src_file = workspace + uri = _document_uri(src_file) + + with caplog.at_level(logging.ERROR, logger="java_functional_lsp.proxy"): + # Position the cursor on the `greeting` identifier at line 8 col 20 + # inside `return this.greeting;`. Exact column doesn't matter — we + # care that the REQUEST reaches jdtls with a valid textDocument. + params = lsp.DefinitionParams( + text_document=lsp.TextDocumentIdentifier(uri=uri), + position=lsp.Position(line=8, character=20), + ) + serialized = _serialize_params(params) + assert "textDocument" in serialized + assert "text_document" not in serialized + + # We don't assert on the RESULT of this call — jdtls may return None + # or [] depending on classpath state. The critical assertion is that + # no NPE appeared in stderr, because an NPE would prove the camelCase + # regression has come back. + await proxy.send_request("textDocument/definition", serialized) + + _assert_no_npe_in_logs(caplog) + + async def test_hover_request_does_not_npe( + self, + workspace: tuple[Path, Path], + proxy: JdtlsProxy, + caplog: pytest.LogCaptureFixture, + ) -> None: + """Hover is another TextDocumentPositionParams-shaped request. + + Verifying hover in addition to definition catches regressions that + might only affect a subset of position-based handlers. + """ + _, src_file = workspace + uri = _document_uri(src_file) + + with caplog.at_level(logging.ERROR, logger="java_functional_lsp.proxy"): + params = lsp.HoverParams( + text_document=lsp.TextDocumentIdentifier(uri=uri), + position=lsp.Position(line=0, character=13), + ) + serialized = _serialize_params(params) + assert "textDocument" in serialized + + await proxy.send_request("textDocument/hover", serialized) + + _assert_no_npe_in_logs(caplog) + + async def test_did_open_notification_does_not_npe( + self, + proxy: JdtlsProxy, + caplog: pytest.LogCaptureFixture, + ) -> None: + """Notifications go through the same serialization path as requests. + + didOpen uses ``DidOpenTextDocumentParams`` which wraps a + ``TextDocumentItem`` with snake_case ``language_id``. A camelCase bug + here would cause jdtls to fail parsing the notification silently + (no response, so no request-side error) but the NPE would appear in + stderr when jdtls tries to look up the file by URI later. + + This test verifies the serialization shape only — the proxy fixture + already opened Hello.java, so we use a synthetic URI to avoid + interfering with other tests. + """ + with caplog.at_level(logging.ERROR, logger="java_functional_lsp.proxy"): + params = lsp.DidOpenTextDocumentParams( + text_document=lsp.TextDocumentItem( + uri="file:///tmp/DidOpenTest.java", + language_id="java", + version=1, + text="public class DidOpenTest {}", + ), + ) + serialized = _serialize_params(params) + assert "textDocument" in serialized + assert "languageId" in serialized["textDocument"] + assert "language_id" not in serialized["textDocument"] + + await proxy.send_notification("textDocument/didOpen", serialized) + await asyncio.sleep(0.5) + + _assert_no_npe_in_logs(caplog) + + async def test_references_request_does_not_npe( + self, + workspace: tuple[Path, Path], + proxy: JdtlsProxy, + caplog: pytest.LogCaptureFixture, + ) -> None: + """textDocument/references through _serialize_params. + + ReferenceParams is a compound type: it wraps ``text_document``, + ``position``, AND a nested ``context`` with its own snake_case field + ``include_declaration``. A vanilla cattrs converter would emit + ``context.include_declaration`` which jdtls would see as a null + ReferenceContext — guaranteed NPE. + """ + _, src_file = workspace + uri = _document_uri(src_file) + + with caplog.at_level(logging.ERROR, logger="java_functional_lsp.proxy"): + params = lsp.ReferenceParams( + text_document=lsp.TextDocumentIdentifier(uri=uri), + position=lsp.Position(line=8, character=20), # `greeting` inside greet() + context=lsp.ReferenceContext(include_declaration=True), + ) + serialized = _serialize_params(params) + assert "textDocument" in serialized + assert "text_document" not in serialized + # Nested field is also camelCase + assert "context" in serialized + assert "includeDeclaration" in serialized["context"] + assert "include_declaration" not in serialized["context"] + + await proxy.send_request("textDocument/references", serialized) + + _assert_no_npe_in_logs(caplog) + + async def test_completion_request_does_not_npe( + self, + workspace: tuple[Path, Path], + proxy: JdtlsProxy, + caplog: pytest.LogCaptureFixture, + ) -> None: + """textDocument/completion through _serialize_params. + + Completion exercises a distinct jdtls code path from definition/hover + (content assist rather than symbol resolution) and uses + ``CompletionParams`` which inherits TextDocumentPositionParams. A + request-shape bug here would NPE the completion handler. + """ + _, src_file = workspace + uri = _document_uri(src_file) + + with caplog.at_level(logging.ERROR, logger="java_functional_lsp.proxy"): + # Position after `h.` on the main() line where completion makes sense. + params = lsp.CompletionParams( + text_document=lsp.TextDocumentIdentifier(uri=uri), + position=lsp.Position(line=13, character=29), + ) + serialized = _serialize_params(params) + assert "textDocument" in serialized + assert "text_document" not in serialized + + await proxy.send_request("textDocument/completion", serialized) + + _assert_no_npe_in_logs(caplog) + + +# Major-feature sanity checks (analyzer pipeline + code actions) now live in +# tests/test_server.py, which is NOT gated by the jdtls skipif above. They run +# in the regular unit matrix on every Python version and contribute to the 80% +# coverage floor without requiring jdtls to be installed. diff --git a/tests/test_proxy.py b/tests/test_proxy.py index 4b346b7..446245b 100644 --- a/tests/test_proxy.py +++ b/tests/test_proxy.py @@ -263,6 +263,147 @@ def test_analyze_document(self) -> None: assert "null-return" in codes +class TestLspConverterCamelCase: + """Regression tests for the LSP JSON shape of request forwarding. + + The LSP wire protocol uses camelCase field names (e.g. ``textDocument``). + pygls/lsprotocol models use snake_case attributes (``text_document``) and + rely on their own cattrs converter to handle the conversion. A vanilla + ``cattrs.Converter()`` emits snake_case literally, which causes jdtls to + throw ``NullPointerException`` on every ``textDocument/definition``, + ``textDocument/references``, ``textDocument/hover``, etc. because its + ``TextDocumentPositionParams.getTextDocument()`` returns null when the + field is named ``text_document``. + + These tests pin the converter behavior so a regression to a vanilla + cattrs converter would be caught immediately. + """ + + def test_definition_params_unstructures_to_camelcase(self) -> None: + from lsprotocol import types as lsp + + from java_functional_lsp.server import _serialize_params + + params = lsp.DefinitionParams( + text_document=lsp.TextDocumentIdentifier(uri="file:///foo.java"), + position=lsp.Position(line=10, character=5), + ) + result = _serialize_params(params) + assert isinstance(result, dict) + # jdtls requires camelCase: textDocument, NOT text_document + assert "textDocument" in result + assert "text_document" not in result + assert result["textDocument"]["uri"] == "file:///foo.java" + assert result["position"]["line"] == 10 + assert result["position"]["character"] == 5 + + def test_reference_params_unstructures_to_camelcase(self) -> None: + from lsprotocol import types as lsp + + from java_functional_lsp.server import _serialize_params + + params = lsp.ReferenceParams( + text_document=lsp.TextDocumentIdentifier(uri="file:///bar.java"), + position=lsp.Position(line=3, character=7), + context=lsp.ReferenceContext(include_declaration=True), + ) + result = _serialize_params(params) + assert isinstance(result, dict) + assert "textDocument" in result + assert "text_document" not in result + # Nested camelCase: context.includeDeclaration, NOT context.include_declaration + assert "context" in result + assert "includeDeclaration" in result["context"] + assert "include_declaration" not in result["context"] + + def test_hover_params_unstructures_to_camelcase(self) -> None: + from lsprotocol import types as lsp + + from java_functional_lsp.server import _serialize_params + + params = lsp.HoverParams( + text_document=lsp.TextDocumentIdentifier(uri="file:///baz.java"), + position=lsp.Position(line=0, character=0), + ) + result = _serialize_params(params) + assert "textDocument" in result + assert "text_document" not in result + + def test_document_symbol_params_unstructures_to_camelcase(self) -> None: + from lsprotocol import types as lsp + + from java_functional_lsp.server import _serialize_params + + params = lsp.DocumentSymbolParams( + text_document=lsp.TextDocumentIdentifier(uri="file:///quux.java"), + ) + result = _serialize_params(params) + assert "textDocument" in result + assert "text_document" not in result + + def test_did_open_params_unstructures_to_camelcase(self) -> None: + from lsprotocol import types as lsp + + from java_functional_lsp.server import _serialize_params + + params = lsp.DidOpenTextDocumentParams( + text_document=lsp.TextDocumentItem( + uri="file:///open.java", + language_id="java", + version=1, + text="class T {}", + ), + ) + result = _serialize_params(params) + assert "textDocument" in result + assert "text_document" not in result + # TextDocumentItem fields too: languageId, not language_id + assert "languageId" in result["textDocument"] + assert "language_id" not in result["textDocument"] + + def test_completion_params_unstructures_to_camelcase(self) -> None: + from lsprotocol import types as lsp + + from java_functional_lsp.server import _serialize_params + + params = lsp.CompletionParams( + text_document=lsp.TextDocumentIdentifier(uri="file:///c.java"), + position=lsp.Position(line=5, character=10), + ) + result = _serialize_params(params) + assert "textDocument" in result + assert "text_document" not in result + + def test_none_fields_are_omitted(self) -> None: + """Optional fields with None values should be dropped from the JSON. + + jdtls and other LSP servers treat the presence of a key with null + differently from the absence of the key. The LSP converter drops + None fields; a vanilla converter would emit ``"workDoneToken": null`` + which could confuse strict servers. + """ + from lsprotocol import types as lsp + + from java_functional_lsp.server import _serialize_params + + params = lsp.DefinitionParams( + text_document=lsp.TextDocumentIdentifier(uri="file:///foo.java"), + position=lsp.Position(line=0, character=0), + work_done_token=None, + partial_result_token=None, + ) + result = _serialize_params(params) + # Required fields must survive pruning + assert "textDocument" in result + assert "position" in result + # Optional None fields must be omitted + assert "workDoneToken" not in result + assert "partialResultToken" not in result + # Also the snake_case equivalents should not appear + assert "work_done_token" not in result + assert "partial_result_token" not in result + + class TestReadJavaMajorVersion: """Parsing of the ``java -version`` output. diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..cca9071 --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,537 @@ +"""Integration tests for the LanguageServer — mock-free via real LSP transport. + +These tests spawn the actual ``java-functional-lsp`` server as a subprocess, +connect via pygls ``LanguageClient`` over stdio pipes, and drive the full LSP +lifecycle: initialize → didOpen → publishDiagnostics → codeAction. No mocks, +no patching — the same bytes flow that a real IDE sends. + +This is the layer that catches regressions invisible to unit tests: +- camelCase serialization (v0.7.2 bug: vanilla cattrs → snake_case) +- transport framing (Content-Length, JSON encoding) +- server initialization + workspace wiring +- diagnostic publishing timing +""" + +from __future__ import annotations + +import asyncio +import os +import sys +from typing import Any + +import pytest +from lsprotocol import types as lsp +from pygls.lsp.client import LanguageClient + +_BUGGY_JAVA = """\ +import java.util.List; + +public class BuggyExample { + public String firstOrNull(List xs) { + if (xs != null) { + return xs.get(0); + } else { + return null; + } + } +} +""" + +_TRY_CATCH_JAVA = """\ +import java.io.IOException; + +public class TryCatchExample { + public String read() { + try { + return riskyRead(); + } catch (IOException e) { + return "fallback"; + } + } +} +""" + +_CLEAN_JAVA = """\ +public class Clean { + public int add(int a, int b) { + return a + b; + } +} +""" + + +@pytest.fixture +async def lsp_client(tmp_path: Any) -> LanguageClient: # type: ignore[misc] + """Spawn the real java-functional-lsp server and return an initialized client. + + Uses pygls ``LanguageClient.start_io`` to connect via stdio — the exact + transport IntelliJ/VS Code use. The server process is killed on teardown. + """ + client = LanguageClient("test-client", "1.0") + + # Collect published diagnostics so tests can assert on them. + client._published: dict[str, list[lsp.Diagnostic]] = {} # type: ignore[attr-defined] + + @client.feature(lsp.TEXT_DOCUMENT_PUBLISH_DIAGNOSTICS) + def on_publish(params: lsp.PublishDiagnosticsParams) -> None: + client._published[params.uri] = list(params.diagnostics) # type: ignore[attr-defined] + + await client.start_io(sys.executable, "-m", "java_functional_lsp") + + result = await client.initialize_async( + lsp.InitializeParams( + process_id=os.getpid(), + root_uri=tmp_path.as_uri(), + root_path=str(tmp_path), + capabilities=lsp.ClientCapabilities( + text_document=lsp.TextDocumentClientCapabilities( + code_action=lsp.CodeActionClientCapabilities( + code_action_literal_support=lsp.ClientCodeActionLiteralOptions( + code_action_kind=lsp.ClientCodeActionKindOptions( + value_set=[lsp.CodeActionKind.QuickFix], + ), + ), + ), + publish_diagnostics=lsp.PublishDiagnosticsClientCapabilities(), + ), + ), + ) + ) + assert result.capabilities is not None + client._server_capabilities = result.capabilities # type: ignore[attr-defined] + client.initialized(lsp.InitializedParams()) + + try: + yield client + finally: + try: + await client.shutdown_async(None) + client.exit(None) + except Exception: + pass + await client.stop() + + +async def _open_and_wait_for_diagnostics( + client: LanguageClient, + uri: str, + source: str, + *, + timeout: float = 10.0, +) -> list[lsp.Diagnostic]: + """Open a document and wait until publishDiagnostics arrives for its URI. + + The server publishes diagnostics asynchronously after didOpen. We poll + the client's collected notifications until the URI appears or timeout. + """ + client._published.pop(uri, None) # type: ignore[attr-defined] + + client.text_document_did_open( + lsp.DidOpenTextDocumentParams( + text_document=lsp.TextDocumentItem( + uri=uri, + language_id="java", + version=1, + text=source, + ), + ) + ) + + deadline = asyncio.get_running_loop().time() + timeout + while asyncio.get_running_loop().time() < deadline: + if uri in client._published: # type: ignore[attr-defined] + return client._published[uri] # type: ignore[attr-defined] + await asyncio.sleep(0.1) + + pytest.fail(f"Timed out waiting for publishDiagnostics on {uri}") + return [] # unreachable, but satisfies type checker + + +# -------------------------------------------------------------------------- +# Direct-call tests — exercise server internals for coverage +# -------------------------------------------------------------------------- +# +# The subprocess tests below (TestLspLifecycle) are the real e2e tests, but +# pytest-cov can only instrument the test process, not spawned subprocesses. +# These direct-call tests exercise the same server.py code paths in-process +# so the coverage counter credits them. + + +def _ensure_workspace() -> None: + """Bootstrap a minimal pygls workspace if not already initialized.""" + from pygls.workspace import Workspace + + from java_functional_lsp.server import server + + if server.protocol._workspace is None: + server.protocol._workspace = Workspace( + root_uri="file:///test", + sync_kind=lsp.TextDocumentSyncKind.Full, + ) + + +class TestServerInternals: + """Direct-call tests for server.py helpers — provides in-process coverage.""" + + def test_load_config_no_workspace(self) -> None: + from java_functional_lsp.server import _load_config + + assert _load_config(None) == {} + + def test_load_config_missing_file(self, tmp_path: Any) -> None: + from java_functional_lsp.server import _load_config + + assert _load_config(str(tmp_path)) == {} + + def test_load_config_valid_json(self, tmp_path: Any) -> None: + from java_functional_lsp.server import _load_config + + (tmp_path / ".java-functional-lsp.json").write_text('{"rules": {"null-return": "off"}}') + assert _load_config(str(tmp_path)) == {"rules": {"null-return": "off"}} + + def test_load_config_invalid_json(self, tmp_path: Any) -> None: + from java_functional_lsp.server import _load_config + + (tmp_path / ".java-functional-lsp.json").write_text("not json {{{") + assert _load_config(str(tmp_path)) == {} + + def test_to_lsp_diagnostic_with_data(self) -> None: + from java_functional_lsp.analyzers.base import Diagnostic as LintDiag + from java_functional_lsp.analyzers.base import DiagnosticData, Severity + from java_functional_lsp.server import _to_lsp_diagnostic + + diag = LintDiag( + line=5, + col=10, + end_line=5, + end_col=20, + severity=Severity.HINT, + code="test", + message="msg", + data=DiagnosticData(fix_type="FIX", target_library="lib", rationale="r"), + ) + result = _to_lsp_diagnostic(diag) + assert result.severity == lsp.DiagnosticSeverity.Hint + assert result.data is not None + assert result.data["fixType"] == "FIX" + + def test_to_lsp_diagnostic_without_data(self) -> None: + from java_functional_lsp.analyzers.base import Diagnostic as LintDiag + from java_functional_lsp.analyzers.base import Severity + from java_functional_lsp.server import _to_lsp_diagnostic + + diag = LintDiag(line=0, col=0, end_line=0, end_col=5, severity=Severity.WARNING, code="x", message="m") + assert _to_lsp_diagnostic(diag).data is None + + def test_analyze_document_with_excludes(self) -> None: + from java_functional_lsp.server import _analyze_document, server + + old = server._config + server._config = {"excludes": ["**/generated/**"]} + try: + assert _analyze_document("class T { String f() { return null; } }", "file:///generated/F.java") == [] + finally: + server._config = old + + def test_analyze_document_produces_diagnostics(self) -> None: + from java_functional_lsp.server import _analyze_document + + result = _analyze_document("class T { String f() { return null; } }", "file:///F.java") + assert any(d.code == "null-return" for d in result) + + def test_handle_exception_logs(self, caplog: Any) -> None: + import logging + + from java_functional_lsp.server import _handle_exception + + with caplog.at_level(logging.ERROR, logger="java_functional_lsp.server"): + _handle_exception(ValueError, ValueError("crash"), None) + assert any("Uncaught exception" in r.getMessage() for r in caplog.records) + + def test_jdtls_raw_to_lsp_diagnostics(self) -> None: + from java_functional_lsp.server import _jdtls_raw_to_lsp_diagnostics + + raw = [ + { + "range": {"start": {"line": 1, "character": 0}, "end": {"line": 1, "character": 10}}, + "severity": 2, + "code": "x", + "source": "Java", + "message": "warn", + } + ] + result = _jdtls_raw_to_lsp_diagnostics(raw) + assert len(result) == 1 + assert result[0].message == "warn" + + def test_jdtls_raw_to_lsp_diagnostics_malformed(self) -> None: + from java_functional_lsp.server import _jdtls_raw_to_lsp_diagnostics + + assert _jdtls_raw_to_lsp_diagnostics([42, None, "bad"]) == [] + + def test_on_jdtls_diagnostics_callback(self) -> None: + from unittest.mock import patch + + from java_functional_lsp.server import server + + _ensure_workspace() + uri = "file:///test/Cb.java" + server.workspace.put_text_document( + lsp.TextDocumentItem( + uri=uri, language_id="java", version=1, text="class T { String f() { return null; } }" + ), + ) + try: + with patch.object(server, "text_document_publish_diagnostics") as mock_pub: + server._on_jdtls_diagnostics(uri, []) + mock_pub.assert_called_once() + codes = [d.code for d in mock_pub.call_args[0][0].diagnostics] + assert "null-return" in codes + finally: + server.workspace.remove_text_document(uri) + + def test_serialize_params_camelcase(self) -> None: + from java_functional_lsp.server import _serialize_params + + result = _serialize_params( + lsp.DefinitionParams( + text_document=lsp.TextDocumentIdentifier(uri="file:///x.java"), + position=lsp.Position(line=0, character=0), + ) + ) + assert "textDocument" in result + assert "text_document" not in result + + def test_code_action_null_return(self) -> None: + from java_functional_lsp.server import on_code_action, server + + _ensure_workspace() + uri = "file:///test/CA1.java" + server.workspace.put_text_document( + lsp.TextDocumentItem(uri=uri, language_id="java", version=1, text=_BUGGY_JAVA), + ) + try: + diag = lsp.Diagnostic( + range=lsp.Range(start=lsp.Position(line=7, character=19), end=lsp.Position(line=7, character=23)), + message="m", + severity=lsp.DiagnosticSeverity.Warning, + code="null-return", + source="java-functional-lsp", + ) + result = on_code_action( + lsp.CodeActionParams( + text_document=lsp.TextDocumentIdentifier(uri=uri), + range=diag.range, + context=lsp.CodeActionContext(diagnostics=[diag]), + ) + ) + finally: + server.workspace.remove_text_document(uri) + assert result is not None + assert result[0].title == "Replace with Option.none()" + + def test_code_action_try_catch(self) -> None: + from java_functional_lsp.server import on_code_action, server + + _ensure_workspace() + uri = "file:///test/CA2.java" + server.workspace.put_text_document( + lsp.TextDocumentItem(uri=uri, language_id="java", version=1, text=_TRY_CATCH_JAVA), + ) + try: + diag = lsp.Diagnostic( + range=lsp.Range(start=lsp.Position(line=4, character=8), end=lsp.Position(line=4, character=11)), + message="m", + severity=lsp.DiagnosticSeverity.Hint, + code="try-catch-to-monadic", + source="java-functional-lsp", + ) + result = on_code_action( + lsp.CodeActionParams( + text_document=lsp.TextDocumentIdentifier(uri=uri), + range=diag.range, + context=lsp.CodeActionContext(diagnostics=[diag]), + ) + ) + finally: + server.workspace.remove_text_document(uri) + assert result is not None + assert any("Try.of" in e.new_text for e in result[0].edit.changes[uri]) + + def test_code_action_filters_foreign(self) -> None: + from java_functional_lsp.server import on_code_action, server + + _ensure_workspace() + uri = "file:///test/CA3.java" + server.workspace.put_text_document( + lsp.TextDocumentItem(uri=uri, language_id="java", version=1, text=_BUGGY_JAVA), + ) + try: + diag = lsp.Diagnostic( + range=lsp.Range(start=lsp.Position(line=0, character=0), end=lsp.Position(line=0, character=5)), + message="x", + severity=lsp.DiagnosticSeverity.Warning, + code="jdtls-thing", + source="Java", + ) + result = on_code_action( + lsp.CodeActionParams( + text_document=lsp.TextDocumentIdentifier(uri=uri), + range=diag.range, + context=lsp.CodeActionContext(diagnostics=[diag]), + ) + ) + finally: + server.workspace.remove_text_document(uri) + assert result is None + + +# -------------------------------------------------------------------------- +# Subprocess-based tests — zero mocks, real LSP transport +# -------------------------------------------------------------------------- + + +@pytest.mark.timeout(30) +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.""" + 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 + + async def test_null_return_diagnostic_published(self, lsp_client: LanguageClient) -> None: + """didOpen a file with ``return null`` → server publishes null-return diagnostic.""" + uri = "file:///test/BuggyExample.java" + diags = await _open_and_wait_for_diagnostics(lsp_client, uri, _BUGGY_JAVA) + codes = [d.code for d in diags] + assert "null-return" in codes + + async def test_null_check_to_monadic_diagnostic_published(self, lsp_client: LanguageClient) -> None: + """The if(x != null) pattern produces a null-check-to-monadic hint.""" + uri = "file:///test/BuggyExample2.java" + diags = await _open_and_wait_for_diagnostics(lsp_client, uri, _BUGGY_JAVA) + codes = [d.code for d in diags] + assert "null-check-to-monadic" in codes + + async def test_try_catch_to_monadic_diagnostic_published(self, lsp_client: LanguageClient) -> None: + """try/catch with single return produces a try-catch-to-monadic hint.""" + uri = "file:///test/TryCatch.java" + diags = await _open_and_wait_for_diagnostics(lsp_client, uri, _TRY_CATCH_JAVA) + codes = [d.code for d in diags] + assert "try-catch-to-monadic" in codes + + async def test_clean_file_produces_no_diagnostics(self, lsp_client: LanguageClient) -> None: + """A clean Java file should produce zero diagnostics.""" + uri = "file:///test/Clean.java" + diags = await _open_and_wait_for_diagnostics(lsp_client, uri, _CLEAN_JAVA) + assert len(diags) == 0 + + async def test_null_return_code_action_quickfix(self, lsp_client: LanguageClient) -> None: + """Request code action on null-return diagnostic → QuickFix with Option.none(). + + This is the full round-trip: didOpen → publishDiagnostics → codeAction + request with the real diagnostic → server returns a WorkspaceEdit. + """ + uri = "file:///test/BuggyAction.java" + diags = await _open_and_wait_for_diagnostics(lsp_client, uri, _BUGGY_JAVA) + null_diag = next((d for d in diags if d.code == "null-return"), None) + assert null_diag is not None + + actions = await lsp_client.text_document_code_action_async( + lsp.CodeActionParams( + text_document=lsp.TextDocumentIdentifier(uri=uri), + range=null_diag.range, + context=lsp.CodeActionContext(diagnostics=[null_diag]), + ) + ) + + assert actions is not None + assert len(actions) >= 1 + action = actions[0] + assert action.title == "Replace with Option.none()" + assert action.kind == lsp.CodeActionKind.QuickFix + assert action.edit is not None + assert action.edit.changes is not None + edits = action.edit.changes[uri] + assert any("Option.none()" in e.new_text for e in edits) + assert any("import io.vavr.control.Option;" in e.new_text for e in edits) + + async def test_try_catch_code_action_quickfix(self, lsp_client: LanguageClient) -> None: + """Request code action on try-catch-to-monadic → QuickFix with Try.of().""" + uri = "file:///test/TryCatchAction.java" + diags = await _open_and_wait_for_diagnostics(lsp_client, uri, _TRY_CATCH_JAVA) + try_diag = next((d for d in diags if d.code == "try-catch-to-monadic"), None) + assert try_diag is not None + + actions = await lsp_client.text_document_code_action_async( + lsp.CodeActionParams( + text_document=lsp.TextDocumentIdentifier(uri=uri), + range=try_diag.range, + context=lsp.CodeActionContext(diagnostics=[try_diag]), + ) + ) + + assert actions is not None + assert len(actions) >= 1 + action = actions[0] + assert action.title == "Convert try/catch to Try monadic flow" + assert action.edit is not None + assert action.edit.changes is not None + edits = action.edit.changes[uri] + assert any("Try.of(() -> riskyRead())" in e.new_text for e in edits) + assert any("import io.vavr.control.Try;" in e.new_text for e in edits) + + async def test_code_action_ignores_foreign_diagnostics(self, lsp_client: LanguageClient) -> None: + """Diagnostics from other sources get no code actions from our server.""" + uri = "file:///test/Foreign.java" + await _open_and_wait_for_diagnostics(lsp_client, uri, _BUGGY_JAVA) + + foreign_diag = lsp.Diagnostic( + range=lsp.Range(start=lsp.Position(line=0, character=0), end=lsp.Position(line=0, character=5)), + message="Some jdtls warning", + severity=lsp.DiagnosticSeverity.Warning, + code="something-jdtls", + source="Java", + ) + actions = await lsp_client.text_document_code_action_async( + lsp.CodeActionParams( + text_document=lsp.TextDocumentIdentifier(uri=uri), + range=foreign_diag.range, + context=lsp.CodeActionContext(diagnostics=[foreign_diag]), + ) + ) + assert actions is None or len(actions) == 0 + + async def test_diagnostics_update_on_file_change(self, lsp_client: LanguageClient) -> None: + """didChange with a fixed file should clear diagnostics. + + Opens a buggy file, verifies diagnostics arrive, then sends a + didChange with clean source and verifies diagnostics are cleared. + """ + uri = "file:///test/Changing.java" + diags = await _open_and_wait_for_diagnostics(lsp_client, uri, _BUGGY_JAVA) + assert len(diags) > 0 + + # Clear the notification cache and send a change to clean source. + lsp_client._published.pop(uri, None) # type: ignore[attr-defined] + lsp_client.text_document_did_change( + lsp.DidChangeTextDocumentParams( + text_document=lsp.VersionedTextDocumentIdentifier(uri=uri, version=2), + content_changes=[lsp.TextDocumentContentChangeWholeDocument(text=_CLEAN_JAVA)], + ) + ) + + # Wait for fresh diagnostics (debounced, ~150ms + processing). + loop = asyncio.get_running_loop() + deadline = loop.time() + 5.0 + while loop.time() < deadline: + if uri in lsp_client._published: # type: ignore[attr-defined] + break + await asyncio.sleep(0.1) + + assert uri in lsp_client._published, "Timed out waiting for updated diagnostics after didChange" # type: ignore[attr-defined] + new_diags = lsp_client._published[uri] # type: ignore[attr-defined] + assert len(new_diags) == 0, f"Expected zero diagnostics after fixing, got {[d.code for d in new_diags]}" diff --git a/uv.lock b/uv.lock index 98c354c..43ed4e3 100644 --- a/uv.lock +++ b/uv.lock @@ -184,7 +184,7 @@ wheels = [ [[package]] name = "java-functional-lsp" -version = "0.7.1" +version = "0.7.2" source = { editable = "." } dependencies = [ { name = "pygls" },