java-functional-lsp 0.7.9__tar.gz → 0.8.1__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.
Files changed (66) hide show
  1. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/PKG-INFO +20 -2
  2. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/README.md +19 -1
  3. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/editors/intellij/README.md +2 -1
  4. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/pyproject.toml +1 -1
  5. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/src/java_functional_lsp/__init__.py +1 -1
  6. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/src/java_functional_lsp/proxy.py +44 -16
  7. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/src/java_functional_lsp/server.py +121 -26
  8. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/tests/test_server.py +224 -33
  9. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/uv.lock +4 -4
  10. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/.claude-plugin/plugin.json +0 -0
  11. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/.githooks/pre-commit +0 -0
  12. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/.githooks/pre-push +0 -0
  13. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/.github/CODEOWNERS +0 -0
  14. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/.github/ISSUE_TEMPLATE/bug-report.md +0 -0
  15. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/.github/ISSUE_TEMPLATE/feature-request.md +0 -0
  16. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  17. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/.github/SECURITY.md +0 -0
  18. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/.github/dependabot.yml +0 -0
  19. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/.github/release-drafter.yml +0 -0
  20. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/.github/workflows/publish.yml +0 -0
  21. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/.github/workflows/release-drafter.yml +0 -0
  22. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/.github/workflows/stale.yml +0 -0
  23. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/.github/workflows/test.yml +0 -0
  24. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/.github/workflows/update-homebrew.yml +0 -0
  25. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/.github/workflows/vscode-ext.yml +0 -0
  26. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/.gitignore +0 -0
  27. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/CONTRIBUTING.md +0 -0
  28. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/LICENSE +0 -0
  29. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/SKILL.md +0 -0
  30. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/commands/lint-java.md +0 -0
  31. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/editors/intellij/lsp4ij-template.json +0 -0
  32. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/editors/vscode/.vscodeignore +0 -0
  33. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/editors/vscode/README.md +0 -0
  34. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/editors/vscode/package-lock.json +0 -0
  35. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/editors/vscode/package.json +0 -0
  36. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/editors/vscode/src/extension.ts +0 -0
  37. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/editors/vscode/tsconfig.json +0 -0
  38. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/hooks/hooks.json +0 -0
  39. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/hooks/java_linter_reminder.py +0 -0
  40. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/scripts/ensure-lsp.sh +0 -0
  41. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/scripts/generate-formula.py +0 -0
  42. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/src/java_functional_lsp/__main__.py +0 -0
  43. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/src/java_functional_lsp/analyzers/__init__.py +0 -0
  44. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/src/java_functional_lsp/analyzers/base.py +0 -0
  45. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/src/java_functional_lsp/analyzers/exception_checker.py +0 -0
  46. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/src/java_functional_lsp/analyzers/functional_checker.py +0 -0
  47. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/src/java_functional_lsp/analyzers/mutation_checker.py +0 -0
  48. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/src/java_functional_lsp/analyzers/null_checker.py +0 -0
  49. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/src/java_functional_lsp/analyzers/spring_checker.py +0 -0
  50. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/src/java_functional_lsp/cli.py +0 -0
  51. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/src/java_functional_lsp/fixes.py +0 -0
  52. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/tests/__init__.py +0 -0
  53. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/tests/conftest.py +0 -0
  54. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/tests/test_base.py +0 -0
  55. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/tests/test_cli.py +0 -0
  56. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/tests/test_config.py +0 -0
  57. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/tests/test_e2e.py +0 -0
  58. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/tests/test_e2e_jdtls.py +0 -0
  59. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/tests/test_exception_checker.py +0 -0
  60. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/tests/test_fixes.py +0 -0
  61. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/tests/test_functional_checker.py +0 -0
  62. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/tests/test_mutation_checker.py +0 -0
  63. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/tests/test_null_checker.py +0 -0
  64. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/tests/test_proxy.py +0 -0
  65. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/tests/test_spring_checker.py +0 -0
  66. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.1}/tests/test_suppress.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: java-functional-lsp
3
- Version: 0.7.9
3
+ Version: 0.8.1
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
@@ -128,6 +128,8 @@ Use the [LSP4IJ](https://github.com/redhat-developer/lsp4ij) plugin (works on Co
128
128
  2. **Settings** → **Languages & Frameworks** → **Language Servers** → **`+`**
129
129
  3. Set **Command**: `java-functional-lsp`, then in **Mappings** → **File name patterns** add `*.java` with Language Id `java`
130
130
 
131
+ 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.
132
+
131
133
  See [editors/intellij/README.md](editors/intellij/README.md) for detailed instructions.
132
134
 
133
135
  ### Claude Code
@@ -230,6 +232,7 @@ Create `.java-functional-lsp.json` in your project root to customize rules:
230
232
  **Options:**
231
233
  - `excludes` — glob patterns for files/directories to skip entirely (supports `**` for multi-segment wildcards)
232
234
  - `rules` — per-rule severity: `error`, `warning` (default), `info`, `hint`, `off`
235
+ - `suppressJdtlsPatterns` — list of regex patterns to suppress jdtls diagnostics (see below)
233
236
 
234
237
  **Spring-aware behavior:**
235
238
  - `throw-statement`, `catch-rethrow`, and `try-catch-to-monadic` are automatically suppressed inside `@Bean` methods
@@ -267,7 +270,22 @@ The server auto-discovers `lombok.jar` from these locations (first match wins):
267
270
  3. **Maven cache** — auto-discovered from `~/.m2/repository/org/projectlombok/lombok/`
268
271
  4. **Dedicated directory** — `~/.jdtls-libs/lombok.jar`
269
272
 
270
- If Lombok is used in your project but the jar isn't found, the server automatically filters common Lombok false-positive errors (like "builder() is undefined") so they don't clutter your diagnostics.
273
+ If Lombok is used in your project but the jar isn't found, the server logs a warning. The jdtls cache is automatically cleared on version upgrade, so adding Lombok support takes effect immediately after `brew upgrade`.
274
+
275
+ ### Suppressing jdtls diagnostics
276
+
277
+ For project-specific jdtls false positives (e.g., annotation processor methods, MapStruct mappers), use `suppressJdtlsPatterns` to add custom regex filters:
278
+
279
+ ```json
280
+ {
281
+ "suppressJdtlsPatterns": [
282
+ "The method \\w+Mapper\\(\\) is undefined",
283
+ "cannot be resolved to a type"
284
+ ]
285
+ }
286
+ ```
287
+
288
+ Each entry is a regex matched against jdtls diagnostic messages. Invalid patterns are skipped with a warning.
271
289
 
272
290
  ## Code actions (quick fixes)
273
291
 
@@ -100,6 +100,8 @@ Use the [LSP4IJ](https://github.com/redhat-developer/lsp4ij) plugin (works on Co
100
100
  2. **Settings** → **Languages & Frameworks** → **Language Servers** → **`+`**
101
101
  3. Set **Command**: `java-functional-lsp`, then in **Mappings** → **File name patterns** add `*.java` with Language Id `java`
102
102
 
103
+ 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.
104
+
103
105
  See [editors/intellij/README.md](editors/intellij/README.md) for detailed instructions.
104
106
 
105
107
  ### Claude Code
@@ -202,6 +204,7 @@ Create `.java-functional-lsp.json` in your project root to customize rules:
202
204
  **Options:**
203
205
  - `excludes` — glob patterns for files/directories to skip entirely (supports `**` for multi-segment wildcards)
204
206
  - `rules` — per-rule severity: `error`, `warning` (default), `info`, `hint`, `off`
207
+ - `suppressJdtlsPatterns` — list of regex patterns to suppress jdtls diagnostics (see below)
205
208
 
206
209
  **Spring-aware behavior:**
207
210
  - `throw-statement`, `catch-rethrow`, and `try-catch-to-monadic` are automatically suppressed inside `@Bean` methods
@@ -239,7 +242,22 @@ The server auto-discovers `lombok.jar` from these locations (first match wins):
239
242
  3. **Maven cache** — auto-discovered from `~/.m2/repository/org/projectlombok/lombok/`
240
243
  4. **Dedicated directory** — `~/.jdtls-libs/lombok.jar`
241
244
 
242
- If Lombok is used in your project but the jar isn't found, the server automatically filters common Lombok false-positive errors (like "builder() is undefined") so they don't clutter your diagnostics.
245
+ If Lombok is used in your project but the jar isn't found, the server logs a warning. The jdtls cache is automatically cleared on version upgrade, so adding Lombok support takes effect immediately after `brew upgrade`.
246
+
247
+ ### Suppressing jdtls diagnostics
248
+
249
+ For project-specific jdtls false positives (e.g., annotation processor methods, MapStruct mappers), use `suppressJdtlsPatterns` to add custom regex filters:
250
+
251
+ ```json
252
+ {
253
+ "suppressJdtlsPatterns": [
254
+ "The method \\w+Mapper\\(\\) is undefined",
255
+ "cannot be resolved to a type"
256
+ ]
257
+ }
258
+ ```
259
+
260
+ Each entry is a regex matched against jdtls diagnostic messages. Invalid patterns are skipped with a warning.
243
261
 
244
262
  ## Code actions (quick fixes)
245
263
 
@@ -73,7 +73,7 @@ Project-level rules are configured via `.java-functional-lsp.json` in your proje
73
73
 
74
74
  ## Coexistence with IntelliJ's Java Support
75
75
 
76
- LSP4IJ is designed to **supplement** IntelliJ's native Java support, not replace it. Your custom functional programming diagnostics appear alongside IntelliJ's built-in inspections. No conflicts.
76
+ 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.
77
77
 
78
78
  ## Troubleshooting
79
79
 
@@ -85,6 +85,7 @@ LSP4IJ is designed to **supplement** IntelliJ's native Java support, not replace
85
85
  - Ensure the file mapping is set to Language: `Java`, Language ID: `java`
86
86
  - Check that `.java-functional-lsp.json` doesn't have rules set to `"off"`
87
87
  - Try restarting the language server: **Tools** → **Language Servers** → **Restart**
88
+ - If diagnostics worked before but stopped, the jdtls proxy may be interfering. Set `JAVA_FUNCTIONAL_LSP_JDTLS=off` in the server command environment to force-disable jdtls. The server auto-detects JetBrains IDEs and disables jdtls by default (since IntelliJ provides native Java support), but this can be overridden with `JAVA_FUNCTIONAL_LSP_JDTLS=on`.
88
89
 
89
90
  ### PATH not found
90
91
  - IntelliJ may not inherit your shell's PATH. Use the full absolute path to the binary in the server command.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "java-functional-lsp"
7
- version = "0.7.9"
7
+ version = "0.8.1"
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" }
@@ -1,3 +1,3 @@
1
1
  """java-functional-lsp: A Java LSP server enforcing functional programming best practices."""
2
2
 
3
- __version__ = "0.7.9"
3
+ __version__ = "0.8.1"
@@ -444,6 +444,29 @@ def _version_key(name: str) -> tuple[int, ...]:
444
444
  return tuple(parts)
445
445
 
446
446
 
447
+ def _clear_cache_on_version_change(cache_root: Path) -> None:
448
+ """Clear jdtls data cache when the server version changes.
449
+
450
+ Stores a ``.version`` marker in the cache root. On version mismatch
451
+ (e.g., Lombok agent added, jdtls config changed), wipes the entire
452
+ cache so jdtls re-indexes from scratch with the current configuration.
453
+ """
454
+ from . import __version__
455
+
456
+ marker = cache_root / ".version"
457
+ try:
458
+ if marker.exists() and marker.read_text().strip() == __version__:
459
+ return
460
+ # Version changed or first run — clear stale caches.
461
+ if cache_root.exists():
462
+ shutil.rmtree(cache_root)
463
+ logger.info("jdtls: cleared stale cache (version changed to %s)", __version__)
464
+ cache_root.mkdir(parents=True, exist_ok=True)
465
+ marker.write_text(__version__)
466
+ except OSError as e:
467
+ logger.warning("jdtls: failed to manage cache version marker: %s", e)
468
+
469
+
447
470
  def _find_lombok_jar(config: Mapping[str, Any] | None = None) -> str | None:
448
471
  """Find lombok.jar from configurable and auto-discovered locations.
449
472
 
@@ -550,20 +573,35 @@ class JdtlsProxy:
550
573
  if not jdtls_path:
551
574
  return False
552
575
 
553
- # Data-dir hash: use module URI when scoped so each module gets its own
554
- # clean jdtls state. This avoids loading a 2.5GB monorepo index when only
555
- # one module is needed. Without expansion, each session is module-scoped,
556
- # so the data-dir should match that scope.
557
576
  original_root: str = init_params.get("rootUri") or init_params.get("rootPath") or str(Path.cwd())
558
577
  self._original_root_uri = original_root
578
+ effective_root_uri = module_root_uri or original_root
579
+
580
+ # Mark ADDED before any await to prevent duplicate module additions
581
+ # from concurrent coroutines during the executor yield below.
582
+ self._initial_module_uri = module_root_uri
583
+ self.modules.mark_added(effective_root_uri)
584
+
585
+ # All blocking startup I/O in a single executor call: cache version check
586
+ # (may rmtree on upgrade), Lombok discovery, and jdtls env build.
587
+ cache_root = Path.home() / ".cache" / "jdtls-data"
588
+
589
+ def _blocking_startup() -> tuple[dict[str, str], str | None]:
590
+ _clear_cache_on_version_change(cache_root)
591
+ return build_jdtls_env(), _find_lombok_jar(config)
592
+
593
+ loop = asyncio.get_running_loop()
594
+ jdtls_env, lombok_jar = await loop.run_in_executor(None, _blocking_startup)
595
+ if lombok_jar:
596
+ logger.info("jdtls: using Lombok agent from %s", _redact_path(lombok_jar))
597
+
559
598
  hash_source = module_root_uri or original_root
560
599
  data_hash = hashlib.sha256(hash_source.encode()).hexdigest()[:12]
561
- data_dir = Path.home() / ".cache" / "jdtls-data" / data_hash
600
+ data_dir = cache_root / data_hash
562
601
  data_dir.mkdir(parents=True, exist_ok=True)
563
602
 
564
603
  # Deep copy to avoid mutating server._init_params.
565
604
  effective_params = copy.deepcopy(init_params)
566
- effective_root_uri = module_root_uri or original_root
567
605
  if module_root_uri:
568
606
  effective_params["rootUri"] = module_root_uri
569
607
  from pygls.uris import to_fs_path
@@ -580,16 +618,6 @@ class JdtlsProxy:
580
618
  ws = caps.setdefault("workspace", {})
581
619
  ws["workspaceFolders"] = True
582
620
 
583
- # Track the initial module as already loaded (mark ADDED before await).
584
- self._initial_module_uri = module_root_uri
585
- self.modules.mark_added(effective_root_uri)
586
-
587
- # Build a clean environment and find Lombok jar (both do blocking I/O).
588
- loop = asyncio.get_running_loop()
589
- jdtls_env, lombok_jar = await loop.run_in_executor(None, lambda: (build_jdtls_env(), _find_lombok_jar(config)))
590
- if lombok_jar:
591
- logger.info("jdtls: using Lombok agent from %s", _redact_path(lombok_jar))
592
-
593
621
  jdtls_cmd: list[str] = [
594
622
  jdtls_path,
595
623
  "-data",
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
  import asyncio
10
10
  import json
11
11
  import logging
12
+ import os
12
13
  import re
13
14
  import sys
14
15
  from pathlib import Path
@@ -46,6 +47,10 @@ _ANALYZERS: list[Analyzer] = [
46
47
  FunctionalChecker(),
47
48
  ]
48
49
 
50
+ # JetBrains IDE detection — all products contain one of these substrings in clientInfo.name.
51
+ # When detected, jdtls is skipped because IntelliJ provides native Java language support.
52
+ _JDTLS_SKIP_CLIENTS = ("IntelliJ", "JetBrains")
53
+
49
54
  #: LSP-aware cattrs converter. Unstructures to the LSP JSON shape
50
55
  #: (camelCase field names, discriminated unions, None-field pruning) and
51
56
  #: correspondingly structures from the same shape. Using a vanilla
@@ -65,6 +70,10 @@ class JavaFunctionalLspServer(LanguageServer):
65
70
  self._config: dict[str, Any] = {}
66
71
  self._init_params: dict[str, Any] = {}
67
72
  self._proxy = JdtlsProxy(on_diagnostics=self._on_jdtls_diagnostics)
73
+ self._user_suppress_patterns: list[re.Pattern[str]] = []
74
+ self._skip_jdtls: bool = False
75
+ self._skip_jdtls_registration: bool = False
76
+ self._init_generation: int = 0
68
77
 
69
78
  def _on_jdtls_diagnostics(self, uri: str, diagnostics: list[Any]) -> None:
70
79
  """Called when jdtls publishes diagnostics — merge with custom and re-publish.
@@ -198,38 +207,60 @@ def _jdtls_raw_to_lsp_diagnostics(raw_diagnostics: list[Any]) -> list[lsp.Diagno
198
207
  return result
199
208
 
200
209
 
201
- # Lombok-specific patterns: only match diagnostics that are clearly Lombok-generated.
202
- # Intentionally narrow to avoid suppressing real compile errors.
203
- _LOMBOK_PATTERNS = re.compile(
204
- r"(?:"
205
- r"The method builder\(\)|" # @Builder
206
- r"The method toBuilder\(\)|" # @Builder(toBuilder=true)
207
- r"log cannot be resolved|" # @Slf4j, @Log
208
- r"\w+Builder cannot be resolved to a type|" # @Builder inner class
209
- r"The blank final field \w+ may not have been initialized" # @Value final fields
210
- r")"
211
- )
210
+ _MAX_PATTERN_LENGTH = 500 # Cap regex length to mitigate ReDoS from pathological patterns
211
+
212
+
213
+ def _compile_user_patterns(config: dict[str, Any]) -> list[re.Pattern[str]]:
214
+ """Compile user-defined suppressJdtlsPatterns from config."""
215
+ raw = config.get("suppressJdtlsPatterns", [])
216
+ if not isinstance(raw, list):
217
+ logger.warning("suppressJdtlsPatterns must be a list of regex strings, got %s", type(raw).__name__)
218
+ return []
219
+ patterns: list[re.Pattern[str]] = []
220
+ for entry in raw:
221
+ if not isinstance(entry, str):
222
+ continue
223
+ if len(entry) > _MAX_PATTERN_LENGTH:
224
+ logger.warning("suppressJdtlsPatterns entry too long (%d chars, max %d)", len(entry), _MAX_PATTERN_LENGTH)
225
+ continue
226
+ try:
227
+ patterns.append(re.compile(entry))
228
+ except re.error as e:
229
+ logger.warning("Invalid suppressJdtlsPatterns regex %r: %s", entry, e)
230
+ return patterns
212
231
 
213
232
 
214
- def _is_lombok_false_positive(diag: dict[str, Any]) -> bool:
215
- """Check if a jdtls diagnostic is likely a Lombok false positive.
233
+ def _is_jdtls_suppressed(diag: dict[str, Any], user_patterns: list[re.Pattern[str]]) -> bool:
234
+ """Check if a jdtls diagnostic matches a user-configured suppress pattern.
216
235
 
217
- Only matches narrow Lombok-specific patterns to avoid suppressing real errors.
236
+ No built-in patterns — the root cause (stale jdtls caches) is fixed by
237
+ clearing the cache on version change. Users can still suppress specific
238
+ jdtls messages via suppressJdtlsPatterns in .java-functional-lsp.json.
218
239
  """
240
+ if not user_patterns:
241
+ return False
219
242
  msg = diag.get("message", "")
220
- return bool(_LOMBOK_PATTERNS.search(msg))
243
+ for pat in user_patterns:
244
+ if pat.search(msg):
245
+ return True
246
+ return False
221
247
 
222
248
 
223
249
  def _run_analysis(source: str, uri: str) -> list[lsp.Diagnostic]:
224
- """Run custom analyzers on source text and merge with jdtls diagnostics."""
250
+ """Run custom analyzers on source text and merge with jdtls diagnostics.
251
+
252
+ jdtls processing is isolated: if it fails, custom diagnostics still publish.
253
+ """
225
254
  custom_diags = _analyze_document(source, uri)
226
255
 
227
256
  jdtls_diags: list[lsp.Diagnostic] = []
228
257
  if server._proxy.is_available:
229
- raw = server._proxy.get_cached_diagnostics(uri)
230
- if not server._proxy.has_lombok:
231
- raw = [d for d in raw if not _is_lombok_false_positive(d)]
232
- jdtls_diags = _jdtls_raw_to_lsp_diagnostics(raw)
258
+ try:
259
+ raw = server._proxy.get_cached_diagnostics(uri)
260
+ raw = [d for d in raw if not _is_jdtls_suppressed(d, server._user_suppress_patterns)]
261
+ jdtls_diags = _jdtls_raw_to_lsp_diagnostics(raw)
262
+ except Exception as e:
263
+ logger.warning("jdtls diagnostic processing failed for %s: %s", uri, e)
233
264
 
234
265
  return jdtls_diags + custom_diags
235
266
 
@@ -257,6 +288,39 @@ def on_initialize(params: lsp.InitializeParams) -> lsp.InitializeResult:
257
288
  root = params.root_path
258
289
 
259
290
  server._config = _load_config(root)
291
+ server._user_suppress_patterns = _compile_user_patterns(server._config)
292
+
293
+ # Determine jdtls mode: env var takes priority, then auto-detect JetBrains IDEs.
294
+ client_name = (params.client_info.name if params.client_info else "")[:200]
295
+ logger.info("LSP client: %s", client_name or "(unknown)")
296
+
297
+ # Reset flags so re-initialization (non-standard but defensive) starts clean.
298
+ # Bump generation so in-flight _register_jdtls_capabilities from a prior
299
+ # session detects the stale context and aborts.
300
+ global _jdtls_capabilities_registered
301
+ server._skip_jdtls = False
302
+ server._skip_jdtls_registration = False
303
+ server._init_generation += 1
304
+ _jdtls_capabilities_registered = False
305
+
306
+ jdtls_override = os.environ.get("JAVA_FUNCTIONAL_LSP_JDTLS", "").strip().lower()
307
+ if jdtls_override == "off":
308
+ server._skip_jdtls = True
309
+ logger.info("jdtls proxy disabled via JAVA_FUNCTIONAL_LSP_JDTLS=off")
310
+ elif jdtls_override == "on":
311
+ logger.info("jdtls proxy force-enabled via JAVA_FUNCTIONAL_LSP_JDTLS=on")
312
+ elif jdtls_override == "no-register":
313
+ server._skip_jdtls_registration = True
314
+ logger.info("jdtls dynamic registration disabled via JAVA_FUNCTIONAL_LSP_JDTLS=no-register")
315
+ elif jdtls_override:
316
+ logger.warning("Unknown JAVA_FUNCTIONAL_LSP_JDTLS value %r; expected off/on/no-register", jdtls_override)
317
+ elif any(token in client_name for token in _JDTLS_SKIP_CLIENTS):
318
+ server._skip_jdtls = True
319
+ logger.info(
320
+ "JetBrains IDE detected (%s) — skipping jdtls proxy "
321
+ "(IDE provides native Java support; override with JAVA_FUNCTIONAL_LSP_JDTLS=on)",
322
+ client_name,
323
+ )
260
324
 
261
325
  return lsp.InitializeResult(
262
326
  capabilities=lsp.ServerCapabilities(
@@ -284,6 +348,9 @@ async def on_initialized(params: lsp.InitializedParams) -> None:
284
348
  "java-functional-lsp initialized (rules: %s)",
285
349
  list(server._config.get("rules", {}).keys()) or "all defaults",
286
350
  )
351
+ if server._skip_jdtls:
352
+ logger.info("jdtls proxy disabled — custom rules only")
353
+ return
287
354
  if server._proxy.check_available():
288
355
  logger.info("jdtls found on PATH — will start lazily on first file open")
289
356
  else:
@@ -331,11 +398,15 @@ async def _register_jdtls_capabilities() -> None:
331
398
  ready, which suppresses the IDE's built-in diagnostic tooltips.
332
399
 
333
400
  Idempotent: safe to call multiple times (e.g., proxy restart).
401
+ Uses a generation counter to detect stale registrations from a prior
402
+ initialize cycle (defensive against non-standard re-initialization).
334
403
  """
335
404
  global _jdtls_capabilities_registered
336
405
  if _jdtls_capabilities_registered:
337
406
  return
338
407
 
408
+ generation = server._init_generation
409
+
339
410
  try:
340
411
  # Register handlers so pygls dispatches incoming requests to them.
341
412
  for method, handler in _JDTLS_HANDLERS.items():
@@ -344,6 +415,12 @@ async def _register_jdtls_capabilities() -> None:
344
415
  # Tell the client we now support these capabilities.
345
416
  registrations = _build_jdtls_registrations()
346
417
  await server.client_register_capability_async(lsp.RegistrationParams(registrations=registrations))
418
+
419
+ # Bail if a re-initialize happened while we were awaiting.
420
+ if generation != server._init_generation:
421
+ logger.info("Discarding stale jdtls capability registration (re-initialize detected)")
422
+ return
423
+
347
424
  _jdtls_capabilities_registered = True
348
425
  logger.info("Dynamically registered jdtls capabilities (hover, definition, references, completion, symbol)")
349
426
  except Exception:
@@ -363,11 +440,16 @@ def _analyze_and_publish(uri: str) -> None:
363
440
  async def _deferred_validate(uri: str) -> None:
364
441
  """Debounced validation — waits before analyzing to batch rapid edits."""
365
442
  await asyncio.sleep(_DEBOUNCE_SECONDS)
366
- _analyze_and_publish(uri)
443
+ try:
444
+ _analyze_and_publish(uri)
445
+ except Exception as e:
446
+ logger.error("Validation failed for %s: %s", uri, e)
367
447
 
368
448
 
369
449
  def _forward_or_queue(method: str, serialized: Any) -> None:
370
450
  """Forward a notification to jdtls if available, or queue it if starting."""
451
+ if server._skip_jdtls:
452
+ return
371
453
  if server._proxy.is_available:
372
454
  task = asyncio.create_task(server._proxy.send_notification(method, serialized))
373
455
  _bg_tasks.add(task)
@@ -385,14 +467,18 @@ async def on_did_open(params: lsp.DidOpenTextDocumentParams) -> None:
385
467
  didOpen response isn't delayed by jdtls cold-start.
386
468
  """
387
469
  uri = params.text_document.uri
388
- serialized = _serialize_params(params)
389
470
 
390
- if server._proxy.is_available:
471
+ if server._skip_jdtls:
472
+ # Skip all jdtls forwarding — custom diagnostics only.
473
+ pass
474
+ elif server._proxy.is_available:
391
475
  # Fast path: jdtls running. Forward didOpen + add module if new.
476
+ serialized = _serialize_params(params)
392
477
  await server._proxy.send_notification("textDocument/didOpen", serialized)
393
478
  await server._proxy.add_module_if_new(uri)
394
479
  elif server._proxy._jdtls_on_path and not server._proxy._start_failed:
395
480
  # Queue the didOpen (whether this is the first file or a subsequent one during startup).
481
+ serialized = _serialize_params(params)
396
482
  server._proxy.queue_notification("textDocument/didOpen", serialized)
397
483
  if not server._proxy._lazy_start_fired:
398
484
  # First file: kick off lazy start in background.
@@ -402,7 +488,10 @@ async def on_did_open(params: lsp.DidOpenTextDocumentParams) -> None:
402
488
  task.add_done_callback(_bg_tasks.discard)
403
489
 
404
490
  # Custom diagnostics always publish immediately — never blocked by jdtls.
405
- _analyze_and_publish(uri)
491
+ try:
492
+ _analyze_and_publish(uri)
493
+ except Exception as e:
494
+ logger.error("Analysis failed on open for %s: %s", uri, e)
406
495
 
407
496
 
408
497
  @server.feature(lsp.TEXT_DOCUMENT_DID_CHANGE)
@@ -420,7 +509,10 @@ async def on_did_change(params: lsp.DidChangeTextDocumentParams) -> None:
420
509
  async def on_did_save(params: lsp.DidSaveTextDocumentParams) -> None:
421
510
  """Forward to jdtls and re-analyze immediately (no debounce on save)."""
422
511
  _forward_or_queue("textDocument/didSave", _serialize_params(params))
423
- _analyze_and_publish(params.text_document.uri)
512
+ try:
513
+ _analyze_and_publish(params.text_document.uri)
514
+ except Exception as e:
515
+ logger.error("Analysis failed on save for %s: %s", params.text_document.uri, e)
424
516
 
425
517
 
426
518
  @server.feature(lsp.TEXT_DOCUMENT_DID_CLOSE)
@@ -447,7 +539,10 @@ async def _lazy_start_jdtls(file_uri: str) -> None:
447
539
  started = await server._proxy.ensure_started(server._init_params, file_uri, config=server._config)
448
540
  if started:
449
541
  logger.info("jdtls proxy active — full Java language support enabled")
450
- await _register_jdtls_capabilities()
542
+ if server._skip_jdtls_registration:
543
+ logger.info("Skipping dynamic capability registration (no-register mode)")
544
+ else:
545
+ await _register_jdtls_capabilities()
451
546
  await server._proxy.flush_queued_notifications()
452
547
  except Exception:
453
548
  logger.warning("jdtls lazy start failed", exc_info=True)
@@ -647,55 +647,201 @@ class TestServerInternals:
647
647
  # --------------------------------------------------------------------------
648
648
 
649
649
 
650
- class TestLombokFalsePositive:
651
- """Tests for _is_lombok_false_positive()."""
650
+ class TestUserSuppressPatterns:
651
+ """Tests for configurable suppressJdtlsPatterns."""
652
652
 
653
- def test_matches_builder_undefined(self) -> None:
654
- from java_functional_lsp.server import _is_lombok_false_positive
653
+ def test_user_pattern_matches(self) -> None:
654
+ from java_functional_lsp.server import _compile_user_patterns, _is_jdtls_suppressed
655
655
 
656
- diag = {"code": "67108964", "message": "The method builder() is undefined for the type Foo"}
657
- assert _is_lombok_false_positive(diag) is True
656
+ patterns = _compile_user_patterns({"suppressJdtlsPatterns": [r"The method generateReport\(\) is undefined"]})
657
+ diag = {"message": "The method generateReport() is undefined for the type InternalService"}
658
+ assert _is_jdtls_suppressed(diag, patterns) is True
658
659
 
659
- def test_matches_log_unresolved(self) -> None:
660
- from java_functional_lsp.server import _is_lombok_false_positive
660
+ def test_user_pattern_does_not_match_unrelated(self) -> None:
661
+ from java_functional_lsp.server import _compile_user_patterns, _is_jdtls_suppressed
661
662
 
662
- diag = {"code": "570425394", "message": "log cannot be resolved"}
663
- assert _is_lombok_false_positive(diag) is True
663
+ patterns = _compile_user_patterns({"suppressJdtlsPatterns": [r"The method generateReport\(\) is undefined"]})
664
+ diag = {"message": "The method foo() is undefined for the type String"}
665
+ assert _is_jdtls_suppressed(diag, patterns) is False
664
666
 
665
- def test_matches_builder_type_unresolved(self) -> None:
666
- from java_functional_lsp.server import _is_lombok_false_positive
667
+ def test_no_patterns_passes_everything(self) -> None:
668
+ """With no user patterns, nothing is suppressed."""
669
+ from java_functional_lsp.server import _is_jdtls_suppressed
667
670
 
668
- diag = {"code": "16777218", "message": "FooBuilder cannot be resolved to a type"}
669
- assert _is_lombok_false_positive(diag) is True
671
+ diag = {"message": "The method builder() is undefined for the type Foo"}
672
+ assert _is_jdtls_suppressed(diag, []) is False
670
673
 
671
- def test_does_not_match_real_undefined_method(self) -> None:
672
- from java_functional_lsp.server import _is_lombok_false_positive
674
+ def test_invalid_regex_skipped(self) -> None:
675
+ from java_functional_lsp.server import _compile_user_patterns
673
676
 
674
- diag = {"code": "67108964", "message": "The method foo() is undefined for the type String"}
675
- assert _is_lombok_false_positive(diag) is False
677
+ patterns = _compile_user_patterns({"suppressJdtlsPatterns": [r"valid", r"[invalid"]})
678
+ assert len(patterns) == 1
679
+ assert patterns[0].search("valid") is not None
676
680
 
677
- def test_does_not_match_real_unresolved_type(self) -> None:
678
- from java_functional_lsp.server import _is_lombok_false_positive
681
+ def test_non_list_ignored(self) -> None:
682
+ from java_functional_lsp.server import _compile_user_patterns
679
683
 
680
- diag = {"code": "16777218", "message": "SomeClass cannot be resolved to a type"}
681
- assert _is_lombok_false_positive(diag) is False
684
+ patterns = _compile_user_patterns({"suppressJdtlsPatterns": "not a list"})
685
+ assert patterns == []
682
686
 
683
- def test_empty_message(self) -> None:
684
- from java_functional_lsp.server import _is_lombok_false_positive
687
+ def test_empty_config(self) -> None:
688
+ from java_functional_lsp.server import _compile_user_patterns
685
689
 
686
- diag = {"code": "67108964", "message": ""}
687
- assert _is_lombok_false_positive(diag) is False
690
+ patterns = _compile_user_patterns({})
691
+ assert patterns == []
688
692
 
689
- def test_missing_fields(self) -> None:
690
- from java_functional_lsp.server import _is_lombok_false_positive
691
693
 
692
- assert _is_lombok_false_positive({}) is False
694
+ class TestJdtlsIsolation:
695
+ """Tests that jdtls failures don't suppress custom diagnostics."""
693
696
 
694
- def test_matches_blank_final_field(self) -> None:
695
- from java_functional_lsp.server import _is_lombok_false_positive
697
+ def test_jdtls_exception_does_not_suppress_custom_diags(self) -> None:
698
+ """If jdtls diagnostic processing throws, custom diagnostics still publish."""
699
+ from unittest.mock import patch
700
+
701
+ from java_functional_lsp.server import _run_analysis
702
+
703
+ java_source = "public class Foo { public String bar() { return null; } }"
704
+
705
+ with patch("java_functional_lsp.server.server") as mock_server:
706
+ mock_server._proxy.is_available = True
707
+ mock_server._proxy.get_cached_diagnostics.side_effect = RuntimeError("jdtls corrupt")
708
+ mock_server._user_suppress_patterns = []
709
+ mock_server._config = {}
710
+ mock_server._parser = __import__("java_functional_lsp.analyzers.base", fromlist=["get_parser"]).get_parser()
711
+
712
+ result = _run_analysis(java_source, "file:///test/Foo.java")
713
+
714
+ custom_diags = [d for d in result if d.source == "java-functional-lsp"]
715
+ assert len(custom_diags) > 0, "Custom diagnostics should publish even when jdtls fails"
716
+
717
+ def test_jdtls_unavailable_still_publishes_custom_diags(self) -> None:
718
+ """When jdtls is not available, custom diagnostics still publish."""
719
+ from unittest.mock import patch
720
+
721
+ from java_functional_lsp.server import _run_analysis
722
+
723
+ java_source = "public class Foo { public String bar() { return null; } }"
724
+
725
+ with patch("java_functional_lsp.server.server") as mock_server:
726
+ mock_server._proxy.is_available = False
727
+ mock_server._config = {}
728
+ mock_server._parser = __import__("java_functional_lsp.analyzers.base", fromlist=["get_parser"]).get_parser()
729
+
730
+ result = _run_analysis(java_source, "file:///test/Foo.java")
731
+
732
+ custom_diags = [d for d in result if d.source == "java-functional-lsp"]
733
+ assert len(custom_diags) > 0, "Custom diagnostics should publish without jdtls"
734
+
735
+ def test_analyze_and_publish_exception_does_not_crash_handler(self) -> None:
736
+ """Handler-level try/except prevents server crash when _analyze_and_publish raises."""
737
+ from unittest.mock import patch
738
+
739
+ from java_functional_lsp.server import _deferred_validate
740
+
741
+ with patch("java_functional_lsp.server._analyze_and_publish", side_effect=RuntimeError("boom")):
742
+ import asyncio
743
+
744
+ loop = asyncio.new_event_loop()
745
+ try:
746
+ # _deferred_validate should catch the exception and log, not raise
747
+ loop.run_until_complete(_deferred_validate("file:///test/Foo.java"))
748
+ finally:
749
+ loop.close()
750
+ # If we get here without exception, the handler caught it correctly
751
+
752
+
753
+ class TestJdtlsSkipDetection:
754
+ """Tests for auto-detecting JetBrains IDEs and skipping jdtls."""
755
+
756
+ def _make_init_params(self, client_name: str | None = None) -> Any:
757
+ """Create minimal InitializeParams with optional client_info."""
758
+ return lsp.InitializeParams(
759
+ capabilities=lsp.ClientCapabilities(),
760
+ root_uri="file:///tmp/test",
761
+ client_info=lsp.ClientInfo(name=client_name) if client_name else None,
762
+ )
763
+
764
+ def _run_init(self, monkeypatch: Any, client_name: str | None, env_value: str | None = None) -> None:
765
+ """Run on_initialize with controlled env and client_info."""
766
+ from java_functional_lsp.server import on_initialize, server
767
+
768
+ server._skip_jdtls = False
769
+ server._skip_jdtls_registration = False
770
+ if env_value is not None:
771
+ monkeypatch.setenv("JAVA_FUNCTIONAL_LSP_JDTLS", env_value)
772
+ else:
773
+ monkeypatch.delenv("JAVA_FUNCTIONAL_LSP_JDTLS", raising=False)
774
+ on_initialize(self._make_init_params(client_name))
775
+
776
+ def test_intellij_client_info_skips_jdtls(self, monkeypatch: Any) -> None:
777
+ """IntelliJ IDEA detected → _skip_jdtls = True."""
778
+ from java_functional_lsp.server import server
779
+
780
+ self._run_init(monkeypatch, "IntelliJ IDEA")
781
+ assert server._skip_jdtls is True
782
+
783
+ def test_jetbrains_product_skips_jdtls(self, monkeypatch: Any) -> None:
784
+ """Any JetBrains product detected → _skip_jdtls = True."""
785
+ from java_functional_lsp.server import server
786
+
787
+ self._run_init(monkeypatch, "JetBrains Rider 2025.2")
788
+ assert server._skip_jdtls is True
789
+
790
+ def test_vscode_allows_jdtls(self, monkeypatch: Any) -> None:
791
+ """VS Code → _skip_jdtls = False (jdtls needed)."""
792
+ from java_functional_lsp.server import server
793
+
794
+ self._run_init(monkeypatch, "Visual Studio Code")
795
+ assert server._skip_jdtls is False
696
796
 
697
- diag = {"code": "33554513", "message": "The blank final field name may not have been initialized"}
698
- assert _is_lombok_false_positive(diag) is True
797
+ def test_no_client_info_allows_jdtls(self, monkeypatch: Any) -> None:
798
+ """No client_info → _skip_jdtls = False (safe default)."""
799
+ from java_functional_lsp.server import server
800
+
801
+ self._run_init(monkeypatch, None)
802
+ assert server._skip_jdtls is False
803
+
804
+ def test_env_var_off_overrides_detection(self, monkeypatch: Any) -> None:
805
+ """JAVA_FUNCTIONAL_LSP_JDTLS=off disables jdtls even for non-JetBrains client."""
806
+ from java_functional_lsp.server import server
807
+
808
+ self._run_init(monkeypatch, "Visual Studio Code", env_value="off")
809
+ assert server._skip_jdtls is True
810
+
811
+ def test_env_var_on_overrides_intellij_detection(self, monkeypatch: Any) -> None:
812
+ """JAVA_FUNCTIONAL_LSP_JDTLS=on force-enables jdtls even for IntelliJ."""
813
+ from java_functional_lsp.server import server
814
+
815
+ self._run_init(monkeypatch, "IntelliJ IDEA", env_value="on")
816
+ assert server._skip_jdtls is False
817
+
818
+ def test_env_var_no_register(self, monkeypatch: Any) -> None:
819
+ """JAVA_FUNCTIONAL_LSP_JDTLS=no-register keeps jdtls but skips registration."""
820
+ from java_functional_lsp.server import server
821
+
822
+ self._run_init(monkeypatch, "Visual Studio Code", env_value="no-register")
823
+ assert server._skip_jdtls is False
824
+ assert server._skip_jdtls_registration is True
825
+
826
+ def test_custom_diagnostics_publish_when_jdtls_skipped(self) -> None:
827
+ """Tree-sitter analysis works normally when jdtls is skipped."""
828
+ from unittest.mock import patch
829
+
830
+ from java_functional_lsp.server import _run_analysis
831
+
832
+ java_source = "public class Foo { public String bar() { return null; } }"
833
+
834
+ with patch("java_functional_lsp.server.server") as mock_server:
835
+ mock_server._proxy.is_available = False
836
+ mock_server._skip_jdtls = True
837
+ mock_server._config = {}
838
+ mock_server._parser = __import__("java_functional_lsp.analyzers.base", fromlist=["get_parser"]).get_parser()
839
+
840
+ result = _run_analysis(java_source, "file:///test/Foo.java")
841
+
842
+ custom_diags = [d for d in result if d.source == "java-functional-lsp"]
843
+ assert len(custom_diags) > 0, "Custom diagnostics should publish when jdtls is skipped"
844
+ assert any(d.code == "null-return" for d in custom_diags), "null-return diagnostic expected"
699
845
 
700
846
 
701
847
  class TestFindLombokJar:
@@ -761,6 +907,51 @@ class TestFindLombokJar:
761
907
  assert any("does not exist" in r.getMessage() for r in caplog.records)
762
908
 
763
909
 
910
+ class TestCacheClear:
911
+ """Tests for _clear_cache_on_version_change()."""
912
+
913
+ def test_clears_on_version_mismatch(self, tmp_path: Any) -> None:
914
+ from java_functional_lsp.proxy import _clear_cache_on_version_change
915
+
916
+ cache = tmp_path / "jdtls-data"
917
+ cache.mkdir()
918
+ stale_dir = cache / "abc123"
919
+ stale_dir.mkdir()
920
+ (cache / ".version").write_text("0.7.9")
921
+
922
+ _clear_cache_on_version_change(cache)
923
+
924
+ assert not stale_dir.exists(), "Stale data-dir should be cleared"
925
+ assert cache.exists(), "Cache root should be recreated"
926
+ from java_functional_lsp import __version__
927
+
928
+ assert (cache / ".version").read_text() == __version__
929
+
930
+ def test_keeps_cache_on_same_version(self, tmp_path: Any) -> None:
931
+ from java_functional_lsp import __version__
932
+ from java_functional_lsp.proxy import _clear_cache_on_version_change
933
+
934
+ cache = tmp_path / "jdtls-data"
935
+ cache.mkdir()
936
+ existing_dir = cache / "abc123"
937
+ existing_dir.mkdir()
938
+ (cache / ".version").write_text(__version__)
939
+
940
+ _clear_cache_on_version_change(cache)
941
+
942
+ assert existing_dir.exists(), "Existing data-dir should be preserved"
943
+
944
+ def test_creates_marker_on_first_run(self, tmp_path: Any) -> None:
945
+ from java_functional_lsp.proxy import _clear_cache_on_version_change
946
+
947
+ cache = tmp_path / "jdtls-data"
948
+ _clear_cache_on_version_change(cache)
949
+
950
+ from java_functional_lsp import __version__
951
+
952
+ assert (cache / ".version").read_text() == __version__
953
+
954
+
764
955
  # Subprocess-based tests — zero mocks, real LSP transport
765
956
  # --------------------------------------------------------------------------
766
957
 
@@ -184,7 +184,7 @@ wheels = [
184
184
 
185
185
  [[package]]
186
186
  name = "java-functional-lsp"
187
- version = "0.7.9"
187
+ version = "0.8.1"
188
188
  source = { editable = "." }
189
189
  dependencies = [
190
190
  { name = "pygls" },
@@ -426,7 +426,7 @@ wheels = [
426
426
 
427
427
  [[package]]
428
428
  name = "pytest"
429
- version = "9.0.2"
429
+ version = "9.0.3"
430
430
  source = { registry = "https://pypi.org/simple" }
431
431
  dependencies = [
432
432
  { name = "colorama", marker = "sys_platform == 'win32'" },
@@ -437,9 +437,9 @@ dependencies = [
437
437
  { name = "pygments" },
438
438
  { name = "tomli", marker = "python_full_version < '3.11'" },
439
439
  ]
440
- sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
440
+ sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
441
441
  wheels = [
442
- { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
442
+ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
443
443
  ]
444
444
 
445
445
  [[package]]