java-functional-lsp 0.7.8__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.8 → java_functional_lsp-0.8.0}/PKG-INFO +33 -1
  2. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/README.md +32 -0
  3. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/pyproject.toml +1 -1
  4. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/src/java_functional_lsp/__init__.py +1 -1
  5. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/src/java_functional_lsp/proxy.py +121 -20
  6. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/src/java_functional_lsp/server.py +59 -7
  7. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/tests/test_proxy.py +2 -2
  8. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/tests/test_server.py +216 -0
  9. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/uv.lock +1 -1
  10. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/.claude-plugin/plugin.json +0 -0
  11. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/.githooks/pre-commit +0 -0
  12. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/.githooks/pre-push +0 -0
  13. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/.github/CODEOWNERS +0 -0
  14. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/.github/ISSUE_TEMPLATE/bug-report.md +0 -0
  15. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/.github/ISSUE_TEMPLATE/feature-request.md +0 -0
  16. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  17. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/.github/SECURITY.md +0 -0
  18. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/.github/dependabot.yml +0 -0
  19. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/.github/release-drafter.yml +0 -0
  20. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/.github/workflows/publish.yml +0 -0
  21. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/.github/workflows/release-drafter.yml +0 -0
  22. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/.github/workflows/stale.yml +0 -0
  23. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/.github/workflows/test.yml +0 -0
  24. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/.github/workflows/update-homebrew.yml +0 -0
  25. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/.github/workflows/vscode-ext.yml +0 -0
  26. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/.gitignore +0 -0
  27. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/CONTRIBUTING.md +0 -0
  28. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/LICENSE +0 -0
  29. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/SKILL.md +0 -0
  30. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/commands/lint-java.md +0 -0
  31. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/editors/intellij/README.md +0 -0
  32. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/editors/intellij/lsp4ij-template.json +0 -0
  33. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/editors/vscode/.vscodeignore +0 -0
  34. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/editors/vscode/README.md +0 -0
  35. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/editors/vscode/package-lock.json +0 -0
  36. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/editors/vscode/package.json +0 -0
  37. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/editors/vscode/src/extension.ts +0 -0
  38. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/editors/vscode/tsconfig.json +0 -0
  39. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/hooks/hooks.json +0 -0
  40. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/hooks/java_linter_reminder.py +0 -0
  41. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/scripts/ensure-lsp.sh +0 -0
  42. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/scripts/generate-formula.py +0 -0
  43. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/src/java_functional_lsp/__main__.py +0 -0
  44. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/src/java_functional_lsp/analyzers/__init__.py +0 -0
  45. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/src/java_functional_lsp/analyzers/base.py +0 -0
  46. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/src/java_functional_lsp/analyzers/exception_checker.py +0 -0
  47. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/src/java_functional_lsp/analyzers/functional_checker.py +0 -0
  48. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/src/java_functional_lsp/analyzers/mutation_checker.py +0 -0
  49. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/src/java_functional_lsp/analyzers/null_checker.py +0 -0
  50. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/src/java_functional_lsp/analyzers/spring_checker.py +0 -0
  51. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/src/java_functional_lsp/cli.py +0 -0
  52. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/src/java_functional_lsp/fixes.py +0 -0
  53. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/tests/__init__.py +0 -0
  54. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/tests/conftest.py +0 -0
  55. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/tests/test_base.py +0 -0
  56. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/tests/test_cli.py +0 -0
  57. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/tests/test_config.py +0 -0
  58. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/tests/test_e2e.py +0 -0
  59. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/tests/test_e2e_jdtls.py +0 -0
  60. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/tests/test_exception_checker.py +0 -0
  61. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/tests/test_fixes.py +0 -0
  62. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/tests/test_functional_checker.py +0 -0
  63. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/tests/test_mutation_checker.py +0 -0
  64. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/tests/test_null_checker.py +0 -0
  65. {java_functional_lsp-0.7.8 → java_functional_lsp-0.8.0}/tests/test_spring_checker.py +0 -0
  66. {java_functional_lsp-0.7.8 → 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.8
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
@@ -253,6 +254,37 @@ public String legacyMethod() { ... }
253
254
 
254
255
  Works on classes, methods, constructors, fields, and local variables. Suppression applies to the annotated scope — a class-level annotation suppresses all methods within it.
255
256
 
257
+ ### Lombok support
258
+
259
+ Projects using [Lombok](https://projectlombok.org/) need the Lombok Java agent for jdtls to process `@Builder`, `@Value`, `@Data`, `@Slf4j`, and other annotations. Without it, jdtls reports false "method undefined" errors for generated methods.
260
+
261
+ The server auto-discovers `lombok.jar` from these locations (first match wins):
262
+
263
+ 1. **Project config** — add to `.java-functional-lsp.json`:
264
+ ```json
265
+ { "lombok": "/path/to/lombok.jar" }
266
+ ```
267
+ 2. **Environment variable** — `LOMBOK_JAR=/path/to/lombok.jar`
268
+ 3. **Maven cache** — auto-discovered from `~/.m2/repository/org/projectlombok/lombok/`
269
+ 4. **Dedicated directory** — `~/.jdtls-libs/lombok.jar`
270
+
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.
287
+
256
288
  ## Code actions (quick fixes)
257
289
 
258
290
  The server provides LSP code actions (`textDocument/codeAction`) that automatically refactor code. When your editor shows a diagnostic with a lightbulb icon, clicking it applies the fix:
@@ -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
@@ -225,6 +226,37 @@ public String legacyMethod() { ... }
225
226
 
226
227
  Works on classes, methods, constructors, fields, and local variables. Suppression applies to the annotated scope — a class-level annotation suppresses all methods within it.
227
228
 
229
+ ### Lombok support
230
+
231
+ Projects using [Lombok](https://projectlombok.org/) need the Lombok Java agent for jdtls to process `@Builder`, `@Value`, `@Data`, `@Slf4j`, and other annotations. Without it, jdtls reports false "method undefined" errors for generated methods.
232
+
233
+ The server auto-discovers `lombok.jar` from these locations (first match wins):
234
+
235
+ 1. **Project config** — add to `.java-functional-lsp.json`:
236
+ ```json
237
+ { "lombok": "/path/to/lombok.jar" }
238
+ ```
239
+ 2. **Environment variable** — `LOMBOK_JAR=/path/to/lombok.jar`
240
+ 3. **Maven cache** — auto-discovered from `~/.m2/repository/org/projectlombok/lombok/`
241
+ 4. **Dedicated directory** — `~/.jdtls-libs/lombok.jar`
242
+
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.
259
+
228
260
  ## Code actions (quick fixes)
229
261
 
230
262
  The server provides LSP code actions (`textDocument/codeAction`) that automatically refactor code. When your editor shows a diagnostic with a lightbulb icon, clicking it applies the fix:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "java-functional-lsp"
7
- version = "0.7.8"
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.8"
3
+ __version__ = "0.8.0"
@@ -433,6 +433,81 @@ def _resolve_module_uri(file_uri: str) -> str | None:
433
433
  return module_uri or None
434
434
 
435
435
 
436
+ def _version_key(name: str) -> tuple[int, ...]:
437
+ """Parse a version string into a tuple for semantic comparison."""
438
+ parts: list[int] = []
439
+ for segment in re.split(r"[.\-]", name):
440
+ try:
441
+ parts.append(int(segment))
442
+ except ValueError:
443
+ break
444
+ return tuple(parts)
445
+
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
+
470
+ def _find_lombok_jar(config: Mapping[str, Any] | None = None) -> str | None:
471
+ """Find lombok.jar from configurable and auto-discovered locations.
472
+
473
+ Search order (first match wins):
474
+ 1. Project config (.java-functional-lsp.json): ``{"lombok": "/path/to/lombok.jar"}``
475
+ 2. Environment variable: ``LOMBOK_JAR``
476
+ 3. Maven cache: ``~/.m2/repository/org/projectlombok/lombok/*/lombok-*.jar``
477
+ 4. Dedicated directory: ``~/.jdtls-libs/lombok.jar``
478
+ """
479
+ # 1. Project config
480
+ if config and config.get("lombok"):
481
+ p = Path(config["lombok"]).expanduser()
482
+ if p.is_file():
483
+ return str(p)
484
+ logger.warning("Lombok path from config does not exist: %s", _redact_path(str(p)))
485
+
486
+ # 2. Environment variable
487
+ env_path = os.environ.get("LOMBOK_JAR")
488
+ if env_path:
489
+ p = Path(env_path).expanduser()
490
+ if p.is_file():
491
+ return str(p)
492
+ logger.warning("LOMBOK_JAR does not exist: %s", _redact_path(env_path))
493
+
494
+ # 3. Maven cache (latest version, semantic sort)
495
+ m2_lombok = Path.home() / ".m2" / "repository" / "org" / "projectlombok" / "lombok"
496
+ if m2_lombok.is_dir():
497
+ version_dirs = [d for d in m2_lombok.iterdir() if d.is_dir()]
498
+ for version_dir in sorted(version_dirs, key=lambda d: _version_key(d.name), reverse=True):
499
+ jar = version_dir / f"lombok-{version_dir.name}.jar"
500
+ if jar.is_file():
501
+ return str(jar)
502
+
503
+ # 4. Dedicated directory
504
+ fallback = Path.home() / ".jdtls-libs" / "lombok.jar"
505
+ if fallback.is_file():
506
+ return str(fallback)
507
+
508
+ return None
509
+
510
+
436
511
  class JdtlsProxy:
437
512
  """Manages a jdtls subprocess and provides async request/notification forwarding."""
438
513
 
@@ -457,6 +532,7 @@ class JdtlsProxy:
457
532
  self._original_root_uri: str | None = None
458
533
  self._initial_module_uri: str | None = None
459
534
  self.modules = ModuleRegistry()
535
+ self.has_lombok = False
460
536
  self._workspace_expanded = False
461
537
 
462
538
  @property
@@ -480,7 +556,13 @@ class JdtlsProxy:
480
556
  logger.warning("jdtls not found on PATH — running in standalone mode (custom rules only)")
481
557
  return self._jdtls_on_path
482
558
 
483
- async def start(self, init_params: dict[str, Any], *, module_root_uri: str | None = None) -> bool:
559
+ async def start(
560
+ self,
561
+ init_params: dict[str, Any],
562
+ *,
563
+ module_root_uri: str | None = None,
564
+ config: Mapping[str, Any] | None = None,
565
+ ) -> bool:
484
566
  """Start jdtls subprocess and initialize it.
485
567
 
486
568
  If *module_root_uri* is provided, jdtls is scoped to that module for
@@ -491,20 +573,35 @@ class JdtlsProxy:
491
573
  if not jdtls_path:
492
574
  return False
493
575
 
494
- # Data-dir hash: use module URI when scoped so each module gets its own
495
- # clean jdtls state. This avoids loading a 2.5GB monorepo index when only
496
- # one module is needed. Without expansion, each session is module-scoped,
497
- # so the data-dir should match that scope.
498
576
  original_root: str = init_params.get("rootUri") or init_params.get("rootPath") or str(Path.cwd())
499
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
+
500
598
  hash_source = module_root_uri or original_root
501
599
  data_hash = hashlib.sha256(hash_source.encode()).hexdigest()[:12]
502
- data_dir = Path.home() / ".cache" / "jdtls-data" / data_hash
600
+ data_dir = cache_root / data_hash
503
601
  data_dir.mkdir(parents=True, exist_ok=True)
504
602
 
505
603
  # Deep copy to avoid mutating server._init_params.
506
604
  effective_params = copy.deepcopy(init_params)
507
- effective_root_uri = module_root_uri or original_root
508
605
  if module_root_uri:
509
606
  effective_params["rootUri"] = module_root_uri
510
607
  from pygls.uris import to_fs_path
@@ -521,20 +618,18 @@ class JdtlsProxy:
521
618
  ws = caps.setdefault("workspace", {})
522
619
  ws["workspaceFolders"] = True
523
620
 
524
- # Track the initial module as already loaded (mark ADDED before await).
525
- self._initial_module_uri = module_root_uri
526
- self.modules.mark_added(effective_root_uri)
527
-
528
- # Build a clean environment for jdtls.
529
- loop = asyncio.get_running_loop()
530
- jdtls_env = await loop.run_in_executor(None, build_jdtls_env)
621
+ jdtls_cmd: list[str] = [
622
+ jdtls_path,
623
+ "-data",
624
+ str(data_dir),
625
+ f"--jvm-arg=-Xmx{DEFAULT_JVM_MAX_HEAP}",
626
+ ]
627
+ if lombok_jar:
628
+ jdtls_cmd.append(f"--jvm-arg=-javaagent:{lombok_jar}")
531
629
 
532
630
  try:
533
631
  self._process = await asyncio.create_subprocess_exec(
534
- jdtls_path,
535
- "-data",
536
- str(data_dir),
537
- f"--jvm-arg=-Xmx{DEFAULT_JVM_MAX_HEAP}",
632
+ *jdtls_cmd,
538
633
  stdin=asyncio.subprocess.PIPE,
539
634
  stdout=asyncio.subprocess.PIPE,
540
635
  stderr=asyncio.subprocess.PIPE,
@@ -563,13 +658,19 @@ class JdtlsProxy:
563
658
 
564
659
  await self.send_notification("initialized", {})
565
660
  self._available = True
661
+ self.has_lombok = lombok_jar is not None
566
662
  return True
567
663
 
568
664
  except (OSError, FileNotFoundError) as e:
569
665
  logger.error("Failed to start jdtls: %s", e)
570
666
  return False
571
667
 
572
- async def ensure_started(self, init_params: dict[str, Any], file_uri: str) -> bool:
668
+ async def ensure_started(
669
+ self,
670
+ init_params: dict[str, Any],
671
+ file_uri: str,
672
+ config: Mapping[str, Any] | None = None,
673
+ ) -> bool:
573
674
  """Start jdtls lazily, scoped to the module containing *file_uri*.
574
675
 
575
676
  Thread-safe: uses asyncio.Lock to prevent double-start from rapid
@@ -596,7 +697,7 @@ class JdtlsProxy:
596
697
  self._starting = True
597
698
  try:
598
699
  module_uri = _resolve_module_uri(file_uri)
599
- started = await self.start(init_params, module_root_uri=module_uri)
700
+ started = await self.start(init_params, module_root_uri=module_uri, config=config)
600
701
  if not started:
601
702
  self._start_failed = True
602
703
  self._start_failed_at = time.monotonic()
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
  import asyncio
10
10
  import json
11
11
  import logging
12
+ import re
12
13
  import sys
13
14
  from pathlib import Path
14
15
  from typing import Any
@@ -64,6 +65,7 @@ class JavaFunctionalLspServer(LanguageServer):
64
65
  self._config: dict[str, Any] = {}
65
66
  self._init_params: dict[str, Any] = {}
66
67
  self._proxy = JdtlsProxy(on_diagnostics=self._on_jdtls_diagnostics)
68
+ self._user_suppress_patterns: list[re.Pattern[str]] = []
67
69
 
68
70
  def _on_jdtls_diagnostics(self, uri: str, diagnostics: list[Any]) -> None:
69
71
  """Called when jdtls publishes diagnostics — merge with custom and re-publish.
@@ -197,14 +199,54 @@ def _jdtls_raw_to_lsp_diagnostics(raw_diagnostics: list[Any]) -> list[lsp.Diagno
197
199
  return result
198
200
 
199
201
 
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
217
+
218
+
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.
221
+
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.
225
+ """
226
+ if not user_patterns:
227
+ return False
228
+ msg = diag.get("message", "")
229
+ for pat in user_patterns:
230
+ if pat.search(msg):
231
+ return True
232
+ return False
233
+
234
+
200
235
  def _run_analysis(source: str, uri: str) -> list[lsp.Diagnostic]:
201
- """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
+ """
202
240
  custom_diags = _analyze_document(source, uri)
203
241
 
204
242
  jdtls_diags: list[lsp.Diagnostic] = []
205
243
  if server._proxy.is_available:
206
- raw = server._proxy.get_cached_diagnostics(uri)
207
- 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)
208
250
 
209
251
  return jdtls_diags + custom_diags
210
252
 
@@ -232,6 +274,7 @@ def on_initialize(params: lsp.InitializeParams) -> lsp.InitializeResult:
232
274
  root = params.root_path
233
275
 
234
276
  server._config = _load_config(root)
277
+ server._user_suppress_patterns = _compile_user_patterns(server._config)
235
278
 
236
279
  return lsp.InitializeResult(
237
280
  capabilities=lsp.ServerCapabilities(
@@ -338,7 +381,10 @@ def _analyze_and_publish(uri: str) -> None:
338
381
  async def _deferred_validate(uri: str) -> None:
339
382
  """Debounced validation — waits before analyzing to batch rapid edits."""
340
383
  await asyncio.sleep(_DEBOUNCE_SECONDS)
341
- _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)
342
388
 
343
389
 
344
390
  def _forward_or_queue(method: str, serialized: Any) -> None:
@@ -377,7 +423,10 @@ async def on_did_open(params: lsp.DidOpenTextDocumentParams) -> None:
377
423
  task.add_done_callback(_bg_tasks.discard)
378
424
 
379
425
  # Custom diagnostics always publish immediately — never blocked by jdtls.
380
- _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)
381
430
 
382
431
 
383
432
  @server.feature(lsp.TEXT_DOCUMENT_DID_CHANGE)
@@ -395,7 +444,10 @@ async def on_did_change(params: lsp.DidChangeTextDocumentParams) -> None:
395
444
  async def on_did_save(params: lsp.DidSaveTextDocumentParams) -> None:
396
445
  """Forward to jdtls and re-analyze immediately (no debounce on save)."""
397
446
  _forward_or_queue("textDocument/didSave", _serialize_params(params))
398
- _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)
399
451
 
400
452
 
401
453
  @server.feature(lsp.TEXT_DOCUMENT_DID_CLOSE)
@@ -419,7 +471,7 @@ async def _lazy_start_jdtls(file_uri: str) -> None:
419
471
  are loaded on-demand via ``add_module_if_new()`` as files are opened.
420
472
  """
421
473
  try:
422
- started = await server._proxy.ensure_started(server._init_params, file_uri)
474
+ started = await server._proxy.ensure_started(server._init_params, file_uri, config=server._config)
423
475
  if started:
424
476
  logger.info("jdtls proxy active — full Java language support enabled")
425
477
  await _register_jdtls_capabilities()
@@ -1103,7 +1103,7 @@ class TestLazyStart:
1103
1103
  proxy._jdtls_on_path = True
1104
1104
  captured: dict[str, Any] = {}
1105
1105
 
1106
- async def capturing_start(params: Any, *, module_root_uri: str | None = None) -> bool:
1106
+ async def capturing_start(params: Any, *, module_root_uri: str | None = None, config: Any = None) -> bool:
1107
1107
  captured["module_root_uri"] = module_root_uri
1108
1108
  return False
1109
1109
 
@@ -1127,7 +1127,7 @@ class TestLazyStart:
1127
1127
 
1128
1128
  captured: dict[str, Any] = {}
1129
1129
 
1130
- async def capturing_start(params: Any, *, module_root_uri: str | None = None) -> bool:
1130
+ async def capturing_start(params: Any, *, module_root_uri: str | None = None, config: Any = None) -> bool:
1131
1131
  captured["module_root_uri"] = module_root_uri
1132
1132
  return False
1133
1133
 
@@ -642,6 +642,222 @@ class TestServerInternals:
642
642
 
643
643
 
644
644
  # --------------------------------------------------------------------------
645
+ # --------------------------------------------------------------------------
646
+ # Lombok support tests
647
+ # --------------------------------------------------------------------------
648
+
649
+
650
+ class TestUserSuppressPatterns:
651
+ """Tests for configurable suppressJdtlsPatterns."""
652
+
653
+ def test_user_pattern_matches(self) -> None:
654
+ from java_functional_lsp.server import _compile_user_patterns, _is_jdtls_suppressed
655
+
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
659
+
660
+ def test_user_pattern_does_not_match_unrelated(self) -> None:
661
+ from java_functional_lsp.server import _compile_user_patterns, _is_jdtls_suppressed
662
+
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
666
+
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
670
+
671
+ diag = {"message": "The method builder() is undefined for the type Foo"}
672
+ assert _is_jdtls_suppressed(diag, []) is False
673
+
674
+ def test_invalid_regex_skipped(self) -> None:
675
+ from java_functional_lsp.server import _compile_user_patterns
676
+
677
+ patterns = _compile_user_patterns({"suppressJdtlsPatterns": [r"valid", r"[invalid"]})
678
+ assert len(patterns) == 1
679
+ assert patterns[0].search("valid") is not None
680
+
681
+ def test_non_list_ignored(self) -> None:
682
+ from java_functional_lsp.server import _compile_user_patterns
683
+
684
+ patterns = _compile_user_patterns({"suppressJdtlsPatterns": "not a list"})
685
+ assert patterns == []
686
+
687
+ def test_empty_config(self) -> None:
688
+ from java_functional_lsp.server import _compile_user_patterns
689
+
690
+ patterns = _compile_user_patterns({})
691
+ assert patterns == []
692
+
693
+
694
+ class TestJdtlsIsolation:
695
+ """Tests that jdtls failures don't suppress custom diagnostics."""
696
+
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 TestFindLombokJar:
754
+ """Tests for _find_lombok_jar()."""
755
+
756
+ def test_config_path_wins(self, tmp_path: Any) -> None:
757
+ from java_functional_lsp.proxy import _find_lombok_jar
758
+
759
+ jar = tmp_path / "lombok.jar"
760
+ jar.touch()
761
+ result = _find_lombok_jar({"lombok": str(jar)})
762
+ assert result == str(jar)
763
+
764
+ def test_env_var_used(self, tmp_path: Any, monkeypatch: Any) -> None:
765
+ from java_functional_lsp.proxy import _find_lombok_jar
766
+
767
+ jar = tmp_path / "lombok.jar"
768
+ jar.touch()
769
+ monkeypatch.setenv("LOMBOK_JAR", str(jar))
770
+ result = _find_lombok_jar()
771
+ assert result == str(jar)
772
+
773
+ def test_config_wins_over_env(self, tmp_path: Any, monkeypatch: Any) -> None:
774
+ from java_functional_lsp.proxy import _find_lombok_jar
775
+
776
+ config_jar = tmp_path / "config-lombok.jar"
777
+ config_jar.touch()
778
+ env_jar = tmp_path / "env-lombok.jar"
779
+ env_jar.touch()
780
+ monkeypatch.setenv("LOMBOK_JAR", str(env_jar))
781
+ result = _find_lombok_jar({"lombok": str(config_jar)})
782
+ assert result == str(config_jar)
783
+
784
+ def test_maven_cache_semantic_sort(self, tmp_path: Any, monkeypatch: Any) -> None:
785
+ from java_functional_lsp.proxy import _find_lombok_jar
786
+
787
+ monkeypatch.delenv("LOMBOK_JAR", raising=False)
788
+ m2 = tmp_path / ".m2" / "repository" / "org" / "projectlombok" / "lombok"
789
+ for v in ["1.18.4", "1.18.30", "1.9.2"]:
790
+ d = m2 / v
791
+ d.mkdir(parents=True)
792
+ (d / f"lombok-{v}.jar").touch()
793
+ monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
794
+ result = _find_lombok_jar()
795
+ assert result is not None
796
+ assert "1.18.30" in result # Semantic sort picks 1.18.30 over 1.9.2
797
+
798
+ def test_returns_none_when_nothing_found(self, tmp_path: Any, monkeypatch: Any) -> None:
799
+ from java_functional_lsp.proxy import _find_lombok_jar
800
+
801
+ monkeypatch.delenv("LOMBOK_JAR", raising=False)
802
+ monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
803
+ result = _find_lombok_jar()
804
+ assert result is None
805
+
806
+ def test_warns_on_missing_config_path(self, tmp_path: Any, caplog: Any) -> None:
807
+ import logging
808
+
809
+ from java_functional_lsp.proxy import _find_lombok_jar
810
+
811
+ with caplog.at_level(logging.WARNING, logger="java_functional_lsp.proxy"):
812
+ _find_lombok_jar({"lombok": "/nonexistent/lombok.jar"})
813
+ assert any("does not exist" in r.getMessage() for r in caplog.records)
814
+
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
+
645
861
  # Subprocess-based tests — zero mocks, real LSP transport
646
862
  # --------------------------------------------------------------------------
647
863
 
@@ -184,7 +184,7 @@ wheels = [
184
184
 
185
185
  [[package]]
186
186
  name = "java-functional-lsp"
187
- version = "0.7.4"
187
+ version = "0.8.0"
188
188
  source = { editable = "." }
189
189
  dependencies = [
190
190
  { name = "pygls" },