A Java Language Server that provides three things in one:
- Full Java language support — completions, hover, go-to-definition, compile errors, missing imports — by proxying Eclipse jdtls under the hood
- 16 functional programming rules — catches anti-patterns and suggests Vavr/Lombok/Spring alternatives, all before compilation
- 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.
When jdtls is installed, the server proxies all standard Java language features:
- Compile errors and warnings
- Missing imports and unresolved symbols
- 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.
| Rule | Detects | Suggests | Quick Fix |
|---|---|---|---|
null-literal-arg |
null passed as method argument |
Option.none() or default value |
— |
null-return |
return null |
Option.of(), Option.none(), or Either |
✅ |
null-assignment |
Type x = null |
Option<Type> |
— |
null-field-assignment |
Field initialized to null |
Option<T> with Option.none() |
— |
throw-statement |
throw new XxxException(...) |
Either.left() or Try.of() |
— |
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() |
— |
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 |
— |
# Homebrew
brew install aviadshiber/tap/java-functional-lsp
# pip
pip install java-functional-lsp
# From source
pip install git+https://github.com/aviadshiber/java-functional-lsp.git
# Optional: install jdtls for full Java language support (see above)
brew install jdtlsRequirements:
- Python 3.10+ (for the LSP server)
- JDK 21+ (only if using jdtls — jdtls 1.57+ requires JDK 21 as its runtime, but can analyze Java 8+ source code)
Install the extension from a .vsix file (download from releases):
# Download and install
gh release download --repo aviadshiber/java-functional-lsp --pattern "*.vsix" --dir /tmp
code --install-extension /tmp/java-functional-lsp-*.vsixOr build from source:
cd editors/vscode
npm install && npm run compile
npx vsce package
code --install-extension java-functional-lsp-*.vsixThe extension is a thin launcher — it just starts the java-functional-lsp binary for .java files. Updating rules only requires upgrading the LSP binary (brew upgrade java-functional-lsp or pip install --upgrade java-functional-lsp). The VSIX itself rarely needs updating.
Configure the binary path in settings if needed (javaFunctionalLsp.serverPath). See editors/vscode/README.md for details.
Use the LSP4IJ plugin (works on Community & Ultimate):
- Install LSP4IJ from the JetBrains Marketplace
- Settings → Languages & Frameworks → Language Servers →
+ - Set Command:
java-functional-lsp, then in Mappings → File name patterns add*.javawith Language Idjava
See editors/intellij/README.md for detailed instructions.
Step 1: Enable LSP support (required, one-time):
Add to ~/.claude/settings.json:
{
"env": {
"ENABLE_LSP_TOOL": "1"
}
}Step 2: Install the plugin:
claude plugin add https://github.com/aviadshiber/java-functional-lsp.gitThis 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.
Or manually add to your Claude Code config:
{
"lspServers": {
"java-functional": {
"command": "java-functional-lsp",
"extensionToLanguage": { ".java": "java" }
}
}
}Alternative: project-level .lsp.json — instead of installing the plugin or editing global config, add a .lsp.json file at your project root:
{
"java-functional": {
"command": "java-functional-lsp",
"extensionToLanguage": { ".java": "java" }
}
}This is useful for CI environments, containers, or ensuring all team members get the LSP server without individual setup. The java-functional-lsp binary must still be installed (pip install java-functional-lsp or brew install aviadshiber/tap/java-functional-lsp).
Step 3: Nudge Claude to prefer LSP (recommended):
Add to ~/.claude/rules/code-intelligence.md:
# Code Intelligence
Prefer LSP over Grep/Glob/Read for code navigation:
- goToDefinition / goToImplementation to jump to source
- findReferences to see all usages across the codebase
- hover for type info without reading the file
After writing or editing code, check LSP diagnostics before
moving on. Fix any type errors or missing imports immediately.Troubleshooting:
| Issue | Fix |
|---|---|
| No diagnostics appear | Ensure ENABLE_LSP_TOOL=1 is set, restart Claude Code |
| "java-functional-lsp not found" | Run brew install aviadshiber/tap/java-functional-lsp |
| Plugin not active | Run claude plugin list to verify, then /reload-plugins |
| Diagnostics slow on first open | Normal — tree-sitter parses on first load, then incremental |
Any LSP client that supports stdio transport can use this server. Point it to the java-functional-lsp command for java files.
| Editor | Config |
|---|---|
| Neovim | vim.lsp.start({ cmd = {"java-functional-lsp"}, filetypes = {"java"} }) |
| Emacs (eglot) | (add-to-list 'eglot-server-programs '(java-mode "java-functional-lsp")) |
| Sublime Text | LSP package → add server with "command": ["java-functional-lsp"] |
Create .java-functional-lsp.json in your project root to customize rules:
{
"excludes": ["**/generated/**", "**/vendor/**"],
"rules": {
"null-literal-arg": "warning",
"throw-statement": "info",
"imperative-loop": "hint",
"mutable-dto": "off"
}
}Options:
excludes— glob patterns for files/directories to skip entirely (supports**for multi-segment wildcards)rules— per-rule severity:error,warning(default),info,hint,off
Spring-aware behavior:
throw-statement,catch-rethrow, andtry-catch-to-monadicare automatically suppressed inside@Beanmethodsmutable-dtosuggests@ConstructorBindinginstead of@Valuewhen the class has@ConfigurationProperties
Inline suppression with @SuppressWarnings:
// Suppress a specific rule on a method
@SuppressWarnings("java-functional-lsp:null-return")
public String findUser() { return null; } // no diagnostic
// Suppress multiple rules
@SuppressWarnings({"java-functional-lsp:null-return", "java-functional-lsp:throw-statement"})
public String findUser() { ... }
// Suppress all java-functional-lsp rules
@SuppressWarnings("java-functional-lsp")
public String legacyMethod() { ... }Works on classes, methods, constructors, fields, and local variables. Suppression applies to the annotated scope — a class-level annotation suppresses all methods within it.
The server provides LSP code actions (textDocument/codeAction) that automatically refactor code. When your editor shows a diagnostic with a lightbulb icon, clicking it applies the fix:
| 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(...), 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.
Every diagnostic includes a machine-readable data payload designed for AI agents like Claude Code:
{
"code": "frozen-mutation",
"message": "Runtime Exception Risk: Mutating a frozen structure...",
"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."
}
}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.
Agent mode configuration in .java-functional-lsp.json:
{
"autoImportVavr": true,
"strictPurity": true
}| Key | Default | Effect |
|---|---|---|
autoImportVavr |
true |
Quick fixes auto-add Vavr/Option imports |
strictPurity |
false |
When true, impure-method uses WARNING severity instead of HINT |
Note: The machine-readable
datapayload is always included in diagnostics when available — no configuration needed.
The server has two layers:
- Custom rules — uses tree-sitter with the Java grammar for sub-millisecond AST analysis (~0.4ms per file). No compiler or classpath needed — runs on raw source files.
- Java language features — proxies Eclipse jdtls for compile errors, completions, hover, go-to-definition, and references. Diagnostics from both layers are merged and published together.
The server speaks the Language Server Protocol (LSP) via stdio, making it compatible with any LSP client.
# Clone and setup
git clone https://github.com/aviadshiber/java-functional-lsp.git
cd java-functional-lsp
uv sync
git config core.hooksPath .githooks
# Run checks
uv run ruff check src/ tests/
uv run ruff format --check src/ tests/
uv run mypy src/
uv run pytestGit hooks in .githooks/ enforce quality automatically:
- pre-commit — runs lint, format, type check, and tests before each commit
- pre-push — blocks direct pushes to main (use feature branches + PRs)
See CONTRIBUTING.md for full guidelines.
MIT