java-functional-lsp 0.12.0__tar.gz → 0.13.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/.claude-plugin/plugin.json +1 -1
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/PKG-INFO +29 -1
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/README.md +28 -0
- java_functional_lsp-0.13.0/hooks/post_tool_lint.py +184 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/pyproject.toml +3 -1
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/src/java_functional_lsp/__init__.py +1 -1
- java_functional_lsp-0.13.0/src/java_functional_lsp/freshness_marker.py +78 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/src/java_functional_lsp/proxy.py +47 -6
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/src/java_functional_lsp/server.py +424 -30
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/conftest.py +16 -0
- java_functional_lsp-0.13.0/tests/test_diagnostics_freshness.py +785 -0
- java_functional_lsp-0.13.0/tests/test_e2e_cross_file.py +170 -0
- java_functional_lsp-0.13.0/tests/test_e2e_hold.py +294 -0
- java_functional_lsp-0.13.0/tests/test_freshness_marker.py +243 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_server.py +2 -2
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/uv.lock +1 -1
- java_functional_lsp-0.12.0/hooks/post_tool_lint.py +0 -89
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/.githooks/pre-commit +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/.githooks/pre-push +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/.github/CODEOWNERS +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/.github/ISSUE_TEMPLATE/bug-report.md +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/.github/ISSUE_TEMPLATE/feature-request.md +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/.github/SECURITY.md +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/.github/dependabot.yml +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/.github/release-drafter.yml +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/.github/workflows/publish.yml +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/.github/workflows/release-drafter.yml +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/.github/workflows/stale.yml +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/.github/workflows/test.yml +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/.github/workflows/update-homebrew.yml +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/.github/workflows/vscode-ext.yml +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/.gitignore +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/CONTRIBUTING.md +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/LICENSE +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/SKILL.md +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/commands/lint-java.md +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/editors/intellij/README.md +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/editors/intellij/lsp4ij-template.json +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/editors/vscode/.vscodeignore +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/editors/vscode/README.md +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/editors/vscode/package-lock.json +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/editors/vscode/package.json +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/editors/vscode/src/extension.ts +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/editors/vscode/tsconfig.json +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/hooks/hooks.json +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/hooks/java_linter_reminder.py +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/scripts/ensure-lsp.sh +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/scripts/generate-formula.py +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/src/java_functional_lsp/__main__.py +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/src/java_functional_lsp/analyzers/__init__.py +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/src/java_functional_lsp/analyzers/base.py +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/src/java_functional_lsp/analyzers/exception_checker.py +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/src/java_functional_lsp/analyzers/functional_checker.py +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/src/java_functional_lsp/analyzers/mutation_checker.py +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/src/java_functional_lsp/analyzers/null_checker.py +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/src/java_functional_lsp/analyzers/spring_checker.py +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/src/java_functional_lsp/capabilities/__init__.py +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/src/java_functional_lsp/capabilities/handler_wiring.py +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/src/java_functional_lsp/capabilities/negotiator.py +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/src/java_functional_lsp/capabilities/probe.py +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/src/java_functional_lsp/capabilities/registry.py +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/src/java_functional_lsp/capabilities/static_builder.py +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/src/java_functional_lsp/cli.py +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/src/java_functional_lsp/fixes.py +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/src/java_functional_lsp/merkle.py +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/__init__.py +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_base.py +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_capabilities/__init__.py +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_capabilities/test_handler_wiring.py +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_capabilities/test_negotiator.py +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_capabilities/test_probe.py +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_capabilities/test_registry.py +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_capabilities/test_static_builder.py +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_cli.py +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_config.py +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_e2e.py +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_e2e_jdtls.py +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_exception_checker.py +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_fixes.py +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_functional_checker.py +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_hooks.py +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_merkle.py +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_merkle_proxy.py +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_mutation_checker.py +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_null_checker.py +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_plugin_manifest.py +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_proxy.py +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_spring_checker.py +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_suppress.py +0 -0
- {java_functional_lsp-0.12.0 → java_functional_lsp-0.13.0}/tests/test_version.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "java-functional-lsp",
|
|
3
3
|
"description": "Java LSP with functional programming rules enforcement — null safety, immutability, no exceptions, Spring best practices. Wraps jdtls for full Java language support.",
|
|
4
|
-
"version": "0.4.
|
|
4
|
+
"version": "0.4.4",
|
|
5
5
|
"lspServers": {
|
|
6
6
|
"java-functional": {
|
|
7
7
|
"command": "java-functional-lsp",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: java-functional-lsp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.13.0
|
|
4
4
|
Summary: Java LSP server enforcing functional programming best practices — null safety, immutability, no exceptions
|
|
5
5
|
Project-URL: Homepage, https://github.com/aviadshiber/java-functional-lsp
|
|
6
6
|
Project-URL: Repository, https://github.com/aviadshiber/java-functional-lsp
|
|
@@ -229,6 +229,7 @@ moving on. Fix any type errors or missing imports immediately.
|
|
|
229
229
|
| "java-functional-lsp not found" | Run `brew install aviadshiber/tap/java-functional-lsp` |
|
|
230
230
|
| Plugin not active | Run `claude plugin list` to verify, then `/reload-plugins` |
|
|
231
231
|
| Diagnostics slow on first open | Normal — tree-sitter parses on first load, then incremental |
|
|
232
|
+
| 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](https://github.com/anthropics/claude-code/issues/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](#fresh-jdtls-diagnostics-after-edits) |
|
|
232
233
|
|
|
233
234
|
### Other Editors
|
|
234
235
|
|
|
@@ -334,6 +335,33 @@ The jdtls Eclipse workspace index is cached in `~/.cache/jdtls-data/`. Warm star
|
|
|
334
335
|
|
|
335
336
|
To force a clean rebuild: `rm -rf ~/.cache/jdtls-data/`
|
|
336
337
|
|
|
338
|
+
### Fresh jdtls diagnostics after edits
|
|
339
|
+
|
|
340
|
+
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](https://github.com/aviadshiber/java-functional-lsp/issues/109)):
|
|
341
|
+
|
|
342
|
+
| Mode | Default for | After an edit |
|
|
343
|
+
|------|-------------|---------------|
|
|
344
|
+
| `custom-first` | Claude Code | Custom diagnostics publish at once, without jdtls errors; the full set follows when jdtls has re-validated |
|
|
345
|
+
| `hold-all` | Other editors | Nothing is published for the file until jdtls has re-validated (no flickering squiggles) |
|
|
346
|
+
| `off` | — | Previous behavior: custom diagnostics plus the last known jdtls diagnostics after 150ms |
|
|
347
|
+
|
|
348
|
+
With Claude Code two more things happen:
|
|
349
|
+
|
|
350
|
+
- **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](https://github.com/anthropics/claude-code/issues/93321)). The server records when it last published final diagnostics for each file, and `hooks/post_tool_lint.py` waits until that is newer than the edit (at most `JAVA_FUNCTIONAL_LSP_HOOK_WAIT` seconds, 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.
|
|
351
|
+
- **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.
|
|
352
|
+
|
|
353
|
+
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.
|
|
354
|
+
|
|
355
|
+
Environment variables:
|
|
356
|
+
|
|
357
|
+
| Variable | Values | Effect |
|
|
358
|
+
|----------|--------|--------|
|
|
359
|
+
| `JAVA_FUNCTIONAL_LSP_DIAG_HOLD` | `custom-first`, `hold-all`, `off` | Overrides the mode above; `off` restores the previous behavior |
|
|
360
|
+
| `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 |
|
|
361
|
+
| `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` |
|
|
362
|
+
|
|
363
|
+
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.
|
|
364
|
+
|
|
337
365
|
### Suppressing jdtls diagnostics
|
|
338
366
|
|
|
339
367
|
For project-specific jdtls false positives (e.g., annotation processor methods, MapStruct mappers), use `suppressJdtlsPatterns` to add custom regex filters:
|
|
@@ -201,6 +201,7 @@ moving on. Fix any type errors or missing imports immediately.
|
|
|
201
201
|
| "java-functional-lsp not found" | Run `brew install aviadshiber/tap/java-functional-lsp` |
|
|
202
202
|
| Plugin not active | Run `claude plugin list` to verify, then `/reload-plugins` |
|
|
203
203
|
| Diagnostics slow on first open | Normal — tree-sitter parses on first load, then incremental |
|
|
204
|
+
| 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](https://github.com/anthropics/claude-code/issues/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](#fresh-jdtls-diagnostics-after-edits) |
|
|
204
205
|
|
|
205
206
|
### Other Editors
|
|
206
207
|
|
|
@@ -306,6 +307,33 @@ The jdtls Eclipse workspace index is cached in `~/.cache/jdtls-data/`. Warm star
|
|
|
306
307
|
|
|
307
308
|
To force a clean rebuild: `rm -rf ~/.cache/jdtls-data/`
|
|
308
309
|
|
|
310
|
+
### Fresh jdtls diagnostics after edits
|
|
311
|
+
|
|
312
|
+
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](https://github.com/aviadshiber/java-functional-lsp/issues/109)):
|
|
313
|
+
|
|
314
|
+
| Mode | Default for | After an edit |
|
|
315
|
+
|------|-------------|---------------|
|
|
316
|
+
| `custom-first` | Claude Code | Custom diagnostics publish at once, without jdtls errors; the full set follows when jdtls has re-validated |
|
|
317
|
+
| `hold-all` | Other editors | Nothing is published for the file until jdtls has re-validated (no flickering squiggles) |
|
|
318
|
+
| `off` | — | Previous behavior: custom diagnostics plus the last known jdtls diagnostics after 150ms |
|
|
319
|
+
|
|
320
|
+
With Claude Code two more things happen:
|
|
321
|
+
|
|
322
|
+
- **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](https://github.com/anthropics/claude-code/issues/93321)). The server records when it last published final diagnostics for each file, and `hooks/post_tool_lint.py` waits until that is newer than the edit (at most `JAVA_FUNCTIONAL_LSP_HOOK_WAIT` seconds, 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.
|
|
323
|
+
- **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.
|
|
324
|
+
|
|
325
|
+
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.
|
|
326
|
+
|
|
327
|
+
Environment variables:
|
|
328
|
+
|
|
329
|
+
| Variable | Values | Effect |
|
|
330
|
+
|----------|--------|--------|
|
|
331
|
+
| `JAVA_FUNCTIONAL_LSP_DIAG_HOLD` | `custom-first`, `hold-all`, `off` | Overrides the mode above; `off` restores the previous behavior |
|
|
332
|
+
| `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 |
|
|
333
|
+
| `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` |
|
|
334
|
+
|
|
335
|
+
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.
|
|
336
|
+
|
|
309
337
|
### Suppressing jdtls diagnostics
|
|
310
338
|
|
|
311
339
|
For project-specific jdtls false positives (e.g., annotation processor methods, MapStruct mappers), use `suppressJdtlsPatterns` to add custom regex filters:
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PostToolUse hook: lint a .java file after Edit/Write and surface violations to Claude.
|
|
3
|
+
|
|
4
|
+
Reads the Claude Code PostToolUse JSON payload on stdin, runs java-functional-lsp
|
|
5
|
+
on the edited file, and emits diagnostics as ``hookSpecificOutput.additionalContext``
|
|
6
|
+
so the agent sees them in context and can fix them immediately (issue #70). Before
|
|
7
|
+
returning it waits (bounded) for the running language server to publish fresh jdtls
|
|
8
|
+
diagnostics for the file, so Claude Code attaches current ones to this edit (#109).
|
|
9
|
+
|
|
10
|
+
Failure-safe by design: every path exits 0 — a linter problem must never break the
|
|
11
|
+
editing session. Prefers the fast in-process import; falls back to the CLI when the
|
|
12
|
+
package is not importable under this interpreter.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import getpass
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import signal
|
|
22
|
+
import stat
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
import tempfile
|
|
26
|
+
import time
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
TIMEOUT_SECONDS = 5 # hard cap; single-file analysis is typically <200ms
|
|
30
|
+
MAX_DIAGNOSTICS = 25 # keep additionalContext bounded
|
|
31
|
+
POLL_SECONDS = 0.05
|
|
32
|
+
DEFAULT_WAIT_SECONDS = 3.0
|
|
33
|
+
MAX_WAIT_SECONDS = 4.0
|
|
34
|
+
EXIT_MARGIN_SECONDS = 0.5 # finish well before SIGALRM so the lint output is printed
|
|
35
|
+
MARKER_MAX_BYTES = 128
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _fresh_wait_seconds() -> float:
|
|
39
|
+
try:
|
|
40
|
+
wait = float(os.environ.get("JAVA_FUNCTIONAL_LSP_HOOK_WAIT", DEFAULT_WAIT_SECONDS))
|
|
41
|
+
except ValueError:
|
|
42
|
+
return DEFAULT_WAIT_SECONDS
|
|
43
|
+
return max(0.0, min(wait, MAX_WAIT_SECONDS))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _user() -> str:
|
|
47
|
+
try:
|
|
48
|
+
return getpass.getuser()
|
|
49
|
+
except Exception:
|
|
50
|
+
return str(os.getuid()) if hasattr(os, "getuid") else "user"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _marker_path(file_path: str) -> Path:
|
|
54
|
+
"""Must match java_functional_lsp.freshness_marker.marker_path (a test checks)."""
|
|
55
|
+
digest = hashlib.sha256(os.path.realpath(file_path).encode()).hexdigest()[:32]
|
|
56
|
+
return Path(tempfile.gettempdir()) / f"java-functional-lsp-{_user()}" / "fresh" / digest
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _owned(st: os.stat_result) -> bool:
|
|
60
|
+
return not hasattr(os, "getuid") or st.st_uid == os.getuid()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _read_marker(marker: Path) -> tuple[int, str] | None:
|
|
64
|
+
"""(server pid, content digest), or None unless the marker and its dirs are private and ours."""
|
|
65
|
+
try:
|
|
66
|
+
for level in (marker.parent.parent, marker.parent):
|
|
67
|
+
st = os.lstat(level)
|
|
68
|
+
if not stat.S_ISDIR(st.st_mode) or not _owned(st) or st.st_mode & 0o077:
|
|
69
|
+
return None
|
|
70
|
+
fd = os.open(marker, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0))
|
|
71
|
+
try:
|
|
72
|
+
st = os.fstat(fd)
|
|
73
|
+
if not stat.S_ISREG(st.st_mode) or not _owned(st):
|
|
74
|
+
return None
|
|
75
|
+
pid, digest = os.read(fd, MARKER_MAX_BYTES).decode().split()
|
|
76
|
+
finally:
|
|
77
|
+
os.close(fd)
|
|
78
|
+
server_pid = int(pid)
|
|
79
|
+
return (server_pid, digest) if server_pid > 0 else None # kill(0|-n, 0) targets groups
|
|
80
|
+
except (OSError, ValueError):
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _server_alive(pid: int) -> bool:
|
|
85
|
+
try:
|
|
86
|
+
os.kill(pid, 0)
|
|
87
|
+
except PermissionError:
|
|
88
|
+
return True
|
|
89
|
+
except OSError:
|
|
90
|
+
return False
|
|
91
|
+
return True
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _wait_for_fresh_diagnostics(path: Path, budget: float) -> None:
|
|
95
|
+
"""Wait until the language server has published final diagnostics for this edit.
|
|
96
|
+
|
|
97
|
+
Claude Code attaches LSP diagnostics right after PostToolUse hooks finish, but jdtls
|
|
98
|
+
needs 0.4-2.4s to re-validate (#109). On each final publish the server records its pid
|
|
99
|
+
and a hash of the content it analyzed; waiting until that hash matches the file puts
|
|
100
|
+
fresh results in this tool result. No marker, or a dead server, means nobody will
|
|
101
|
+
publish — don't wait.
|
|
102
|
+
"""
|
|
103
|
+
marker = _marker_path(str(path))
|
|
104
|
+
current = _read_marker(marker)
|
|
105
|
+
if current is None or not _server_alive(current[0]):
|
|
106
|
+
return
|
|
107
|
+
want = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
108
|
+
deadline = time.monotonic() + min(_fresh_wait_seconds(), budget)
|
|
109
|
+
while current is not None and current[1] != want and time.monotonic() < deadline:
|
|
110
|
+
time.sleep(POLL_SECONDS)
|
|
111
|
+
current = _read_marker(marker)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _lint_in_process(path: Path) -> list[str] | None:
|
|
115
|
+
"""Lint via direct import. Returns None if the package isn't importable here."""
|
|
116
|
+
try:
|
|
117
|
+
from java_functional_lsp.analyzers.base import is_excluded
|
|
118
|
+
from java_functional_lsp.cli import check_file, format_diagnostic, load_config
|
|
119
|
+
except ImportError:
|
|
120
|
+
return None
|
|
121
|
+
config = load_config(path)
|
|
122
|
+
if is_excluded(path.as_posix(), config.get("excludes", [])):
|
|
123
|
+
return []
|
|
124
|
+
return [format_diagnostic(path, d) for d in check_file(path, config)]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _lint_via_cli(path: Path) -> list[str]:
|
|
128
|
+
"""Fallback: shell out to the installed CLI (exit 1 + stdout lines on violations)."""
|
|
129
|
+
proc = subprocess.run(
|
|
130
|
+
["java-functional-lsp", "check", str(path)],
|
|
131
|
+
capture_output=True,
|
|
132
|
+
text=True,
|
|
133
|
+
timeout=TIMEOUT_SECONDS,
|
|
134
|
+
check=False, # exit 1 just means violations were found
|
|
135
|
+
)
|
|
136
|
+
return [ln for ln in proc.stdout.splitlines() if ln.strip()]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def main() -> None:
|
|
140
|
+
started = time.monotonic()
|
|
141
|
+
hook_input = json.load(sys.stdin)
|
|
142
|
+
file_path = (hook_input.get("tool_input") or {}).get("file_path", "")
|
|
143
|
+
if not file_path.endswith(".java"):
|
|
144
|
+
return # silent no-op
|
|
145
|
+
path = Path(file_path)
|
|
146
|
+
if not path.is_file():
|
|
147
|
+
return # tool call may have failed or the file was deleted
|
|
148
|
+
|
|
149
|
+
lines = _lint_in_process(path)
|
|
150
|
+
if lines is None:
|
|
151
|
+
lines = _lint_via_cli(path)
|
|
152
|
+
try:
|
|
153
|
+
budget = TIMEOUT_SECONDS - EXIT_MARGIN_SECONDS - (time.monotonic() - started)
|
|
154
|
+
_wait_for_fresh_diagnostics(path, budget=budget)
|
|
155
|
+
except Exception:
|
|
156
|
+
pass # a marker problem must never suppress the lint output below
|
|
157
|
+
if not lines:
|
|
158
|
+
return # clean file: stay silent, no per-edit context noise
|
|
159
|
+
|
|
160
|
+
if len(lines) > MAX_DIAGNOSTICS:
|
|
161
|
+
lines = [*lines[:MAX_DIAGNOSTICS], f"... and {len(lines) - MAX_DIAGNOSTICS} more"]
|
|
162
|
+
json.dump(
|
|
163
|
+
{
|
|
164
|
+
"hookSpecificOutput": {
|
|
165
|
+
"hookEventName": "PostToolUse",
|
|
166
|
+
"additionalContext": (
|
|
167
|
+
"java-functional-lsp found violations in the file you just edited:\n"
|
|
168
|
+
+ "\n".join(lines)
|
|
169
|
+
+ "\nFix each violation now with your next Edit. Do not explain or list them."
|
|
170
|
+
),
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
sys.stdout,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
if __name__ == "__main__":
|
|
178
|
+
if hasattr(signal, "SIGALRM"): # POSIX hard runtime cap
|
|
179
|
+
signal.signal(signal.SIGALRM, lambda *_: sys.exit(0))
|
|
180
|
+
signal.alarm(TIMEOUT_SECONDS)
|
|
181
|
+
try:
|
|
182
|
+
main()
|
|
183
|
+
except Exception:
|
|
184
|
+
sys.exit(0) # hooks must never break the session
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "java-functional-lsp"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.13.0"
|
|
8
8
|
description = "Java LSP server enforcing functional programming best practices — null safety, immutability, no exceptions"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -65,6 +65,8 @@ python_classes = ["Test*"]
|
|
|
65
65
|
python_functions = ["test_*"]
|
|
66
66
|
addopts = "--cov=java_functional_lsp --cov-report=term-missing --cov-fail-under=80"
|
|
67
67
|
asyncio_mode = "auto"
|
|
68
|
+
# Safety net so a hung await fails the test instead of the CI job; e2e tests set their own.
|
|
69
|
+
timeout = 120
|
|
68
70
|
markers = [
|
|
69
71
|
"e2e: end-to-end tests that spawn a real jdtls subprocess (require jdtls + Java 21+; skipped when unavailable)",
|
|
70
72
|
]
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Per-file "diagnostics are final" markers for the Claude Code PostToolUse hook (#109).
|
|
2
|
+
|
|
3
|
+
Claude Code collects LSP diagnostics right after its PostToolUse hooks finish, without
|
|
4
|
+
waiting for the server. When the server publishes final diagnostics for a file (fresh
|
|
5
|
+
jdtls results merged in) it records the server pid and a hash of the content it analyzed;
|
|
6
|
+
``hooks/post_tool_lint.py`` waits until the hash matches the file on disk, so the fresh
|
|
7
|
+
set lands in the same tool result. A hash (not a time) means a publish for an older
|
|
8
|
+
version of the file can never satisfy the hook, and the pid lets the hook skip waiting
|
|
9
|
+
when the server that wrote the marker is gone.
|
|
10
|
+
|
|
11
|
+
The hook keeps its own copy of :func:`marker_path` and of the directory checks (it may run
|
|
12
|
+
without this package importable); tests keep the two in sync.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import getpass
|
|
18
|
+
import hashlib
|
|
19
|
+
import os
|
|
20
|
+
import stat
|
|
21
|
+
import tempfile
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
#: Written at didOpen: never equals a content hash, so the hook waits for jdtls's first result.
|
|
25
|
+
OPENING = "opening"
|
|
26
|
+
|
|
27
|
+
_validated: set[Path] = set()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _user() -> str:
|
|
31
|
+
try:
|
|
32
|
+
return getpass.getuser()
|
|
33
|
+
except Exception: # containers without a passwd entry: KeyError (<3.13) / OSError (3.13+)
|
|
34
|
+
return str(os.getuid()) if hasattr(os, "getuid") else "user"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def marker_dir() -> Path:
|
|
38
|
+
return Path(tempfile.gettempdir()) / f"java-functional-lsp-{_user()}" / "fresh"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def marker_path(file_path: str) -> Path:
|
|
42
|
+
digest = hashlib.sha256(os.path.realpath(file_path).encode()).hexdigest()[:32]
|
|
43
|
+
return marker_dir() / digest
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def content_digest(content: bytes) -> str:
|
|
47
|
+
return hashlib.sha256(content).hexdigest()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _ensure_private_dir(path: Path) -> bool:
|
|
51
|
+
"""Create *path* if needed; True only for a real directory we own with no group/other access."""
|
|
52
|
+
path.mkdir(mode=0o700, exist_ok=True)
|
|
53
|
+
st = os.lstat(path) # lstat: a symlink is never S_ISDIR
|
|
54
|
+
if not stat.S_ISDIR(st.st_mode) or (hasattr(os, "getuid") and st.st_uid != os.getuid()):
|
|
55
|
+
return False
|
|
56
|
+
if st.st_mode & 0o077:
|
|
57
|
+
os.chmod(path, 0o700)
|
|
58
|
+
return True
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def write_marker(file_path: str, digest: str) -> None:
|
|
62
|
+
"""Record that final diagnostics for *file_path* were published for content *digest*. Never raises."""
|
|
63
|
+
try:
|
|
64
|
+
directory = marker_dir()
|
|
65
|
+
if directory not in _validated:
|
|
66
|
+
# Check each level before creating below it, so nothing lands behind a
|
|
67
|
+
# symlink planted in a shared temp dir.
|
|
68
|
+
if not (_ensure_private_dir(directory.parent) and _ensure_private_dir(directory)):
|
|
69
|
+
return
|
|
70
|
+
_validated.add(directory)
|
|
71
|
+
target = marker_path(file_path)
|
|
72
|
+
tmp = target.with_name(f"{target.name}.{os.getpid()}.tmp")
|
|
73
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0)
|
|
74
|
+
with os.fdopen(os.open(tmp, flags, 0o600), "w") as f:
|
|
75
|
+
f.write(f"{os.getpid()} {digest}")
|
|
76
|
+
os.replace(tmp, target)
|
|
77
|
+
except Exception:
|
|
78
|
+
_validated.clear()
|
|
@@ -57,6 +57,9 @@ _DEFAULT_JDTLS_SETTINGS: dict[str, Any] = {
|
|
|
57
57
|
#: Java 21"`` before the server even starts.
|
|
58
58
|
_MIN_JDTLS_JAVA_MAJOR = 21
|
|
59
59
|
|
|
60
|
+
#: Distinct jdtls→client request methods tracked individually; the rest count as "<other>".
|
|
61
|
+
_MAX_DROPPED_METHODS = 50
|
|
62
|
+
|
|
60
63
|
#: Matches the first version token in ``java -version`` output. Handles:
|
|
61
64
|
#: - modern format: ``openjdk version "21.0.10" 2026-01-20``
|
|
62
65
|
#: - legacy Java 8: ``openjdk version "1.8.0_452"`` (captures ``1``; caller
|
|
@@ -788,14 +791,24 @@ def _build_effective_params(
|
|
|
788
791
|
class JdtlsProxy:
|
|
789
792
|
"""Manages a jdtls subprocess and provides async request/notification forwarding."""
|
|
790
793
|
|
|
791
|
-
def __init__(
|
|
794
|
+
def __init__(
|
|
795
|
+
self,
|
|
796
|
+
on_diagnostics: Callable[[str, list[Any]], None] | None = None,
|
|
797
|
+
uri_key: Callable[[str], str] = lambda uri: uri,
|
|
798
|
+
on_stopped: Callable[[], None] | None = None,
|
|
799
|
+
) -> None:
|
|
792
800
|
self._process: asyncio.subprocess.Process | None = None
|
|
793
801
|
self._reader_task: asyncio.Task[None] | None = None
|
|
794
802
|
self._stderr_task: asyncio.Task[None] | None = None
|
|
795
803
|
self._next_id: int = 1
|
|
796
804
|
self._pending: dict[int, asyncio.Future[Any]] = {}
|
|
805
|
+
# Keyed by uri_key(uri): jdtls may echo a URI in a different encoding than
|
|
806
|
+
# the client sent, so lookups must go through the same canonical key.
|
|
797
807
|
self._diagnostics_cache: dict[str, list[Any]] = {}
|
|
808
|
+
self._uri_key = uri_key
|
|
798
809
|
self._on_diagnostics = on_diagnostics
|
|
810
|
+
self._on_stopped = on_stopped
|
|
811
|
+
self._dropped_request_counts: dict[str, int] = {}
|
|
799
812
|
self._available = False
|
|
800
813
|
self._jdtls_capabilities: dict[str, Any] = {}
|
|
801
814
|
# Lazy-start state
|
|
@@ -834,7 +847,19 @@ class JdtlsProxy:
|
|
|
834
847
|
|
|
835
848
|
def get_cached_diagnostics(self, uri: str) -> list[Any]:
|
|
836
849
|
"""Get the latest jdtls diagnostics for a URI."""
|
|
837
|
-
return list(self._diagnostics_cache.get(uri, []))
|
|
850
|
+
return list(self._diagnostics_cache.get(self._uri_key(uri), []))
|
|
851
|
+
|
|
852
|
+
def _mark_stopped(self) -> None:
|
|
853
|
+
"""Drop state that belongs to the dead jdtls process and notify the owner."""
|
|
854
|
+
if not self._available and not self._diagnostics_cache:
|
|
855
|
+
return # already handled (reader EOF, then stop())
|
|
856
|
+
self._available = False
|
|
857
|
+
cached = len(self._diagnostics_cache)
|
|
858
|
+
self._diagnostics_cache.clear()
|
|
859
|
+
if cached:
|
|
860
|
+
logger.info("jdtls stopped: cleared cached diagnostics for %d files", cached)
|
|
861
|
+
if self._on_stopped:
|
|
862
|
+
self._on_stopped()
|
|
838
863
|
|
|
839
864
|
def check_available(self) -> bool:
|
|
840
865
|
"""Check if jdtls is on PATH (lightweight, no subprocess started)."""
|
|
@@ -929,6 +954,8 @@ class JdtlsProxy:
|
|
|
929
954
|
|
|
930
955
|
self._jdtls_capabilities = result.get("capabilities", {})
|
|
931
956
|
logger.info("jdtls initialized (capabilities: %s)", list(self._jdtls_capabilities.keys()))
|
|
957
|
+
server_info = result.get("serverInfo") or {}
|
|
958
|
+
logger.info("jdtls server: %s %s", server_info.get("name", "?"), server_info.get("version", "?"))
|
|
932
959
|
|
|
933
960
|
await self.send_notification("initialized", {})
|
|
934
961
|
self._available = True
|
|
@@ -1170,7 +1197,7 @@ class JdtlsProxy:
|
|
|
1170
1197
|
|
|
1171
1198
|
async def stop(self) -> None:
|
|
1172
1199
|
"""Shutdown jdtls subprocess gracefully."""
|
|
1173
|
-
self.
|
|
1200
|
+
self._mark_stopped()
|
|
1174
1201
|
|
|
1175
1202
|
if self._reader_task and not self._reader_task.done():
|
|
1176
1203
|
self._reader_task.cancel()
|
|
@@ -1258,7 +1285,7 @@ class JdtlsProxy:
|
|
|
1258
1285
|
msg = await read_message(reader)
|
|
1259
1286
|
if msg is None:
|
|
1260
1287
|
logger.warning("jdtls stdout closed — subprocess may have exited")
|
|
1261
|
-
self.
|
|
1288
|
+
self._mark_stopped()
|
|
1262
1289
|
break
|
|
1263
1290
|
|
|
1264
1291
|
self._dispatch_message(msg)
|
|
@@ -1266,7 +1293,7 @@ class JdtlsProxy:
|
|
|
1266
1293
|
pass
|
|
1267
1294
|
except Exception as e:
|
|
1268
1295
|
logger.error("jdtls reader loop error: %s", e)
|
|
1269
|
-
self.
|
|
1296
|
+
self._mark_stopped()
|
|
1270
1297
|
|
|
1271
1298
|
async def _stderr_reader(self, stderr: asyncio.StreamReader) -> None:
|
|
1272
1299
|
"""Background task: log jdtls stderr output for debugging.
|
|
@@ -1311,6 +1338,20 @@ class JdtlsProxy:
|
|
|
1311
1338
|
elif "method" in msg and "id" not in msg:
|
|
1312
1339
|
# Notification from jdtls
|
|
1313
1340
|
self._handle_notification(msg)
|
|
1341
|
+
elif "method" in msg:
|
|
1342
|
+
self._note_dropped_request(str(msg["method"]))
|
|
1343
|
+
|
|
1344
|
+
def _note_dropped_request(self, method: str) -> None:
|
|
1345
|
+
"""Count jdtls→client requests the proxy leaves unanswered (method names only, never params)."""
|
|
1346
|
+
method = method[:100]
|
|
1347
|
+
if method not in self._dropped_request_counts and len(self._dropped_request_counts) >= _MAX_DROPPED_METHODS:
|
|
1348
|
+
method = "<other>"
|
|
1349
|
+
count = self._dropped_request_counts.get(method, 0) + 1
|
|
1350
|
+
self._dropped_request_counts[method] = count
|
|
1351
|
+
if count == 1:
|
|
1352
|
+
logger.info("jdtls request %r dropped (no client-side handler)", method)
|
|
1353
|
+
else:
|
|
1354
|
+
logger.debug("jdtls request %r dropped (%d times)", method, count)
|
|
1314
1355
|
|
|
1315
1356
|
def _handle_notification(self, msg: dict[str, Any]) -> None:
|
|
1316
1357
|
"""Handle a notification from jdtls."""
|
|
@@ -1320,7 +1361,7 @@ class JdtlsProxy:
|
|
|
1320
1361
|
if method == "textDocument/publishDiagnostics":
|
|
1321
1362
|
uri = params.get("uri", "")
|
|
1322
1363
|
diagnostics = params.get("diagnostics", [])
|
|
1323
|
-
self._diagnostics_cache[uri] = diagnostics
|
|
1364
|
+
self._diagnostics_cache[self._uri_key(uri)] = diagnostics
|
|
1324
1365
|
if self._on_diagnostics:
|
|
1325
1366
|
self._on_diagnostics(uri, diagnostics)
|
|
1326
1367
|
# Other notifications (window/logMessage, etc.) are silently ignored
|