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
47 changes: 38 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
A Java Language Server that provides three 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. **16 functional programming rules** — catches anti-patterns and suggests Vavr/Lombok/Spring alternatives, all before compilation
2. **17 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.
Expand All @@ -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+). 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.
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 17 custom rules still work, but you won't get compile errors or completions.

### Functional programming rules

Expand All @@ -38,14 +38,15 @@ Install jdtls separately: `brew install jdtls` (requires JDK 21+). The server au
| `catch-rethrow` | catch block that wraps + rethrows | `Try.of().toEither()` | — |
| `mutable-variable` | Local variable reassignment | Final variables + functional transforms | — |
| `imperative-loop` | `for`/`while` loops | `.map()`/`.filter()`/`.flatMap()`/`.foldLeft()` | — |
| `mutable-dto` | `@Data` or `@Setter` on class | `@Value` (immutable) | |
| `imperative-option-unwrap` | `if (opt.isDefined()) { opt.get() }` | `map()`/`flatMap()`/`fold()` | |
| `mutable-dto` | `@Data` or `@Setter` on class | `@Value` (immutable) | |
| `imperative-option-unwrap` | `if (opt.isDefined()) { opt.get() }` | `map()`/`flatMap()`/`fold()` | |
| `field-injection` | `@Autowired` on field | Constructor injection | — |
| `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` | — |
| `impure-method` | Method mixing pure logic with side-effects | Extract pure logic; wrap IO in `Try` / return `Either.left` instead of throwing | — |
| `option-map-nullable` | `Option.map(x -> x.get(k))` followed by chained call (`Some(null)` risk) | `.flatMap(x -> Option.of(...))` | — |

## Install

Expand Down Expand Up @@ -129,7 +130,30 @@ Add `lspServers` to `~/.claude/settings.json` (the plugin handles this automatic
claude plugin add https://github.com/aviadshiber/java-functional-lsp.git
```

This registers the LSP server, adds auto-install hooks, a PostToolUse hook that reminds Claude to fix violations on every `.java` file edit, and the `/lint-java` command.
This registers the LSP server, adds auto-install hooks, a PostToolUse hook that lints every `.java` file after Edit/Write and feeds the violations back to Claude as context (plus a reminder hook on Read), and the `/lint-java` command.

**Manual hook setup (without the plugin)** — add the lint hook to `~/.claude/settings.json`, pointing at a checkout of this repo:

```json
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|MultiEdit|Write",
"hooks": [
{
"type": "command",
"command": "python3 /path/to/java-functional-lsp/hooks/post_tool_lint.py",
"timeout": 10
}
]
}
]
}
}
```

The hook is failure-safe: it only fires on `.java` files, lints just the edited file (well under 2s), stays silent when the file is clean, and always exits 0 so a linter problem can never break the editing session.

Or manually add to your Claude Code config:

Expand Down Expand Up @@ -313,8 +337,10 @@ The server provides LSP code actions (`textDocument/codeAction`) that automatica
| `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. |
| `imperative-option-unwrap` | Convert to Option.map().getOrElse() | Rewrites `if (opt.isDefined()) return opt.get(); else return X;` → `return opt.map(it -> ...).getOrElse(X);` (lazy `getOrElse(() -> ...)` for non-eager defaults). Bails on missing else or complex bodies. |
| `mutable-dto` | Replace @Data with @Value | Replaces the `@Data` annotation with `@Value` and adds `import lombok.Value`. Skips `@Setter`, `@ConfigurationProperties`, and conflicting Lombok constructor annotations. |

Quick fixes automatically add the required Vavr import if it's not already present. Disable auto-import with `"autoImportVavr": false` in config.
Quick fixes automatically add the required Vavr import if it's not already present. Disable auto-import with `"autoImportVavr": false` in config (`"autoImportLombok": false` for the Lombok import added by the `mutable-dto` fix).

## Agent mode (AI integration)

Expand All @@ -327,12 +353,14 @@ Every diagnostic includes a machine-readable `data` payload designed for AI agen
"data": {
"fixType": "REPLACE_WITH_VAVR_LIST",
"targetLibrary": "io.vavr.collection.List",
"rationale": "Runtime mutation of List.of() causes UnsupportedOperationException. Use Vavr for safe, persistent immutability."
"rationale": "Runtime mutation of List.of() causes UnsupportedOperationException. Use Vavr for safe, persistent immutability.",
"recommendedApi": ".append / .appendAll / .update / .remove (returns a new persistent collection)",
"suggestedSnippet": "list = list.append(\"c\"); // returns a new persistent collection"
}
}
```

This lets agents confidently apply fixes without guessing libraries or patterns — the `fixType` tells them *what* to do, `targetLibrary` tells them *which dependency*, and `rationale` tells them *why*.
This lets agents confidently apply fixes without guessing libraries or patterns — the `fixType` tells them *what* to do, `targetLibrary` tells them *which dependency*, and `rationale` tells them *why*. `recommendedApi` names the exact method on the target library (e.g. Vavr `Option` uses `forEach`, **not** `ifPresent`) and `suggestedSnippet` is a paste-able fix built from the real AST variable names.

**Agent mode configuration** in `.java-functional-lsp.json`:

Expand All @@ -346,6 +374,7 @@ This lets agents confidently apply fixes without guessing libraries or patterns
| Key | Default | Effect |
|-----|---------|--------|
| `autoImportVavr` | `true` | Quick fixes auto-add Vavr/Option imports |
| `autoImportLombok` | `true` | The `mutable-dto` quick fix auto-adds `import lombok.Value` |
| `strictPurity` | `false` | When `true`, `impure-method` uses WARNING severity instead of HINT |

> **Note:** The machine-readable `data` payload is always included in diagnostics when available — no configuration needed.
Expand Down
26 changes: 17 additions & 9 deletions SKILL.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
---
name: java-functional-lsp
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.
description: Java LSP with full language support (completions, hover, go-to-def, compile errors) plus 17 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 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.
A Java LSP server that wraps jdtls and adds 17 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

Expand All @@ -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 (16 checks)
## Rules (17 checks)

| Rule | Detects | Suggests | Quick Fix |
|------|---------|----------|-----------|
Expand All @@ -34,14 +34,15 @@ Without jdtls, the server runs in standalone mode — custom rules still work, b
| `catch-rethrow` | catch wraps + rethrows | `Try.of().toEither()` | — |
| `mutable-variable` | Variable reassignment | Final + functional transforms | — |
| `imperative-loop` | `for`/`while` loops | `.map()`/`.filter()`/`.flatMap()` | — |
| `mutable-dto` | `@Data` or `@Setter` | `@Value` (immutable) | |
| `imperative-option-unwrap` | `if (opt.isDefined()) { opt.get() }` | `map()`/`flatMap()`/`fold()` | |
| `mutable-dto` | `@Data` or `@Setter` | `@Value` (immutable) | |
| `imperative-option-unwrap` | `if (opt.isDefined()) { opt.get() }` | `map()`/`flatMap()`/`fold()` | |
| `field-injection` | `@Autowired` on field | Constructor injection | — |
| `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` | — |
| `impure-method` | Method mixing pure logic with side-effects | Extract pure logic; wrap IO in `Try` / return `Either.left` instead of throwing | — |
| `option-map-nullable` | `Option.map(x -> x.get(k))` followed by chained call (`Some(null)` risk) | `.flatMap(x -> Option.of(...))` | — |

## Code Actions (Quick Fixes)

Expand All @@ -51,6 +52,8 @@ Rules marked ✅ provide automated `textDocument/codeAction` fixes:
- **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.
- **imperative-option-unwrap** → "Convert to Option.map().getOrElse()" — rewrites `if (opt.isDefined()) return opt.get(); else return X;` to `return opt.map(it -> ...).getOrElse(X);` (lazy supplier for non-eager defaults). Bails on missing else or complex bodies.
- **mutable-dto** → "Replace @Data with @Value" — swaps the annotation and adds `import lombok.Value` (disable with `"autoImportLombok": false`). Skips `@Setter`, `@ConfigurationProperties`, and conflicting Lombok constructor annotations.

## Agent-Ready Diagnostics

Expand All @@ -60,11 +63,13 @@ Every diagnostic includes a machine-readable `data` payload:
{
"fixType": "REPLACE_WITH_VAVR_LIST",
"targetLibrary": "io.vavr.collection.List",
"rationale": "Runtime mutation of List.of() causes UnsupportedOperationException."
"rationale": "Runtime mutation of List.of() causes UnsupportedOperationException.",
"recommendedApi": ".append / .appendAll / .update / .remove (returns a new persistent collection)",
"suggestedSnippet": "list = list.append(\"c\"); // returns a new persistent collection"
}
```

This lets AI agents apply fixes with confidence — `fixType` says what to do, `targetLibrary` says which dependency, `rationale` says why.
This lets AI agents apply fixes with confidence — `fixType` says what to do, `targetLibrary` says which dependency, `rationale` says why, `recommendedApi` names the exact method (e.g. Vavr `Option` uses `forEach`, not `ifPresent`), and `suggestedSnippet` is a paste-able fix built from real AST variable names.

## Configuration

Expand Down Expand Up @@ -93,7 +98,10 @@ Create `.java-functional-lsp.json` in your project root:

## Automatic Enforcement

The plugin includes a PostToolUse hook that fires on every Read/Edit/Write of `.java` files. If diagnostics appear, Claude is prompted to fix them immediately without explanation.
The plugin includes two PostToolUse hooks:

- **Edit/MultiEdit/Write** → `hooks/post_tool_lint.py` lints the edited `.java` file (single-file, <2s) and injects any violations into Claude's context so they get fixed immediately. Silent on clean files; internal errors are swallowed (always exits 0) so the editing session is never broken.
- **Read** → `hooks/java_linter_reminder.py` reminds Claude to act on LSP diagnostics shown for the file.

## On-Demand Linting

Expand Down
2 changes: 1 addition & 1 deletion editors/intellij/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ Project-level rules are configured via `.java-functional-lsp.json` in your proje

## Coexistence with IntelliJ's Java Support

The server automatically detects JetBrains IDEs and disables the jdtls proxy — IntelliJ provides its own Java language features (completions, hover, go-to-definition, compile errors). Only the 16 custom functional programming rules run, and they appear alongside IntelliJ's built-in inspections.
The server automatically detects JetBrains IDEs and disables the jdtls proxy — IntelliJ provides its own Java language features (completions, hover, go-to-definition, compile errors). Only the 17 custom functional programming rules run, and they appear alongside IntelliJ's built-in inspections.

## Troubleshooting

Expand Down
2 changes: 1 addition & 1 deletion editors/vscode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,4 @@ This extension **coexists** with the Red Hat Java extension (`redhat.java`) and

## Rules

See the [main README](../../README.md) for the full list of 12 rules and configuration options via `.java-functional-lsp.json`.
See the [main README](../../README.md) for the full list of 17 rules and configuration options via `.java-functional-lsp.json`.
12 changes: 11 additions & 1 deletion hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,23 @@
],
"PostToolUse": [
{
"matcher": "Read|Edit|Write",
"matcher": "Read",
"hooks": [
{
"type": "command",
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/java_linter_reminder.py"
}
]
},
{
"matcher": "Edit|MultiEdit|Write",
"hooks": [
{
"type": "command",
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/post_tool_lint.py",
"timeout": 10
}
]
}
]
}
Expand Down
89 changes: 89 additions & 0 deletions hooks/post_tool_lint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""PostToolUse hook: lint a .java file after Edit/Write and surface violations to Claude.

Reads the Claude Code PostToolUse JSON payload on stdin, runs java-functional-lsp
on the edited file, and emits diagnostics as ``hookSpecificOutput.additionalContext``
so the agent sees them in context and can fix them immediately (issue #70).

Failure-safe by design: every path exits 0 — a linter problem must never break the
editing session. Prefers the fast in-process import; falls back to the CLI when the
package is not importable under this interpreter.
"""

from __future__ import annotations

import json
import signal
import subprocess
import sys
from pathlib import Path

TIMEOUT_SECONDS = 5 # hard cap; single-file analysis is typically <200ms
MAX_DIAGNOSTICS = 25 # keep additionalContext bounded


def _lint_in_process(path: Path) -> list[str] | None:
"""Lint via direct import. Returns None if the package isn't importable here."""
try:
from java_functional_lsp.analyzers.base import is_excluded
from java_functional_lsp.cli import check_file, format_diagnostic, load_config
except ImportError:
return None
config = load_config(path)
if is_excluded(path.as_posix(), config.get("excludes", [])):
return []
return [format_diagnostic(path, d) for d in check_file(path, config)]


def _lint_via_cli(path: Path) -> list[str]:
"""Fallback: shell out to the installed CLI (exit 1 + stdout lines on violations)."""
proc = subprocess.run(
["java-functional-lsp", "check", str(path)],
capture_output=True,
text=True,
timeout=TIMEOUT_SECONDS,
check=False, # exit 1 just means violations were found
)
return [ln for ln in proc.stdout.splitlines() if ln.strip()]


def main() -> None:
hook_input = json.load(sys.stdin)
file_path = (hook_input.get("tool_input") or {}).get("file_path", "")
if not file_path.endswith(".java"):
return # silent no-op
path = Path(file_path)
if not path.is_file():
return # tool call may have failed or the file was deleted

lines = _lint_in_process(path)
if lines is None:
lines = _lint_via_cli(path)
if not lines:
return # clean file: stay silent, no per-edit context noise

if len(lines) > MAX_DIAGNOSTICS:
lines = [*lines[:MAX_DIAGNOSTICS], f"... and {len(lines) - MAX_DIAGNOSTICS} more"]
json.dump(
{
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": (
"java-functional-lsp found violations in the file you just edited:\n"
+ "\n".join(lines)
+ "\nFix each violation now with your next Edit. Do not explain or list them."
),
}
},
sys.stdout,
)


if __name__ == "__main__":
if hasattr(signal, "SIGALRM"): # POSIX hard runtime cap
signal.signal(signal.SIGALRM, lambda *_: sys.exit(0))
signal.alarm(TIMEOUT_SECONDS)
try:
main()
except Exception:
sys.exit(0) # hooks must never break the session
Loading
Loading