java-functional-lsp 0.7.9__tar.gz → 0.8.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.
Files changed (66) hide show
  1. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/PKG-INFO +18 -2
  2. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/README.md +17 -1
  3. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/pyproject.toml +1 -1
  4. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/src/java_functional_lsp/__init__.py +1 -1
  5. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/src/java_functional_lsp/proxy.py +44 -16
  6. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/src/java_functional_lsp/server.py +50 -23
  7. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/tests/test_server.py +130 -33
  8. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/uv.lock +1 -1
  9. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/.claude-plugin/plugin.json +0 -0
  10. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/.githooks/pre-commit +0 -0
  11. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/.githooks/pre-push +0 -0
  12. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/.github/CODEOWNERS +0 -0
  13. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/.github/ISSUE_TEMPLATE/bug-report.md +0 -0
  14. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/.github/ISSUE_TEMPLATE/feature-request.md +0 -0
  15. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  16. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/.github/SECURITY.md +0 -0
  17. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/.github/dependabot.yml +0 -0
  18. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/.github/release-drafter.yml +0 -0
  19. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/.github/workflows/publish.yml +0 -0
  20. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/.github/workflows/release-drafter.yml +0 -0
  21. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/.github/workflows/stale.yml +0 -0
  22. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/.github/workflows/test.yml +0 -0
  23. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/.github/workflows/update-homebrew.yml +0 -0
  24. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/.github/workflows/vscode-ext.yml +0 -0
  25. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/.gitignore +0 -0
  26. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/CONTRIBUTING.md +0 -0
  27. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/LICENSE +0 -0
  28. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/SKILL.md +0 -0
  29. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/commands/lint-java.md +0 -0
  30. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/editors/intellij/README.md +0 -0
  31. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/editors/intellij/lsp4ij-template.json +0 -0
  32. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/editors/vscode/.vscodeignore +0 -0
  33. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/editors/vscode/README.md +0 -0
  34. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/editors/vscode/package-lock.json +0 -0
  35. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/editors/vscode/package.json +0 -0
  36. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/editors/vscode/src/extension.ts +0 -0
  37. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/editors/vscode/tsconfig.json +0 -0
  38. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/hooks/hooks.json +0 -0
  39. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/hooks/java_linter_reminder.py +0 -0
  40. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/scripts/ensure-lsp.sh +0 -0
  41. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/scripts/generate-formula.py +0 -0
  42. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/src/java_functional_lsp/__main__.py +0 -0
  43. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/src/java_functional_lsp/analyzers/__init__.py +0 -0
  44. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/src/java_functional_lsp/analyzers/base.py +0 -0
  45. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/src/java_functional_lsp/analyzers/exception_checker.py +0 -0
  46. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/src/java_functional_lsp/analyzers/functional_checker.py +0 -0
  47. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/src/java_functional_lsp/analyzers/mutation_checker.py +0 -0
  48. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/src/java_functional_lsp/analyzers/null_checker.py +0 -0
  49. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/src/java_functional_lsp/analyzers/spring_checker.py +0 -0
  50. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/src/java_functional_lsp/cli.py +0 -0
  51. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/src/java_functional_lsp/fixes.py +0 -0
  52. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/tests/__init__.py +0 -0
  53. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/tests/conftest.py +0 -0
  54. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/tests/test_base.py +0 -0
  55. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/tests/test_cli.py +0 -0
  56. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/tests/test_config.py +0 -0
  57. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/tests/test_e2e.py +0 -0
  58. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/tests/test_e2e_jdtls.py +0 -0
  59. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/tests/test_exception_checker.py +0 -0
  60. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/tests/test_fixes.py +0 -0
  61. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/tests/test_functional_checker.py +0 -0
  62. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/tests/test_mutation_checker.py +0 -0
  63. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/tests/test_null_checker.py +0 -0
  64. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/tests/test_proxy.py +0 -0
  65. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/tests/test_spring_checker.py +0 -0
  66. {java_functional_lsp-0.7.9 → java_functional_lsp-0.8.0}/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.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
@@ -230,6 +230,7 @@ Create `.java-functional-lsp.json` in your project root to customize rules:
230
230
  **Options:**
231
231
  - `excludes` — glob patterns for files/directories to skip entirely (supports `**` for multi-segment wildcards)
232
232
  - `rules` — per-rule severity: `error`, `warning` (default), `info`, `hint`, `off`
233
+ - `suppressJdtlsPatterns` — list of regex patterns to suppress jdtls diagnostics (see below)
233
234
 
234
235
  **Spring-aware behavior:**
235
236
  - `throw-statement`, `catch-rethrow`, and `try-catch-to-monadic` are automatically suppressed inside `@Bean` methods
@@ -267,7 +268,22 @@ The server auto-discovers `lombok.jar` from these locations (first match wins):
267
268
  3. **Maven cache** — auto-discovered from `~/.m2/repository/org/projectlombok/lombok/`
268
269
  4. **Dedicated directory** — `~/.jdtls-libs/lombok.jar`
269
270
 
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.
271
+ 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`.
272
+
273
+ ### Suppressing jdtls diagnostics
274
+
275
+ For project-specific jdtls false positives (e.g., annotation processor methods, MapStruct mappers), use `suppressJdtlsPatterns` to add custom regex filters:
276
+
277
+ ```json
278
+ {
279
+ "suppressJdtlsPatterns": [
280
+ "The method \\w+Mapper\\(\\) is undefined",
281
+ "cannot be resolved to a type"
282
+ ]
283
+ }
284
+ ```
285
+
286
+ Each entry is a regex matched against jdtls diagnostic messages. Invalid patterns are skipped with a warning.
271
287
 
272
288
  ## Code actions (quick fixes)
273
289
 
@@ -202,6 +202,7 @@ Create `.java-functional-lsp.json` in your project root to customize rules:
202
202
  **Options:**
203
203
  - `excludes` — glob patterns for files/directories to skip entirely (supports `**` for multi-segment wildcards)
204
204
  - `rules` — per-rule severity: `error`, `warning` (default), `info`, `hint`, `off`
205
+ - `suppressJdtlsPatterns` — list of regex patterns to suppress jdtls diagnostics (see below)
205
206
 
206
207
  **Spring-aware behavior:**
207
208
  - `throw-statement`, `catch-rethrow`, and `try-catch-to-monadic` are automatically suppressed inside `@Bean` methods
@@ -239,7 +240,22 @@ The server auto-discovers `lombok.jar` from these locations (first match wins):
239
240
  3. **Maven cache** — auto-discovered from `~/.m2/repository/org/projectlombok/lombok/`
240
241
  4. **Dedicated directory** — `~/.jdtls-libs/lombok.jar`
241
242
 
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.
243
+ 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`.
244
+
245
+ ### Suppressing jdtls diagnostics
246
+
247
+ For project-specific jdtls false positives (e.g., annotation processor methods, MapStruct mappers), use `suppressJdtlsPatterns` to add custom regex filters:
248
+
249
+ ```json
250
+ {
251
+ "suppressJdtlsPatterns": [
252
+ "The method \\w+Mapper\\(\\) is undefined",
253
+ "cannot be resolved to a type"
254
+ ]
255
+ }
256
+ ```
257
+
258
+ Each entry is a regex matched against jdtls diagnostic messages. Invalid patterns are skipped with a warning.
243
259
 
244
260
  ## Code actions (quick fixes)
245
261
 
@@ -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.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" }
@@ -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.0"
@@ -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",
@@ -65,6 +65,7 @@ class JavaFunctionalLspServer(LanguageServer):
65
65
  self._config: dict[str, Any] = {}
66
66
  self._init_params: dict[str, Any] = {}
67
67
  self._proxy = JdtlsProxy(on_diagnostics=self._on_jdtls_diagnostics)
68
+ self._user_suppress_patterns: list[re.Pattern[str]] = []
68
69
 
69
70
  def _on_jdtls_diagnostics(self, uri: str, diagnostics: list[Any]) -> None:
70
71
  """Called when jdtls publishes diagnostics — merge with custom and re-publish.
@@ -198,38 +199,54 @@ def _jdtls_raw_to_lsp_diagnostics(raw_diagnostics: list[Any]) -> list[lsp.Diagno
198
199
  return result
199
200
 
200
201
 
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
- )
202
+ def _compile_user_patterns(config: dict[str, Any]) -> list[re.Pattern[str]]:
203
+ """Compile user-defined suppressJdtlsPatterns from config."""
204
+ raw = config.get("suppressJdtlsPatterns", [])
205
+ if not isinstance(raw, list):
206
+ logger.warning("suppressJdtlsPatterns must be a list of regex strings, got %s", type(raw).__name__)
207
+ return []
208
+ patterns: list[re.Pattern[str]] = []
209
+ for entry in raw:
210
+ if not isinstance(entry, str):
211
+ continue
212
+ try:
213
+ patterns.append(re.compile(entry))
214
+ except re.error as e:
215
+ logger.warning("Invalid suppressJdtlsPatterns regex %r: %s", entry, e)
216
+ return patterns
212
217
 
213
218
 
214
- def _is_lombok_false_positive(diag: dict[str, Any]) -> bool:
215
- """Check if a jdtls diagnostic is likely a Lombok false positive.
219
+ def _is_jdtls_suppressed(diag: dict[str, Any], user_patterns: list[re.Pattern[str]]) -> bool:
220
+ """Check if a jdtls diagnostic matches a user-configured suppress pattern.
216
221
 
217
- Only matches narrow Lombok-specific patterns to avoid suppressing real errors.
222
+ No built-in patterns — the root cause (stale jdtls caches) is fixed by
223
+ clearing the cache on version change. Users can still suppress specific
224
+ jdtls messages via suppressJdtlsPatterns in .java-functional-lsp.json.
218
225
  """
226
+ if not user_patterns:
227
+ return False
219
228
  msg = diag.get("message", "")
220
- return bool(_LOMBOK_PATTERNS.search(msg))
229
+ for pat in user_patterns:
230
+ if pat.search(msg):
231
+ return True
232
+ return False
221
233
 
222
234
 
223
235
  def _run_analysis(source: str, uri: str) -> list[lsp.Diagnostic]:
224
- """Run custom analyzers on source text and merge with jdtls diagnostics."""
236
+ """Run custom analyzers on source text and merge with jdtls diagnostics.
237
+
238
+ jdtls processing is isolated: if it fails, custom diagnostics still publish.
239
+ """
225
240
  custom_diags = _analyze_document(source, uri)
226
241
 
227
242
  jdtls_diags: list[lsp.Diagnostic] = []
228
243
  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)
244
+ try:
245
+ raw = server._proxy.get_cached_diagnostics(uri)
246
+ raw = [d for d in raw if not _is_jdtls_suppressed(d, server._user_suppress_patterns)]
247
+ jdtls_diags = _jdtls_raw_to_lsp_diagnostics(raw)
248
+ except Exception as e:
249
+ logger.warning("jdtls diagnostic processing failed for %s: %s", uri, e)
233
250
 
234
251
  return jdtls_diags + custom_diags
235
252
 
@@ -257,6 +274,7 @@ def on_initialize(params: lsp.InitializeParams) -> lsp.InitializeResult:
257
274
  root = params.root_path
258
275
 
259
276
  server._config = _load_config(root)
277
+ server._user_suppress_patterns = _compile_user_patterns(server._config)
260
278
 
261
279
  return lsp.InitializeResult(
262
280
  capabilities=lsp.ServerCapabilities(
@@ -363,7 +381,10 @@ def _analyze_and_publish(uri: str) -> None:
363
381
  async def _deferred_validate(uri: str) -> None:
364
382
  """Debounced validation — waits before analyzing to batch rapid edits."""
365
383
  await asyncio.sleep(_DEBOUNCE_SECONDS)
366
- _analyze_and_publish(uri)
384
+ try:
385
+ _analyze_and_publish(uri)
386
+ except Exception as e:
387
+ logger.error("Validation failed for %s: %s", uri, e)
367
388
 
368
389
 
369
390
  def _forward_or_queue(method: str, serialized: Any) -> None:
@@ -402,7 +423,10 @@ async def on_did_open(params: lsp.DidOpenTextDocumentParams) -> None:
402
423
  task.add_done_callback(_bg_tasks.discard)
403
424
 
404
425
  # Custom diagnostics always publish immediately — never blocked by jdtls.
405
- _analyze_and_publish(uri)
426
+ try:
427
+ _analyze_and_publish(uri)
428
+ except Exception as e:
429
+ logger.error("Analysis failed on open for %s: %s", uri, e)
406
430
 
407
431
 
408
432
  @server.feature(lsp.TEXT_DOCUMENT_DID_CHANGE)
@@ -420,7 +444,10 @@ async def on_did_change(params: lsp.DidChangeTextDocumentParams) -> None:
420
444
  async def on_did_save(params: lsp.DidSaveTextDocumentParams) -> None:
421
445
  """Forward to jdtls and re-analyze immediately (no debounce on save)."""
422
446
  _forward_or_queue("textDocument/didSave", _serialize_params(params))
423
- _analyze_and_publish(params.text_document.uri)
447
+ try:
448
+ _analyze_and_publish(params.text_document.uri)
449
+ except Exception as e:
450
+ logger.error("Analysis failed on save for %s: %s", params.text_document.uri, e)
424
451
 
425
452
 
426
453
  @server.feature(lsp.TEXT_DOCUMENT_DID_CLOSE)
@@ -647,55 +647,107 @@ 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
696
740
 
697
- diag = {"code": "33554513", "message": "The blank final field name may not have been initialized"}
698
- assert _is_lombok_false_positive(diag) is True
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
699
751
 
700
752
 
701
753
  class TestFindLombokJar:
@@ -761,6 +813,51 @@ class TestFindLombokJar:
761
813
  assert any("does not exist" in r.getMessage() for r in caplog.records)
762
814
 
763
815
 
816
+ class TestCacheClear:
817
+ """Tests for _clear_cache_on_version_change()."""
818
+
819
+ def test_clears_on_version_mismatch(self, tmp_path: Any) -> None:
820
+ from java_functional_lsp.proxy import _clear_cache_on_version_change
821
+
822
+ cache = tmp_path / "jdtls-data"
823
+ cache.mkdir()
824
+ stale_dir = cache / "abc123"
825
+ stale_dir.mkdir()
826
+ (cache / ".version").write_text("0.7.9")
827
+
828
+ _clear_cache_on_version_change(cache)
829
+
830
+ assert not stale_dir.exists(), "Stale data-dir should be cleared"
831
+ assert cache.exists(), "Cache root should be recreated"
832
+ from java_functional_lsp import __version__
833
+
834
+ assert (cache / ".version").read_text() == __version__
835
+
836
+ def test_keeps_cache_on_same_version(self, tmp_path: Any) -> None:
837
+ from java_functional_lsp import __version__
838
+ from java_functional_lsp.proxy import _clear_cache_on_version_change
839
+
840
+ cache = tmp_path / "jdtls-data"
841
+ cache.mkdir()
842
+ existing_dir = cache / "abc123"
843
+ existing_dir.mkdir()
844
+ (cache / ".version").write_text(__version__)
845
+
846
+ _clear_cache_on_version_change(cache)
847
+
848
+ assert existing_dir.exists(), "Existing data-dir should be preserved"
849
+
850
+ def test_creates_marker_on_first_run(self, tmp_path: Any) -> None:
851
+ from java_functional_lsp.proxy import _clear_cache_on_version_change
852
+
853
+ cache = tmp_path / "jdtls-data"
854
+ _clear_cache_on_version_change(cache)
855
+
856
+ from java_functional_lsp import __version__
857
+
858
+ assert (cache / ".version").read_text() == __version__
859
+
860
+
764
861
  # Subprocess-based tests — zero mocks, real LSP transport
765
862
  # --------------------------------------------------------------------------
766
863
 
@@ -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.0"
188
188
  source = { editable = "." }
189
189
  dependencies = [
190
190
  { name = "pygls" },