java-functional-lsp
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
- 17 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.
What it checks
Java language (via jdtls)
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 17 custom rules still work, but you won't get compile errors or completions.
Functional programming rules
| 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); record instead at sourceLevel 16+ |
✅ |
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 / 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
# 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 jdtls
Requirements:
- 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)
IDE Setup
VS 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-*.vsix
Or build from source:
cd editors/vscode
npm install && npm run compile
npx vsce package
code --install-extension java-functional-lsp-*.vsix
The 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.
IntelliJ IDEA
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
The server automatically detects JetBrains IDEs and disables the jdtls proxy (IntelliJ provides native Java support). To force-enable jdtls, set JAVA_FUNCTIONAL_LSP_JDTLS=on in the server command environment.
See editors/intellij/README.md for detailed instructions.
Claude Code
Step 1: Enable LSP support (required, one-time):
Add lspServers to ~/.claude/settings.json (the plugin handles this automatically — only needed for manual setup):
{
"lspServers": {
"java-functional": {
"command": "java-functional-lsp",
"extensionToLanguage": { ".java": "java" }
}
}
}
Step 2: Install the plugin:
claude plugin add https://github.com/aviadshiber/java-functional-lsp.git
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:
{
"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:
{
"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 lspServers is configured (plugin or settings.json), restart |
| "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 |
| Java errors show up one tool call after the edit | Claude Code doesn't wait for LSP diagnostics after Edit/Write (anthropics/claude-code#93321). The plugin's hook waits up to 3s for them; if jdtls is slower (large projects), raise JAVA_FUNCTIONAL_LSP_HOOK_WAIT (max 4). See Fresh jdtls diagnostics after edits |
| False "X cannot be resolved" / "The hierarchy of the type X is inconsistent" on classes from another Maven group of the same repository | The server imports such modules automatically, only as far as the open file needs. See Dependencies on modules in other Maven groups for the stop reasons, the log lines to check, the limits (JAVA_FUNCTIONAL_LSP_DEPENDENCY_MODULES, JAVA_FUNCTIONAL_LSP_DEPENDENCY_ROUNDS) and the stale-install case |
Other Editors
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"] |
Configuration
Create .java-functional-lsp.json in your project root to customize rules:
{
"excludes": ["**/generated/**", "**/vendor/**"],
"sourceLevel": 17,
"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,offsourceLevel— the project's Java language/source level, as an int (e.g.17) or version string ("17", legacy"1.8"). Also accepted asjavaVersion. Defaults to8if unset, so existing configs are unaffected. Currently only changes themutable-dtorecommendation (see below); higher source levels unlock more rewrite targets over time (records, sealed types, pattern matching, text blocks).suppressJdtlsPatterns— list of regex patterns to suppress jdtls diagnostics (see below)
Spring-aware behavior:
throw-statement,catch-rethrow, andtry-catch-to-monadicare automatically suppressed inside@Beanmethodsmutable-dtosuggests@ConstructorBindinginstead of@Valuewhen the class has@ConfigurationProperties
Source-level-aware behavior:
mutable-dtorecommends arecordinstead of@ValueoncesourceLevelis 16 or higher (records became final/non-preview in JDK 16) — a plain immutable DTO is simpler as a built-inrecordwith no Lombok dependency. It still calls out@Valueas the fallback when the class needs@With/@Builder/@Jacksonized, is used as a facade, or relies on AOP proxying (records arefinaland can't be proxied). BelowsourceLevel16 (including the default), the message and quick fix are unchanged —@Valueonly.
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.
Lombok support
Projects using Lombok need the Lombok Java agent for jdtls to process @Builder, @Value, @Data, @Slf4j, and other annotations. Without it, jdtls reports false "method undefined" errors for generated methods.
The server auto-discovers lombok.jar from these locations (first match wins):
- Project config — add to
.java-functional-lsp.json:{ "lombok": "/path/to/lombok.jar" }
- Environment variable —
LOMBOK_JAR=/path/to/lombok.jar - Maven cache — auto-discovered from
~/.m2/repository/org/projectlombok/lombok/ - Dedicated directory —
~/.jdtls-libs/lombok.jar
If Lombok is used in your project but the jar isn't found, the server logs a warning.
jdtls settings
The server sends Maven/Gradle settings to jdtls at startup via initializationOptions.settings. Defaults are optimized for Maven monorepos (Maven enabled, Gradle disabled, build artifact exclusions). Override via .java-functional-lsp.json:
{
"jdtls": {
"settings": {
"java": {
"import": {
"maven": { "enabled": true },
"gradle": { "enabled": true },
"exclusions": ["**/node_modules/**", "**/target/**"]
},
"configuration": { "updateBuildConfiguration": "automatic" },
"maven": { "downloadSources": true }
}
}
}
}
Custom settings fully replace the defaults (no merge). See the jdtls Preferences reference for all available keys.
jdtls cache
The jdtls Eclipse workspace index is cached in ~/.cache/jdtls-data/. Warm starts (~10-20s) reuse this cache; cold starts (60-120s) rebuild from scratch. The cache is automatically invalidated when jdtls or Java is upgraded, but not when java-functional-lsp is upgraded — our Python code changes don't affect the Eclipse index.
To force a clean rebuild: rm -rf ~/.cache/jdtls-data/
Dependencies on modules in other Maven groups
To keep jdtls fast and within its heap, the server imports only the Maven group of the file you open (its tightest parent pom with <modules>), plus up to 5 more groups as you navigate. A module in that group can depend on a reactor module in another group. m2e then looks that dependency up in the local Maven repository at the reactor's version, often a ${revision} default that is never installed. Every symbol from that module then shows as a false error: "cannot be resolved", "is undefined for the type", or "The hierarchy of the type X is inconsistent" (#110). The server fixes this by importing the missing modules itself, only as far as the open file needs. (v0.14.0 imported every module any pom reported missing. On a large monorepo that cascaded through the transitive closure to 60 modules, 3 GB of jdtls heap, and 30 s timeouts on every request.)
How it works (v0.14.1):
- Demand. An open
.javafile whose jdtls diagnostics hold #110-class errors (unresolved import or type, "undefined for the type", "hierarchy … inconsistent", "indirectly referenced from required .class files", "must override or implement a supertype method") starts the process for its module. Other open files, and files you never opened, never trigger an import. - Fresh diagnostics only. jdtls does not re-check a file that is already open when its classpath changes, so its errors can be minutes old. The server waits until jdtls is build-idle: no open
language/progressReporttask (indexing / "Searching…" tasks are ignored) and no progress event for 10 s. It then sendsjava.project.refreshDiagnosticsfor up to 3 demand files, most recently opened first, one at a time. Only the publish that answers that refresh counts, and only if the document still has the same content. - Frontier. The candidates come from the reactor index, which is built once per session from the modules reachable from the reactor root through
<modules>(all profiles). They are the in-repo<dependencies>(compile, provided, runtime) of the demand module and of every module imported so far, including those inherited from in-repo parent poms.dependencyManagementis ignored. For a file undersrc/test, the demand module's test-scope andtest-jardependencies are included too. - Candidates = frontier ∩ m2e markers. A frontier module is imported only if m2e reports it missing on some pom (
[Offline / ]Missing artifact g:a:type[:classifier]:version). The marker is the proof that no installed jar resolves it. Atest-jaredge only matches atest-jar/testsmarker. Candidates keep their declaration order, and high fan-in "hub" modules go last. - One round. Up to 8 candidates are added as one
didChangeWorkspaceFoldersevent. The server waits for build-idle (at most 90 s) and refreshes the demand files again. If the fresh diagnostics are clean, it stops. If they are not, it runs the next round. Before deciding round k, it waits (at most 30 s) until the modules imported in round k−1 have published their own pom markers. A large module group's build can outlast one 90 s wait (measured: a 95-module/127-project group on products), so a still-busy jdtls does not stop the phase at once — the wait retries (up to 6 attempts, up to 10 minutes total) and the round resumes as soon as jdtls next reports build-idle. Only once that busy budget is exhausted does the phase stop withjdtls-busy. - Stop. Every stop is logged once and sent to the client once as a
window/logMessage. Opening a file in a module that had no demand before starts the process again, unless a session limit (budget, rounds, wall clock, refresh cap) was reached. A stop ofjdtls-busyorno-fresh-diagnosticsbefore anything was imported lets the same module try again on its next demand; the same warning twice in a row is logged at DEBUG and not sent again.
If you later open a file in a group that contains one of these modules, the group folder replaces the module folder in the same workspace change. That module folder is never re-added.
Configuration (the repository config can only lower a limit; the environment can raise it up to the hard maximum):
| Setting | Values | Effect |
|---|---|---|
JAVA_FUNCTIONAL_LSP_DEPENDENCY_MODULES (environment) |
0–200 (default 30) |
Session budget of dependency-module folders; 0 disables the import |
{"jdtls": {"dependencyModules": N}} in .java-functional-lsp.json |
0–default |
Lowers the budget (a higher value is ignored with a warning) |
JAVA_FUNCTIONAL_LSP_DEPENDENCY_ROUNDS (environment) |
0–20 (default 6) |
Rounds per session |
{"jdtls": {"dependencyRounds": N}} in .java-functional-lsp.json |
0–default |
Lowers the rounds |
The remaining limits are fixed: 8 modules per round, a 5-minute import wall clock per session (counted only while imports run: from a round-1 import to its stop, excluding refresh time and the idle time between sessions of demand), 3 refreshes per round (a session cap of 3 × (rounds + 1) × 2, i.e. 42 with the default 6 rounds, so the rounds limit fires first), a 20 s refresh timeout with one retry, a 90 s build-idle wait per attempt, and up to 6 attempts (10 minutes total) before a busy jdtls gives up on a round.
Stop reasons (jdtls: dependency-module import stopped (<reason>)):
| Reason | Meaning |
|---|---|
clean |
The refreshed demand files have no #110-class errors left |
no-candidates(none) |
Nothing in the frontier: the errors are not caused by an unimported in-repo module |
no-candidates(markers-pending) |
Frontier modules exist, but m2e has not reported them missing yet (30 s wait) |
no-candidates(owner-has-jar) |
Frontier modules exist, m2e reports none of them missing: a jar in your local repository resolves them (see stale installs below) |
budget / rounds / wall-clock / refresh-cap |
A session limit was reached; later demand stops at once |
jdtls-busy |
jdtls did not become build-idle within 90 s, retried for up to 10 minutes (before the first refresh, or after an import) |
no-fresh-diagnostics |
jdtls did not answer the refresh, or did not re-publish the file, after one retry |
Limits:
- Stale local installs are not detected. If an old build of the sibling is installed at the same version in your local repository, m2e resolves the dependency from that jar and reports nothing. You then see the installed version's API, not the source, and the import stops with
no-candidates(owner-has-jar). Runmvn installfor that module again, or delete it from the local repository (~/.m2/repository/<group path>/<artifactId>/<version>). - Maven only. Gradle projects are unchanged.
- Index bounds. The index skips poms larger than 1 MB, poms with
<!DOCTYPE/<!ENTITYdeclarations (in any encoding), symlinked poms, modules outside the reactor root, coordinates with characters other than letters, digits,_,.and-(such as${revision};${project.groupId}and${project.parent.groupId}in a dependency are resolved), and anygroupId:artifactIddeclared by two directories. It reads at most 5000 poms, for at most 10 s. - Not persisted. jdtls 1.61 does not keep folders added this way across a restart, so each session starts from zero. The server keeps its own record in
~/.cache/jdtls-data/<hash>.deps.json, and the budget counts only this session's imports.
Troubleshooting (the server log is its stderr, as captured by your LSP client):
jdtls: pom.xml errors changed for <pom>: … Missing artifact g:a:…means m2e could not resolve that dependency. It is logged at INFO for modules with an open file, and at DEBUG for imported dependency modules.jdtls: reactor index of <root>: N modules from M pomsmeans the index was built.(truncated)means it hit a bound.jdtls: dependency round k: frontier F, candidates C, deferred D, round took Ts, refresh clean|errors|failed, …; importing K dependency module(s) (used/budget): g:a (path), …is logged once per round.jdtls: dependency-module import stopped (<reason>): …gives the reason (table above). A session summary follows when jdtls stops.- If the errors stay and nothing was imported, the artifact is not a module of this reactor (check the
<modules>path that should reach it), or it is a real external artifact missing from your repository. - m2e caches failed lookups in
*.lastUpdatedfiles in the local repository. The jdtls workspace of a module lives in~/.cache/jdtls-data/<hash>/, where<hash>is the directory named in thejdtls subprocess started (pid=…, data=…)log line. If errors persist after the module is imported, stop the editor, delete that directory (and its<hash>.deps.json), and reopen. v0.14.1 changed the workspace key, so workspaces bloated by v0.14.0 are not reused, and the LRU eviction removes them.
Fresh jdtls diagnostics after edits
jdtls re-validates a file 0.4–2.4s after the last edit to any file, and its results carry no document version. So after an edit, java-functional-lsp holds back jdtls diagnostics for that file until jdtls publishes again, rather than re-sending the previous edit's results as if they were current (#109):
| Mode | Default for | After an edit |
|---|---|---|
custom-first |
Claude Code | Custom diagnostics publish at once, without jdtls errors; the full set follows when jdtls has re-validated |
hold-all |
Other editors | Nothing is published for the file until jdtls has re-validated (no flickering squiggles) |
off |
— | Previous behavior: custom diagnostics plus the last known jdtls diagnostics after 150ms |
With Claude Code two more things happen:
- The plugin's PostToolUse hook waits for fresh results. Claude Code attaches diagnostics as soon as its PostToolUse hooks finish, without waiting for the server (anthropics/claude-code#93321). The server records when it last published final diagnostics for each file, and
hooks/post_tool_lint.pywaits until that is newer than the edit (at mostJAVA_FUNCTIONAL_LSP_HOOK_WAITseconds, default 3, max 4), so jdtls's result for the edit arrives in the same tool result. The markers live in a private per-user temp directory and contain only a timestamp. - jdtls gets a didSave after each edit. Claude Code writes the file itself; when the edited buffer matches the file on disk, the server forwards a didSave. Without it, jdtls can re-check dependent files against the old version of the edited one (e.g. a caller keeps reporting a constructor's old arity) and never correct them.
If jdtls doesn't publish in time (3s after the last edit, stretched when jdtls is slow, 10s at most), the last known jdtls diagnostics are used. Files whose module is still being imported, and files matched by java.diagnostic.filter, are never held. While you edit the same file in quick succession, its diagnostics update only once the burst settles.
Environment variables:
| Variable | Values | Effect |
|---|---|---|
JAVA_FUNCTIONAL_LSP_DIAG_HOLD |
custom-first, hold-all, off |
Overrides the mode above; off restores the previous behavior |
JAVA_FUNCTIONAL_LSP_HOOK_WAIT |
seconds, 0–4 (default 3) |
How long the Claude Code PostToolUse hook waits for fresh jdtls diagnostics; 0 disables the wait |
JAVA_FUNCTIONAL_LSP_LOG_LEVEL |
DEBUG, INFO (default), WARNING |
Verbosity of java-functional-lsp's own logs; DEBUG adds one line per publish decision (file name, trigger, counts). Library logging (pygls) is unaffected — note that pygls already logs the JSON it sends, including diagnostic messages, at INFO |
The log reports jdtls freshness: released=… too_early=… timeout=… late_correction=… every 100 decisions and when jdtls stops. Repeated timeout lines mean jdtls is slow or stuck on that module. A growing late_correction count means jdtls results were released too early.
Suppressing jdtls diagnostics
For project-specific jdtls false positives (e.g., annotation processor methods, MapStruct mappers), use suppressJdtlsPatterns to add custom regex filters:
{
"suppressJdtlsPatterns": [
"The method \\w+Mapper\\(\\) is undefined",
"cannot be resolved to a type"
]
}
Each entry is a regex matched against jdtls diagnostic messages. Invalid patterns are skipped with a warning.
Code actions (quick fixes)
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. |
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. This quick fix is currently @Value-only regardless of sourceLevel — the diagnostic message/snippet recommend a record at sourceLevel 16+, but applying that rewrite safely (enumerating fields into record components) isn't automated yet; use the suggested snippet as a manual starting point. |
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)
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.",
"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. 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:
{
"autoImportVavr": true,
"strictPurity": true
}
| 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 |
sourceLevel (alias javaVersion) |
8 |
Java source level; mutable-dto recommends record over @Value at 16+ |
Note: The machine-readable
datapayload is always included in diagnostics when available — no configuration needed.
How it works
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.
Development
# 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 pytest
Git 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.
License
MIT
Metadata
Release files for java-functional-lsp 0.14.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| java_functional_lsp-0.14.2.tar.gz | 352.2 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| java_functional_lsp-0.14.2-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 484.6 kB
Release files / java_functional_lsp-0.14.2.tar.gz
| Download URL | java_functional_lsp-0.14.2.tar.gz |
|---|---|
| Size | 352.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
ecb2caa4755d5b2e3584ac8962749285c2601b9e4408b2e249d7082f72a4803b
|
|
BLAKE2b-256 checksum How to use checksums |
5fa228c54a6ca4c5b284e03765e19170945fa1bc021f69d9b155aa2abe18f2a7
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 30, 2026.
Transparency logRelease files / java_functional_lsp-0.14.2-py3-none-any.whl
| Download URL | java_functional_lsp-0.14.2-py3-none-any.whl |
|---|---|
| Size | 132.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
1dae47d967e6ceb4381d078abc5e3c72b49ba7389a89bd69f97599486476acb1
|
|
BLAKE2b-256 checksum How to use checksums |
645ec74d14c2ba8592d4389748300eb92359d551c0fbc7e36ec5ea75a5bf53bc
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 30, 2026.
Transparency logSecurity for java-functional-lsp 0.14.2
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for java_functional_lsp-0.14.2.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 30, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.