java-functional-lsp 0.9.10__tar.gz → 0.9.12__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 (69) hide show
  1. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/PKG-INFO +32 -2
  2. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/README.md +31 -1
  3. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/pyproject.toml +1 -1
  4. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/src/java_functional_lsp/__init__.py +1 -1
  5. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/src/java_functional_lsp/proxy.py +92 -27
  6. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/src/java_functional_lsp/server.py +19 -0
  7. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/tests/test_proxy.py +52 -0
  8. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/tests/test_server.py +78 -13
  9. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/.claude-plugin/plugin.json +0 -0
  10. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/.githooks/pre-commit +0 -0
  11. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/.githooks/pre-push +0 -0
  12. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/.github/CODEOWNERS +0 -0
  13. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/.github/ISSUE_TEMPLATE/bug-report.md +0 -0
  14. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/.github/ISSUE_TEMPLATE/feature-request.md +0 -0
  15. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  16. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/.github/SECURITY.md +0 -0
  17. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/.github/dependabot.yml +0 -0
  18. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/.github/release-drafter.yml +0 -0
  19. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/.github/workflows/publish.yml +0 -0
  20. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/.github/workflows/release-drafter.yml +0 -0
  21. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/.github/workflows/stale.yml +0 -0
  22. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/.github/workflows/test.yml +0 -0
  23. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/.github/workflows/update-homebrew.yml +0 -0
  24. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/.github/workflows/vscode-ext.yml +0 -0
  25. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/.gitignore +0 -0
  26. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/CONTRIBUTING.md +0 -0
  27. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/LICENSE +0 -0
  28. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/SKILL.md +0 -0
  29. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/commands/lint-java.md +0 -0
  30. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/editors/intellij/README.md +0 -0
  31. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/editors/intellij/lsp4ij-template.json +0 -0
  32. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/editors/vscode/.vscodeignore +0 -0
  33. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/editors/vscode/README.md +0 -0
  34. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/editors/vscode/package-lock.json +0 -0
  35. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/editors/vscode/package.json +0 -0
  36. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/editors/vscode/src/extension.ts +0 -0
  37. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/editors/vscode/tsconfig.json +0 -0
  38. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/hooks/hooks.json +0 -0
  39. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/hooks/java_linter_reminder.py +0 -0
  40. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/scripts/ensure-lsp.sh +0 -0
  41. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/scripts/generate-formula.py +0 -0
  42. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/src/java_functional_lsp/__main__.py +0 -0
  43. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/src/java_functional_lsp/analyzers/__init__.py +0 -0
  44. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/src/java_functional_lsp/analyzers/base.py +0 -0
  45. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/src/java_functional_lsp/analyzers/exception_checker.py +0 -0
  46. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/src/java_functional_lsp/analyzers/functional_checker.py +0 -0
  47. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/src/java_functional_lsp/analyzers/mutation_checker.py +0 -0
  48. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/src/java_functional_lsp/analyzers/null_checker.py +0 -0
  49. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/src/java_functional_lsp/analyzers/spring_checker.py +0 -0
  50. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/src/java_functional_lsp/cli.py +0 -0
  51. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/src/java_functional_lsp/fixes.py +0 -0
  52. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/src/java_functional_lsp/merkle.py +0 -0
  53. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/tests/__init__.py +0 -0
  54. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/tests/conftest.py +0 -0
  55. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/tests/test_base.py +0 -0
  56. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/tests/test_cli.py +0 -0
  57. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/tests/test_config.py +0 -0
  58. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/tests/test_e2e.py +0 -0
  59. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/tests/test_e2e_jdtls.py +0 -0
  60. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/tests/test_exception_checker.py +0 -0
  61. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/tests/test_fixes.py +0 -0
  62. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/tests/test_functional_checker.py +0 -0
  63. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/tests/test_merkle.py +0 -0
  64. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/tests/test_merkle_proxy.py +0 -0
  65. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/tests/test_mutation_checker.py +0 -0
  66. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/tests/test_null_checker.py +0 -0
  67. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/tests/test_spring_checker.py +0 -0
  68. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/tests/test_suppress.py +0 -0
  69. {java_functional_lsp-0.9.10 → java_functional_lsp-0.9.12}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: java-functional-lsp
3
- Version: 0.9.10
3
+ Version: 0.9.12
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
@@ -270,7 +270,37 @@ The server auto-discovers `lombok.jar` from these locations (first match wins):
270
270
  3. **Maven cache** — auto-discovered from `~/.m2/repository/org/projectlombok/lombok/`
271
271
  4. **Dedicated directory** — `~/.jdtls-libs/lombok.jar`
272
272
 
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`.
273
+ If Lombok is used in your project but the jar isn't found, the server logs a warning.
274
+
275
+ ### jdtls settings
276
+
277
+ The server sends Maven/Gradle settings to jdtls at startup via `initializationOptions.settings`. Defaults are optimized for Maven monorepos (Maven enabled, Gradle disabled, build artifact exclusions). Override via `.java-functional-lsp.json`:
278
+
279
+ ```json
280
+ {
281
+ "jdtls": {
282
+ "settings": {
283
+ "java": {
284
+ "import": {
285
+ "maven": { "enabled": true },
286
+ "gradle": { "enabled": true },
287
+ "exclusions": ["**/node_modules/**", "**/target/**"]
288
+ },
289
+ "configuration": { "updateBuildConfiguration": "automatic" },
290
+ "maven": { "downloadSources": true }
291
+ }
292
+ }
293
+ }
294
+ }
295
+ ```
296
+
297
+ Custom settings fully replace the defaults (no merge). See the [jdtls Preferences reference](https://github.com/eclipse-jdtls/eclipse.jdt.ls/blob/main/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/preferences/Preferences.java) for all available keys.
298
+
299
+ ### jdtls cache
300
+
301
+ The jdtls Eclipse workspace index is cached in `~/.cache/jdtls-data/`. Warm starts (~10-20s) reuse this cache; cold starts (60-120s) rebuild from scratch. The cache is automatically invalidated when jdtls or Java is upgraded, but **not** when java-functional-lsp is upgraded — our Python code changes don't affect the Eclipse index.
302
+
303
+ To force a clean rebuild: `rm -rf ~/.cache/jdtls-data/`
274
304
 
275
305
  ### Suppressing jdtls diagnostics
276
306
 
@@ -242,7 +242,37 @@ The server auto-discovers `lombok.jar` from these locations (first match wins):
242
242
  3. **Maven cache** — auto-discovered from `~/.m2/repository/org/projectlombok/lombok/`
243
243
  4. **Dedicated directory** — `~/.jdtls-libs/lombok.jar`
244
244
 
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`.
245
+ If Lombok is used in your project but the jar isn't found, the server logs a warning.
246
+
247
+ ### jdtls settings
248
+
249
+ The server sends Maven/Gradle settings to jdtls at startup via `initializationOptions.settings`. Defaults are optimized for Maven monorepos (Maven enabled, Gradle disabled, build artifact exclusions). Override via `.java-functional-lsp.json`:
250
+
251
+ ```json
252
+ {
253
+ "jdtls": {
254
+ "settings": {
255
+ "java": {
256
+ "import": {
257
+ "maven": { "enabled": true },
258
+ "gradle": { "enabled": true },
259
+ "exclusions": ["**/node_modules/**", "**/target/**"]
260
+ },
261
+ "configuration": { "updateBuildConfiguration": "automatic" },
262
+ "maven": { "downloadSources": true }
263
+ }
264
+ }
265
+ }
266
+ }
267
+ ```
268
+
269
+ Custom settings fully replace the defaults (no merge). See the [jdtls Preferences reference](https://github.com/eclipse-jdtls/eclipse.jdt.ls/blob/main/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/preferences/Preferences.java) for all available keys.
270
+
271
+ ### jdtls cache
272
+
273
+ The jdtls Eclipse workspace index is cached in `~/.cache/jdtls-data/`. Warm starts (~10-20s) reuse this cache; cold starts (60-120s) rebuild from scratch. The cache is automatically invalidated when jdtls or Java is upgraded, but **not** when java-functional-lsp is upgraded — our Python code changes don't affect the Eclipse index.
274
+
275
+ To force a clean rebuild: `rm -rf ~/.cache/jdtls-data/`
246
276
 
247
277
  ### Suppressing jdtls diagnostics
248
278
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "java-functional-lsp"
7
- version = "0.9.10"
7
+ version = "0.9.12"
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.9.10"
3
+ __version__ = "0.9.12"
@@ -30,6 +30,27 @@ DEFAULT_JVM_MAX_HEAP = "4g"
30
30
  _STDERR_LINE_MAX = 1000
31
31
  _MAX_EXPANDED_GROUPS: int = 5 # hard cap on concurrent Maven group workspace folders (prevents jdtls OOM)
32
32
 
33
+ # Default jdtls initialization settings. Sent via initializationOptions.settings
34
+ # so they apply BEFORE the Maven import scan (didChangeConfiguration is too late).
35
+ # Users can override via .java-functional-lsp.json: {"jdtls": {"settings": {...}}}
36
+ _DEFAULT_JDTLS_SETTINGS: dict[str, Any] = {
37
+ "java": {
38
+ "import": {
39
+ "maven": {"enabled": True},
40
+ "gradle": {"enabled": False},
41
+ "exclusions": [
42
+ "**/node_modules/**",
43
+ "**/.metadata/**",
44
+ "**/archetype-resources/**",
45
+ "**/META-INF/maven/**",
46
+ "**/target/**",
47
+ ],
48
+ },
49
+ "configuration": {"updateBuildConfiguration": "automatic"},
50
+ "maven": {"downloadSources": False},
51
+ }
52
+ }
53
+
33
54
  #: Minimum Java major version required by jdtls 1.57+. Anything below this
34
55
  #: is rejected by the jdtls Python launcher with ``"jdtls requires at least
35
56
  #: Java 21"`` before the server even starts.
@@ -558,25 +579,43 @@ def _wipe_data_dir(data_dir: Path) -> None:
558
579
  logger.info("jdtls: wiped data-dir %s after init timeout", _redact_path(str(data_dir)))
559
580
 
560
581
 
561
- def _clear_cache_on_version_change(cache_root: Path) -> None:
562
- """Clear jdtls data cache when the server version changes.
582
+ def _compute_cache_marker() -> str:
583
+ """Compute a stable cache marker from jdtls version path + Java version.
563
584
 
564
- Stores a ``.version`` marker in the cache root. On version mismatch
565
- (e.g., Lombok agent added, jdtls config changed), wipes the entire
566
- cache so jdtls re-indexes from scratch with the current configuration.
585
+ Our Python package version changes (pip upgrades) do NOT affect the jdtls
586
+ Eclipse workspace index — only jdtls binary upgrades or Java version
587
+ changes do. Resolves through symlinks so Homebrew Cellar version changes
588
+ (e.g. ``/opt/homebrew/Cellar/jdtls/1.58.0/...``) are detected even when
589
+ the wrapper script at ``/opt/homebrew/bin/jdtls`` doesn't change.
567
590
  """
568
- from . import __version__
591
+ parts: list[str] = []
592
+ jdtls_path = shutil.which("jdtls")
593
+ if jdtls_path:
594
+ resolved = str(Path(jdtls_path).resolve())
595
+ parts.append(hashlib.sha256(resolved.encode()).hexdigest()[:12])
596
+ parts.append(str(_MIN_JDTLS_JAVA_MAJOR))
597
+ return "|".join(parts)
598
+
599
+
600
+ def _clear_cache_on_version_change(cache_root: Path) -> None:
601
+ """Clear jdtls data cache when the jdtls installation changes.
569
602
 
603
+ Stores a ``.version`` marker in the cache root. The marker is derived
604
+ from the resolved jdtls binary path + Java version requirement — NOT
605
+ from our Python ``__version__``. This preserves warm caches across
606
+ java-functional-lsp upgrades (which don't affect the Eclipse index).
607
+ """
608
+ current_marker = _compute_cache_marker()
570
609
  marker = cache_root / ".version"
571
610
  try:
572
- if marker.exists() and marker.read_text().strip() == __version__:
611
+ if marker.exists() and marker.read_text().strip() == current_marker:
573
612
  return
574
- # Version changed or first run — clear stale caches.
613
+ # jdtls or Java version changed, or first run — clear stale caches.
575
614
  if cache_root.exists():
576
615
  shutil.rmtree(cache_root)
577
- logger.info("jdtls: cleared stale cache (version changed to %s)", __version__)
616
+ logger.info("jdtls: cleared stale cache (marker changed to %s)", current_marker)
578
617
  cache_root.mkdir(parents=True, exist_ok=True)
579
- marker.write_text(__version__)
618
+ marker.write_text(current_marker)
580
619
  except OSError as e:
581
620
  logger.warning("jdtls: failed to manage cache version marker: %s", e)
582
621
 
@@ -622,6 +661,48 @@ def _find_lombok_jar(config: Mapping[str, Any] | None = None) -> str | None:
622
661
  return None
623
662
 
624
663
 
664
+ def _build_effective_params(
665
+ init_params: dict[str, Any],
666
+ module_root_uri: str | None,
667
+ original_root: str,
668
+ config: Mapping[str, Any] | None,
669
+ ) -> dict[str, Any]:
670
+ """Build the ``initialize`` request params for jdtls.
671
+
672
+ Deep-copies *init_params*, scopes to *module_root_uri* if provided,
673
+ injects workspaceFolders capability, and injects Maven/Gradle settings
674
+ from *config* (or defaults) into ``initializationOptions.settings``.
675
+ """
676
+ from pygls.uris import to_fs_path
677
+
678
+ effective_params = copy.deepcopy(init_params)
679
+ if module_root_uri:
680
+ effective_params["rootUri"] = module_root_uri
681
+ effective_params["rootPath"] = to_fs_path(module_root_uri)
682
+ logger.info(
683
+ "jdtls: scoping to module %s (full root: %s)",
684
+ _redact_path(module_root_uri),
685
+ _redact_path(original_root),
686
+ )
687
+
688
+ # Inject workspaceFolders capability for later expansion.
689
+ caps = effective_params.setdefault("capabilities", {})
690
+ ws = caps.setdefault("workspace", {})
691
+ ws["workspaceFolders"] = True
692
+
693
+ # Inject jdtls Maven/Gradle settings into initializationOptions.
694
+ # Must be in initializationOptions (not didChangeConfiguration) so they
695
+ # apply before the Maven import scan starts.
696
+ jdtls_settings = copy.deepcopy(_DEFAULT_JDTLS_SETTINGS)
697
+ if config and "jdtls" in config and "settings" in config["jdtls"]:
698
+ jdtls_settings = copy.deepcopy(config["jdtls"]["settings"])
699
+ init_opts = effective_params.setdefault("initializationOptions", {})
700
+ init_opts["settings"] = jdtls_settings
701
+ logger.debug("jdtls: initializationOptions.settings = %s", jdtls_settings)
702
+
703
+ return effective_params
704
+
705
+
625
706
  class JdtlsProxy:
626
707
  """Manages a jdtls subprocess and provides async request/notification forwarding."""
627
708
 
@@ -724,23 +805,7 @@ class JdtlsProxy:
724
805
  data_dir = cache_root / data_hash
725
806
  data_dir.mkdir(parents=True, exist_ok=True)
726
807
 
727
- # Deep copy to avoid mutating server._init_params.
728
- effective_params = copy.deepcopy(init_params)
729
- if module_root_uri:
730
- effective_params["rootUri"] = module_root_uri
731
- from pygls.uris import to_fs_path
732
-
733
- effective_params["rootPath"] = to_fs_path(module_root_uri)
734
- logger.info(
735
- "jdtls: scoping to module %s (full root: %s)",
736
- _redact_path(module_root_uri),
737
- _redact_path(original_root),
738
- )
739
-
740
- # Inject workspaceFolders capability for later expansion.
741
- caps = effective_params.setdefault("capabilities", {})
742
- ws = caps.setdefault("workspace", {})
743
- ws["workspaceFolders"] = True
808
+ effective_params = _build_effective_params(init_params, module_root_uri, original_root, config)
744
809
 
745
810
  jdtls_cmd: list[str] = [
746
811
  jdtls_path,
@@ -12,6 +12,7 @@ import logging
12
12
  import os
13
13
  import re
14
14
  import sys
15
+ import time
15
16
  from collections.abc import Coroutine
16
17
  from pathlib import Path
17
18
  from typing import Any
@@ -635,6 +636,24 @@ async def on_did_close(params: lsp.DidCloseTextDocumentParams) -> None:
635
636
  _forward_or_queue("textDocument/didClose", _serialize_params(params))
636
637
 
637
638
 
639
+ _DIDCHANGE_CONFIG_COOLDOWN = 30.0 # seconds — rate-limit didChangeConfiguration forwarding
640
+ _last_config_forward: float = 0.0
641
+
642
+
643
+ @server.feature(lsp.WORKSPACE_DID_CHANGE_CONFIGURATION)
644
+ def on_did_change_configuration(params: lsp.DidChangeConfigurationParams) -> None:
645
+ """Forward IDE configuration changes to jdtls (rate-limited)."""
646
+ global _last_config_forward
647
+ if not server._proxy.is_available:
648
+ return
649
+ now = time.monotonic()
650
+ if now - _last_config_forward < _DIDCHANGE_CONFIG_COOLDOWN:
651
+ logger.debug("jdtls: throttling didChangeConfiguration (%.0fs cooldown)", _DIDCHANGE_CONFIG_COOLDOWN)
652
+ return
653
+ _last_config_forward = now
654
+ _fire_and_forget(server._proxy.send_notification("workspace/didChangeConfiguration", _serialize_params(params)))
655
+
656
+
638
657
  async def _lazy_start_jdtls(file_uri: str) -> None:
639
658
  """Background task: start jdtls scoped to the module containing *file_uri*.
640
659
 
@@ -1417,6 +1417,58 @@ class TestLazyStart:
1417
1417
  # ---------------------------------------------------------------------------
1418
1418
 
1419
1419
 
1420
+ class TestBuildEffectiveParams:
1421
+ """Tests for _build_effective_params."""
1422
+
1423
+ def test_defaults_injected_when_no_config(self) -> None:
1424
+ from java_functional_lsp.proxy import _build_effective_params
1425
+
1426
+ result = _build_effective_params({"rootUri": "file:///ws"}, None, "file:///ws", None)
1427
+ settings = result["initializationOptions"]["settings"]
1428
+ assert settings["java"]["import"]["maven"]["enabled"] is True
1429
+ assert settings["java"]["import"]["gradle"]["enabled"] is False
1430
+ assert "**/target/**" in settings["java"]["import"]["exclusions"]
1431
+
1432
+ def test_config_override_replaces_defaults(self) -> None:
1433
+ from java_functional_lsp.proxy import _build_effective_params
1434
+
1435
+ custom = {"java": {"custom": "value"}}
1436
+ config = {"jdtls": {"settings": custom}}
1437
+ result = _build_effective_params({"rootUri": "file:///ws"}, None, "file:///ws", config)
1438
+ settings = result["initializationOptions"]["settings"]
1439
+ assert settings == {"java": {"custom": "value"}}
1440
+ assert "import" not in settings.get("java", {})
1441
+
1442
+ def test_does_not_mutate_original_init_params(self) -> None:
1443
+ from java_functional_lsp.proxy import _build_effective_params
1444
+
1445
+ original = {"rootUri": "file:///ws", "capabilities": {}}
1446
+ _build_effective_params(original, "file:///mod", "file:///ws", None)
1447
+ assert "initializationOptions" not in original
1448
+ assert original["rootUri"] == "file:///ws"
1449
+
1450
+ def test_module_root_uri_scopes_root(self) -> None:
1451
+ from java_functional_lsp.proxy import _build_effective_params
1452
+
1453
+ result = _build_effective_params({"rootUri": "file:///ws"}, "file:///mod", "file:///ws", None)
1454
+ assert result["rootUri"] == "file:///mod"
1455
+
1456
+ def test_workspace_folders_capability_always_set(self) -> None:
1457
+ from java_functional_lsp.proxy import _build_effective_params
1458
+
1459
+ result = _build_effective_params({"rootUri": "file:///ws"}, None, "file:///ws", None)
1460
+ assert result["capabilities"]["workspace"]["workspaceFolders"] is True
1461
+
1462
+ def test_deepcopy_prevents_default_mutation(self) -> None:
1463
+ from java_functional_lsp.proxy import _DEFAULT_JDTLS_SETTINGS, _build_effective_params
1464
+
1465
+ result = _build_effective_params({"rootUri": "file:///ws"}, None, "file:///ws", None)
1466
+ # Mutate the returned settings
1467
+ result["initializationOptions"]["settings"]["java"]["import"]["exclusions"].append("MUTATED")
1468
+ # Module-level defaults must be unchanged
1469
+ assert "MUTATED" not in _DEFAULT_JDTLS_SETTINGS["java"]["import"]["exclusions"]
1470
+
1471
+
1420
1472
  class TestWipeDataDir:
1421
1473
  def test_wipe_removes_directory(self, tmp_path: Path) -> None:
1422
1474
  """Directory is removed and info is logged."""
@@ -1547,49 +1547,114 @@ class TestFindLombokJar:
1547
1547
  assert any("does not exist" in r.getMessage() for r in caplog.records)
1548
1548
 
1549
1549
 
1550
+ class TestDidChangeConfiguration:
1551
+ """Tests for workspace/didChangeConfiguration forwarding."""
1552
+
1553
+ def test_forwards_to_jdtls_when_available(self) -> None:
1554
+ from unittest.mock import AsyncMock, patch
1555
+
1556
+ import java_functional_lsp.server as srv_mod
1557
+ from java_functional_lsp.server import on_did_change_configuration, server
1558
+
1559
+ srv_mod._last_config_forward = 0.0 # reset cooldown
1560
+ server._proxy._available = True
1561
+ server._proxy.send_notification = AsyncMock() # type: ignore[assignment]
1562
+ params = lsp.DidChangeConfigurationParams(settings={"java": {"autobuild": {"enabled": True}}})
1563
+ with patch("java_functional_lsp.server._fire_and_forget") as mock_faf:
1564
+ on_did_change_configuration(params)
1565
+ mock_faf.assert_called_once()
1566
+
1567
+ def test_throttled_within_cooldown(self) -> None:
1568
+ from unittest.mock import patch
1569
+
1570
+ import java_functional_lsp.server as srv_mod
1571
+ from java_functional_lsp.server import on_did_change_configuration, server
1572
+
1573
+ server._proxy._available = True
1574
+ import time
1575
+
1576
+ srv_mod._last_config_forward = time.monotonic() # just sent
1577
+ params = lsp.DidChangeConfigurationParams(settings={})
1578
+ with patch("java_functional_lsp.server._fire_and_forget") as mock_faf:
1579
+ on_did_change_configuration(params)
1580
+ mock_faf.assert_not_called()
1581
+
1582
+ def test_skipped_when_proxy_unavailable(self) -> None:
1583
+ from unittest.mock import patch
1584
+
1585
+ from java_functional_lsp.server import on_did_change_configuration, server
1586
+
1587
+ server._proxy._available = False
1588
+ params = lsp.DidChangeConfigurationParams(settings={})
1589
+ with patch("java_functional_lsp.server._fire_and_forget") as mock_faf:
1590
+ on_did_change_configuration(params)
1591
+ mock_faf.assert_not_called()
1592
+
1593
+
1550
1594
  class TestCacheClear:
1551
- """Tests for _clear_cache_on_version_change()."""
1595
+ """Tests for _clear_cache_on_version_change() and _compute_cache_marker()."""
1552
1596
 
1553
1597
  def test_clears_on_version_mismatch(self, tmp_path: Any) -> None:
1554
- from java_functional_lsp.proxy import _clear_cache_on_version_change
1598
+ from java_functional_lsp.proxy import _clear_cache_on_version_change, _compute_cache_marker
1555
1599
 
1556
1600
  cache = tmp_path / "jdtls-data"
1557
1601
  cache.mkdir()
1558
1602
  stale_dir = cache / "abc123"
1559
1603
  stale_dir.mkdir()
1560
- (cache / ".version").write_text("0.7.9")
1604
+ (cache / ".version").write_text("old-stale-marker")
1561
1605
 
1562
1606
  _clear_cache_on_version_change(cache)
1563
1607
 
1564
1608
  assert not stale_dir.exists(), "Stale data-dir should be cleared"
1565
1609
  assert cache.exists(), "Cache root should be recreated"
1566
- from java_functional_lsp import __version__
1567
-
1568
- assert (cache / ".version").read_text() == __version__
1610
+ assert (cache / ".version").read_text() == _compute_cache_marker()
1569
1611
 
1570
- def test_keeps_cache_on_same_version(self, tmp_path: Any) -> None:
1571
- from java_functional_lsp import __version__
1572
- from java_functional_lsp.proxy import _clear_cache_on_version_change
1612
+ def test_keeps_cache_on_same_marker(self, tmp_path: Any) -> None:
1613
+ from java_functional_lsp.proxy import _clear_cache_on_version_change, _compute_cache_marker
1573
1614
 
1574
1615
  cache = tmp_path / "jdtls-data"
1575
1616
  cache.mkdir()
1576
1617
  existing_dir = cache / "abc123"
1577
1618
  existing_dir.mkdir()
1578
- (cache / ".version").write_text(__version__)
1619
+ (cache / ".version").write_text(_compute_cache_marker())
1579
1620
 
1580
1621
  _clear_cache_on_version_change(cache)
1581
1622
 
1582
1623
  assert existing_dir.exists(), "Existing data-dir should be preserved"
1583
1624
 
1584
1625
  def test_creates_marker_on_first_run(self, tmp_path: Any) -> None:
1585
- from java_functional_lsp.proxy import _clear_cache_on_version_change
1626
+ from java_functional_lsp.proxy import _clear_cache_on_version_change, _compute_cache_marker
1586
1627
 
1587
1628
  cache = tmp_path / "jdtls-data"
1588
1629
  _clear_cache_on_version_change(cache)
1589
1630
 
1590
- from java_functional_lsp import __version__
1631
+ assert (cache / ".version").read_text() == _compute_cache_marker()
1632
+
1633
+ def test_python_version_bump_preserves_cache(self) -> None:
1634
+ """Our __version__ is NOT in the cache marker — pip upgrades don't wipe."""
1635
+ from unittest.mock import patch
1636
+
1637
+ from java_functional_lsp.proxy import _compute_cache_marker
1638
+
1639
+ marker_before = _compute_cache_marker()
1640
+ with patch("java_functional_lsp.__version__", "99.99.99"):
1641
+ marker_after = _compute_cache_marker()
1642
+ assert marker_before == marker_after, "Changing __version__ must not change the cache marker"
1643
+
1644
+ def test_different_jdtls_paths_produce_different_markers(self) -> None:
1645
+ """Different resolved jdtls paths produce different cache markers."""
1646
+ from pathlib import Path
1647
+ from unittest.mock import patch
1648
+
1649
+ from java_functional_lsp.proxy import _compute_cache_marker
1591
1650
 
1592
- assert (cache / ".version").read_text() == __version__
1651
+ with patch("java_functional_lsp.proxy.shutil.which", return_value="/opt/jdtls/1.57/bin/jdtls"):
1652
+ with patch("java_functional_lsp.proxy.Path.resolve", return_value=Path("/opt/jdtls/1.57/bin/jdtls")):
1653
+ marker_a = _compute_cache_marker()
1654
+ with patch("java_functional_lsp.proxy.shutil.which", return_value="/opt/jdtls/1.58/bin/jdtls"):
1655
+ with patch("java_functional_lsp.proxy.Path.resolve", return_value=Path("/opt/jdtls/1.58/bin/jdtls")):
1656
+ marker_b = _compute_cache_marker()
1657
+ assert marker_a != marker_b
1593
1658
 
1594
1659
 
1595
1660
  # Subprocess-based tests — zero mocks, real LSP transport