java-functional-lsp 0.9.9__tar.gz → 0.9.11__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.9 → java_functional_lsp-0.9.11}/PKG-INFO +1 -1
  2. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/pyproject.toml +1 -1
  3. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/src/java_functional_lsp/__init__.py +1 -1
  4. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/src/java_functional_lsp/proxy.py +176 -65
  5. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/src/java_functional_lsp/server.py +1 -1
  6. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/tests/test_proxy.py +165 -4
  7. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/tests/test_server.py +34 -13
  8. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/.claude-plugin/plugin.json +0 -0
  9. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/.githooks/pre-commit +0 -0
  10. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/.githooks/pre-push +0 -0
  11. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/.github/CODEOWNERS +0 -0
  12. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/.github/ISSUE_TEMPLATE/bug-report.md +0 -0
  13. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/.github/ISSUE_TEMPLATE/feature-request.md +0 -0
  14. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  15. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/.github/SECURITY.md +0 -0
  16. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/.github/dependabot.yml +0 -0
  17. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/.github/release-drafter.yml +0 -0
  18. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/.github/workflows/publish.yml +0 -0
  19. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/.github/workflows/release-drafter.yml +0 -0
  20. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/.github/workflows/stale.yml +0 -0
  21. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/.github/workflows/test.yml +0 -0
  22. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/.github/workflows/update-homebrew.yml +0 -0
  23. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/.github/workflows/vscode-ext.yml +0 -0
  24. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/.gitignore +0 -0
  25. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/CONTRIBUTING.md +0 -0
  26. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/LICENSE +0 -0
  27. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/README.md +0 -0
  28. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/SKILL.md +0 -0
  29. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/commands/lint-java.md +0 -0
  30. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/editors/intellij/README.md +0 -0
  31. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/editors/intellij/lsp4ij-template.json +0 -0
  32. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/editors/vscode/.vscodeignore +0 -0
  33. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/editors/vscode/README.md +0 -0
  34. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/editors/vscode/package-lock.json +0 -0
  35. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/editors/vscode/package.json +0 -0
  36. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/editors/vscode/src/extension.ts +0 -0
  37. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/editors/vscode/tsconfig.json +0 -0
  38. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/hooks/hooks.json +0 -0
  39. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/hooks/java_linter_reminder.py +0 -0
  40. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/scripts/ensure-lsp.sh +0 -0
  41. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/scripts/generate-formula.py +0 -0
  42. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/src/java_functional_lsp/__main__.py +0 -0
  43. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/src/java_functional_lsp/analyzers/__init__.py +0 -0
  44. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/src/java_functional_lsp/analyzers/base.py +0 -0
  45. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/src/java_functional_lsp/analyzers/exception_checker.py +0 -0
  46. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/src/java_functional_lsp/analyzers/functional_checker.py +0 -0
  47. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/src/java_functional_lsp/analyzers/mutation_checker.py +0 -0
  48. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/src/java_functional_lsp/analyzers/null_checker.py +0 -0
  49. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/src/java_functional_lsp/analyzers/spring_checker.py +0 -0
  50. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/src/java_functional_lsp/cli.py +0 -0
  51. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/src/java_functional_lsp/fixes.py +0 -0
  52. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/src/java_functional_lsp/merkle.py +0 -0
  53. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/tests/__init__.py +0 -0
  54. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/tests/conftest.py +0 -0
  55. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/tests/test_base.py +0 -0
  56. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/tests/test_cli.py +0 -0
  57. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/tests/test_config.py +0 -0
  58. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/tests/test_e2e.py +0 -0
  59. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/tests/test_e2e_jdtls.py +0 -0
  60. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/tests/test_exception_checker.py +0 -0
  61. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/tests/test_fixes.py +0 -0
  62. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/tests/test_functional_checker.py +0 -0
  63. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/tests/test_merkle.py +0 -0
  64. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/tests/test_merkle_proxy.py +0 -0
  65. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/tests/test_mutation_checker.py +0 -0
  66. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/tests/test_null_checker.py +0 -0
  67. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/tests/test_spring_checker.py +0 -0
  68. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/tests/test_suppress.py +0 -0
  69. {java_functional_lsp-0.9.9 → java_functional_lsp-0.9.11}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: java-functional-lsp
3
- Version: 0.9.9
3
+ Version: 0.9.11
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "java-functional-lsp"
7
- version = "0.9.9"
7
+ version = "0.9.11"
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.9"
3
+ __version__ = "0.9.11"
@@ -28,6 +28,28 @@ _INITIALIZE_TIMEOUT = 120.0 # seconds — module-scoped init can still be slow
28
28
  _START_RETRY_COOLDOWN = 300.0 # seconds — retry jdtls startup after transient failure
29
29
  DEFAULT_JVM_MAX_HEAP = "4g"
30
30
  _STDERR_LINE_MAX = 1000
31
+ _MAX_EXPANDED_GROUPS: int = 5 # hard cap on concurrent Maven group workspace folders (prevents jdtls OOM)
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
+ }
31
53
 
32
54
  #: Minimum Java major version required by jdtls 1.57+. Anything below this
33
55
  #: is rejected by the jdtls Python launcher with ``"jdtls requires at least
@@ -374,6 +396,10 @@ class ModuleRegistry:
374
396
  if event is not None:
375
397
  event.set()
376
398
 
399
+ def uris(self) -> list[str]:
400
+ """Return all tracked module URIs (any state)."""
401
+ return list(self._states)
402
+
377
403
  def clear(self) -> None:
378
404
  """Reset all state. Used by tests."""
379
405
  self._states.clear()
@@ -445,6 +471,7 @@ def _version_key(name: str) -> tuple[int, ...]:
445
471
  return tuple(parts)
446
472
 
447
473
 
474
+ @lru_cache(maxsize=64)
448
475
  def _find_maven_group_root(initial_module: Path, workspace_root: Path) -> Path:
449
476
  """Return the tightest Maven multi-module parent of *initial_module*.
450
477
 
@@ -552,25 +579,43 @@ def _wipe_data_dir(data_dir: Path) -> None:
552
579
  logger.info("jdtls: wiped data-dir %s after init timeout", _redact_path(str(data_dir)))
553
580
 
554
581
 
555
- def _clear_cache_on_version_change(cache_root: Path) -> None:
556
- """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.
557
584
 
558
- Stores a ``.version`` marker in the cache root. On version mismatch
559
- (e.g., Lombok agent added, jdtls config changed), wipes the entire
560
- 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.
561
590
  """
562
- 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.
563
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()
564
609
  marker = cache_root / ".version"
565
610
  try:
566
- if marker.exists() and marker.read_text().strip() == __version__:
611
+ if marker.exists() and marker.read_text().strip() == current_marker:
567
612
  return
568
- # Version changed or first run — clear stale caches.
613
+ # jdtls or Java version changed, or first run — clear stale caches.
569
614
  if cache_root.exists():
570
615
  shutil.rmtree(cache_root)
571
- logger.info("jdtls: cleared stale cache (version changed to %s)", __version__)
616
+ logger.info("jdtls: cleared stale cache (marker changed to %s)", current_marker)
572
617
  cache_root.mkdir(parents=True, exist_ok=True)
573
- marker.write_text(__version__)
618
+ marker.write_text(current_marker)
574
619
  except OSError as e:
575
620
  logger.warning("jdtls: failed to manage cache version marker: %s", e)
576
621
 
@@ -616,6 +661,48 @@ def _find_lombok_jar(config: Mapping[str, Any] | None = None) -> str | None:
616
661
  return None
617
662
 
618
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
+
619
706
  class JdtlsProxy:
620
707
  """Manages a jdtls subprocess and provides async request/notification forwarding."""
621
708
 
@@ -641,7 +728,7 @@ class JdtlsProxy:
641
728
  self._initial_module_uri: str | None = None
642
729
  self.modules = ModuleRegistry()
643
730
  self.has_lombok = False
644
- self._workspace_expanded = False
731
+ self._expanded_groups: set[str] = set()
645
732
  # Merkle snapshot diff: module_uri → (diff | None, current_snapshot)
646
733
  # Populated by _kick_module_diff (background task on add_module_if_new).
647
734
  # Consumed by server._apply_module_diff after module reaches READY.
@@ -718,23 +805,7 @@ class JdtlsProxy:
718
805
  data_dir = cache_root / data_hash
719
806
  data_dir.mkdir(parents=True, exist_ok=True)
720
807
 
721
- # Deep copy to avoid mutating server._init_params.
722
- effective_params = copy.deepcopy(init_params)
723
- if module_root_uri:
724
- effective_params["rootUri"] = module_root_uri
725
- from pygls.uris import to_fs_path
726
-
727
- effective_params["rootPath"] = to_fs_path(module_root_uri)
728
- logger.info(
729
- "jdtls: scoping to module %s (full root: %s)",
730
- _redact_path(module_root_uri),
731
- _redact_path(original_root),
732
- )
733
-
734
- # Inject workspaceFolders capability for later expansion.
735
- caps = effective_params.setdefault("capabilities", {})
736
- ws = caps.setdefault("workspace", {})
737
- ws["workspaceFolders"] = True
808
+ effective_params = _build_effective_params(init_params, module_root_uri, original_root, config)
738
809
 
739
810
  jdtls_cmd: list[str] = [
740
811
  jdtls_path,
@@ -887,33 +958,76 @@ class JdtlsProxy:
887
958
  pass # result storage is _kick_module_diff's responsibility
888
959
 
889
960
  async def add_module_if_new(self, file_uri: str) -> str | None:
890
- """Add the module containing *file_uri* to jdtls if not already added.
961
+ """Add the module's Maven group to jdtls if not already expanded.
962
+
963
+ When the user navigates to a file in a new Maven group, we add the
964
+ group root (not the individual module) as a workspace folder. This
965
+ gives jdtls the full Maven classpath for cross-module navigation
966
+ within that group.
891
967
 
892
- Returns the module URI if a new module was added (UNKNOWN → ADDED),
893
- or ``None`` if already known or unavailable. The returned URI can be
894
- used with ``modules.wait_until_ready()`` for adaptive waiting.
968
+ Returns the module URI if a new group was expanded (for use with
969
+ ``modules.wait_until_ready()``), or ``None`` if already covered.
895
970
 
896
- Calls ``modules.mark_added()`` before any ``await`` to prevent
897
- duplicate add calls from concurrent coroutines.
971
+ Capped at ``_MAX_EXPANDED_GROUPS`` to prevent jdtls OOM.
898
972
  """
899
- if not self._available or self._workspace_expanded:
973
+ if not self._available:
900
974
  return None
975
+ from pygls.uris import from_fs_path, to_fs_path
976
+
901
977
  module_uri = _resolve_module_uri(file_uri)
902
978
  if module_uri is None or self.modules.was_added(module_uri):
903
979
  return None
980
+
981
+ # Resolve the Maven group root for this module.
982
+ module_path = to_fs_path(module_uri)
983
+ workspace_path = to_fs_path(self._original_root_uri) if self._original_root_uri else None
984
+ if module_path and workspace_path:
985
+ group_root = _find_maven_group_root(Path(module_path), Path(workspace_path))
986
+ group_uri = from_fs_path(str(group_root)) or module_uri
987
+ else:
988
+ group_uri = module_uri
989
+
990
+ # Already expanded this group — module is covered, nothing to do.
991
+ if group_uri in self._expanded_groups:
992
+ return None
993
+
994
+ # Hard cap: refuse expansion beyond the limit.
995
+ if len(self._expanded_groups) >= _MAX_EXPANDED_GROUPS:
996
+ logger.warning(
997
+ "jdtls: refusing to expand group for %s — %d groups already expanded (max %d). Restart IDE to reset.",
998
+ Path(module_path or module_uri).name,
999
+ len(self._expanded_groups),
1000
+ _MAX_EXPANDED_GROUPS,
1001
+ )
1002
+ return None
1003
+
1004
+ # Remove individually-added child modules within this group to avoid
1005
+ # double-indexing once the group root covers them. Must run BEFORE
1006
+ # mark_added so we don't remove the module we're about to add.
1007
+ group_path = Path(to_fs_path(group_uri) or group_uri)
1008
+ removed: list[dict[str, str]] = []
1009
+ for uri in self.modules.uris():
1010
+ if uri == group_uri:
1011
+ continue
1012
+ p = Path(to_fs_path(uri) or uri)
1013
+ if p == group_path or p.is_relative_to(group_path):
1014
+ removed.append({"uri": uri, "name": p.name})
1015
+
904
1016
  # Mark ADDED before await — atomic in asyncio, prevents duplicate sends.
905
1017
  self.modules.mark_added(module_uri)
906
- from pygls.uris import to_fs_path
1018
+ self.modules.mark_added(group_uri)
1019
+ self._expanded_groups.add(group_uri)
907
1020
 
908
- logger.info("jdtls: adding module %s", _redact_path(to_fs_path(module_uri)))
909
- mod_name = Path(to_fs_path(module_uri) or module_uri).name
1021
+ group_name = Path(to_fs_path(group_uri) or group_uri).name
1022
+ logger.info(
1023
+ "jdtls: expanding to group %s (group %d/%d)", group_name, len(self._expanded_groups), _MAX_EXPANDED_GROUPS
1024
+ )
910
1025
  await self.send_notification(
911
1026
  _WORKSPACE_DID_CHANGE_FOLDERS,
912
- {"event": {"added": [{"uri": module_uri, "name": mod_name}], "removed": []}},
1027
+ {"event": {"added": [{"uri": group_uri, "name": group_name}], "removed": removed}},
913
1028
  )
914
- # Fire-and-forget: compute snapshot diff in background so it is ready
915
- # when the module reaches READY state. Does NOT block the jdtls
916
- # readiness timeout (which starts ticking at mark_added above).
1029
+
1030
+ # Fire-and-forget: compute snapshot diff in background.
917
1031
  diff_task = asyncio.create_task(self._kick_module_diff(module_uri))
918
1032
  self._proxy_bg_tasks.add(diff_task)
919
1033
  diff_task.add_done_callback(self._proxy_bg_tasks.discard)
@@ -921,24 +1035,16 @@ class JdtlsProxy:
921
1035
  return module_uri
922
1036
 
923
1037
  async def expand_full_workspace(self) -> None:
924
- """Expand jdtls workspace to the nearest Maven module-group root.
1038
+ """Expand jdtls workspace to the initial module's Maven group root.
925
1039
 
926
- Instead of expanding all the way to the IDE workspace root (which may
927
- contain hundreds of Maven modules and cause jdtls OOM), this walks up
928
- from the initial module to find the *nearest* ancestor directory whose
929
- ``pom.xml`` declares ``<modules>`` — i.e. the tightest multi-module
930
- Maven parent. This covers sibling modules (the common case for
931
- cross-module navigation) without ballooning the index.
932
-
933
- Falls back to the initial module itself when no intermediate
934
- multi-module parent is found (e.g. flat modules sitting directly
935
- under the workspace root), keeping the jdtls scope as tight as
936
- possible.
1040
+ Called once during lazy start. Subsequent groups are expanded
1041
+ on-demand by ``add_module_if_new()`` as the user navigates to
1042
+ files in other Maven groups.
937
1043
 
938
1044
  Removes all individually-added module folders to avoid double-indexing
939
1045
  with the newly added group root.
940
1046
  """
941
- if self._workspace_expanded or not self._available or not self._original_root_uri:
1047
+ if not self._available or not self._original_root_uri:
942
1048
  return
943
1049
  from pygls.uris import from_fs_path, to_fs_path
944
1050
 
@@ -952,26 +1058,31 @@ class JdtlsProxy:
952
1058
 
953
1059
  root_path = group_root_path
954
1060
  root_uri = from_fs_path(root_path) or self._original_root_uri
955
- if self.modules.was_added(root_uri):
956
- self._workspace_expanded = True
1061
+
1062
+ # Already expanded (e.g. initial module IS the group root, or called twice).
1063
+ if root_uri in self._expanded_groups or self.modules.was_added(root_uri):
1064
+ self._expanded_groups.add(root_uri)
957
1065
  return
958
1066
  self.modules.mark_added(root_uri)
959
1067
 
960
- # Remove ALL individually-added modules to prevent double-indexing with
961
- # the full root. This covers both the initial scoped module and any
962
- # modules added by add_module_if_new during the ensure_started window.
1068
+ # Remove individually-added modules that are children of the new root.
1069
+ # This covers the initial scoped module and any modules added by
1070
+ # add_module_if_new during the ensure_started window.
1071
+ root_path_obj = Path(root_path)
963
1072
  removed: list[dict[str, str]] = []
964
- for uri in list(self.modules._states):
965
- if uri != root_uri:
966
- p = to_fs_path(uri) or uri
967
- removed.append({"uri": uri, "name": Path(p).name})
1073
+ for uri in self.modules.uris():
1074
+ if uri == root_uri:
1075
+ continue
1076
+ p = Path(to_fs_path(uri) or uri)
1077
+ if p == root_path_obj or p.is_relative_to(root_path_obj):
1078
+ removed.append({"uri": uri, "name": p.name})
968
1079
 
969
1080
  logger.info("jdtls: expanding workspace to module group %s", _redact_path(root_path))
970
1081
  await self.send_notification(
971
1082
  _WORKSPACE_DID_CHANGE_FOLDERS,
972
1083
  {"event": {"added": [{"uri": root_uri, "name": Path(root_path).name}], "removed": removed}},
973
1084
  )
974
- self._workspace_expanded = True
1085
+ self._expanded_groups.add(root_uri)
975
1086
 
976
1087
  async def stop(self) -> None:
977
1088
  """Shutdown jdtls subprocess gracefully."""
@@ -724,7 +724,7 @@ async def _ensure_module_and_forward(
724
724
  return result
725
725
 
726
726
  # Cold path: add module if unknown, then wait for ready.
727
- logger.info("jdtls: %s [cold] module=%s expanded=%s", method, short_mod, proxy._workspace_expanded)
727
+ logger.info("jdtls: %s [cold] module=%s groups=%d", method, short_mod, len(proxy._expanded_groups))
728
728
  new_module_uri = await proxy.add_module_if_new(file_uri)
729
729
 
730
730
  serialized = _serialize_params(params)
@@ -983,6 +983,13 @@ class TestFindModuleRoot:
983
983
  class TestLazyStart:
984
984
  """Tests for lazy-start proxy features."""
985
985
 
986
+ @pytest.fixture(autouse=True)
987
+ def _clear_group_root_cache(self) -> None: # type: ignore[override]
988
+ """Clear _find_maven_group_root lru_cache between tests."""
989
+ from java_functional_lsp.proxy import _find_maven_group_root
990
+
991
+ _find_maven_group_root.cache_clear()
992
+
986
993
  def test_check_available_true(self) -> None:
987
994
  from java_functional_lsp.proxy import JdtlsProxy
988
995
 
@@ -1164,7 +1171,7 @@ class TestLazyStart:
1164
1171
 
1165
1172
  # No notification — module was already added and is already the tightest scope
1166
1173
  proxy.send_notification.assert_not_called() # type: ignore[attr-defined]
1167
- assert proxy._workspace_expanded is True
1174
+ assert len(proxy._expanded_groups) > 0
1168
1175
 
1169
1176
  async def test_expand_full_workspace_sends_notification(self) -> None:
1170
1177
  from unittest.mock import AsyncMock
@@ -1177,7 +1184,7 @@ class TestLazyStart:
1177
1184
  proxy.send_notification = AsyncMock() # type: ignore[assignment]
1178
1185
  await proxy.expand_full_workspace()
1179
1186
  proxy.send_notification.assert_called_once() # type: ignore[attr-defined]
1180
- assert proxy._workspace_expanded is True
1187
+ assert len(proxy._expanded_groups) > 0
1181
1188
 
1182
1189
  async def test_expand_full_workspace_removes_initial_module(self, tmp_path: Path) -> None:
1183
1190
  """All modules in _states are removed when expanding to an intermediate group root."""
@@ -1227,7 +1234,7 @@ class TestLazyStart:
1227
1234
  proxy.send_notification = AsyncMock() # type: ignore[assignment]
1228
1235
  await proxy.expand_full_workspace()
1229
1236
  proxy.send_notification.assert_not_called() # type: ignore[attr-defined]
1230
- assert proxy._workspace_expanded is False
1237
+ assert len(proxy._expanded_groups) == 0
1231
1238
 
1232
1239
  async def test_expand_full_workspace_noop_when_already_added(self) -> None:
1233
1240
  from unittest.mock import AsyncMock
@@ -1241,7 +1248,109 @@ class TestLazyStart:
1241
1248
  proxy.send_notification = AsyncMock() # type: ignore[assignment]
1242
1249
  await proxy.expand_full_workspace()
1243
1250
  proxy.send_notification.assert_not_called() # type: ignore[attr-defined]
1244
- assert proxy._workspace_expanded is True
1251
+ assert len(proxy._expanded_groups) > 0
1252
+
1253
+ async def test_add_module_cross_group_expands_new_group(self, tmp_path: Path) -> None:
1254
+ """Navigating to a file in a different Maven group expands that group."""
1255
+ from unittest.mock import AsyncMock
1256
+
1257
+ from java_functional_lsp.proxy import JdtlsProxy
1258
+
1259
+ # Layout: monorepo/group-a/mod-a, monorepo/group-b/mod-b
1260
+ monorepo = tmp_path / "monorepo"
1261
+ group_a = monorepo / "group-a"
1262
+ mod_a = group_a / "mod-a"
1263
+ group_b = monorepo / "group-b"
1264
+ mod_b = group_b / "mod-b"
1265
+ for d in (mod_a, mod_b):
1266
+ d.mkdir(parents=True)
1267
+ (d / "pom.xml").write_text("<project/>")
1268
+ (group_a / "pom.xml").write_text("<project><modules><module>mod-a</module></modules></project>")
1269
+ (group_b / "pom.xml").write_text("<project><modules><module>mod-b</module></modules></project>")
1270
+
1271
+ proxy = JdtlsProxy()
1272
+ proxy._available = True
1273
+ proxy._original_root_uri = monorepo.as_uri()
1274
+ proxy._initial_module_uri = mod_a.as_uri()
1275
+ proxy._expanded_groups.add(group_a.as_uri()) # simulate initial expansion done
1276
+ proxy.modules.mark_added(mod_a.as_uri())
1277
+ proxy.send_notification = AsyncMock() # type: ignore[assignment]
1278
+
1279
+ file_uri = (mod_b / "src" / "Foo.java").as_uri()
1280
+ # Create the file so _resolve_module_uri can find mod-b's pom.xml
1281
+ (mod_b / "src").mkdir()
1282
+ (mod_b / "src" / "Foo.java").touch()
1283
+
1284
+ result = await proxy.add_module_if_new(file_uri)
1285
+ assert result is not None # module_uri returned for wait_until_ready
1286
+ proxy.send_notification.assert_called_once() # type: ignore[attr-defined]
1287
+ call_args = proxy.send_notification.call_args[0] # type: ignore[attr-defined]
1288
+ event = call_args[1]["event"]
1289
+ assert event["added"][0]["uri"] == group_b.as_uri()
1290
+ assert event["removed"] == [] # no child modules to remove from new group
1291
+ assert group_b.as_uri() in proxy._expanded_groups
1292
+ assert len(proxy._expanded_groups) == 2
1293
+
1294
+ async def test_add_module_within_expanded_group_is_noop(self, tmp_path: Path) -> None:
1295
+ """A file within an already-expanded group returns None."""
1296
+ from unittest.mock import AsyncMock
1297
+
1298
+ from java_functional_lsp.proxy import JdtlsProxy
1299
+
1300
+ monorepo = tmp_path / "monorepo"
1301
+ group = monorepo / "group"
1302
+ mod_a = group / "mod-a"
1303
+ mod_b = group / "mod-b"
1304
+ for d in (mod_a, mod_b):
1305
+ d.mkdir(parents=True)
1306
+ (d / "pom.xml").write_text("<project/>")
1307
+ (group / "pom.xml").write_text(
1308
+ "<project><modules><module>mod-a</module><module>mod-b</module></modules></project>"
1309
+ )
1310
+
1311
+ proxy = JdtlsProxy()
1312
+ proxy._available = True
1313
+ proxy._original_root_uri = monorepo.as_uri()
1314
+ proxy._expanded_groups.add(group.as_uri())
1315
+ proxy.send_notification = AsyncMock() # type: ignore[assignment]
1316
+
1317
+ file_uri = (mod_b / "src" / "Bar.java").as_uri()
1318
+ (mod_b / "src").mkdir()
1319
+ (mod_b / "src" / "Bar.java").touch()
1320
+
1321
+ result = await proxy.add_module_if_new(file_uri)
1322
+ assert result is None
1323
+ proxy.send_notification.assert_not_called() # type: ignore[attr-defined]
1324
+
1325
+ async def test_add_module_at_cap_refuses_expansion(self, tmp_path: Path) -> None:
1326
+ """Expansion is refused when _MAX_EXPANDED_GROUPS is reached."""
1327
+ from unittest.mock import AsyncMock
1328
+
1329
+ from java_functional_lsp.proxy import _MAX_EXPANDED_GROUPS, JdtlsProxy
1330
+
1331
+ monorepo = tmp_path / "monorepo"
1332
+ new_group = monorepo / "new-group"
1333
+ mod = new_group / "mod"
1334
+ mod.mkdir(parents=True)
1335
+ (mod / "pom.xml").write_text("<project/>")
1336
+ (new_group / "pom.xml").write_text("<project><modules><module>mod</module></modules></project>")
1337
+
1338
+ proxy = JdtlsProxy()
1339
+ proxy._available = True
1340
+ proxy._original_root_uri = monorepo.as_uri()
1341
+ # Fill up to the cap with dummy groups
1342
+ for i in range(_MAX_EXPANDED_GROUPS):
1343
+ proxy._expanded_groups.add(f"file:///fake/group-{i}")
1344
+ proxy.send_notification = AsyncMock() # type: ignore[assignment]
1345
+
1346
+ file_uri = (mod / "src" / "Baz.java").as_uri()
1347
+ (mod / "src").mkdir()
1348
+ (mod / "src" / "Baz.java").touch()
1349
+
1350
+ result = await proxy.add_module_if_new(file_uri)
1351
+ assert result is None
1352
+ proxy.send_notification.assert_not_called() # type: ignore[attr-defined]
1353
+ assert len(proxy._expanded_groups) == _MAX_EXPANDED_GROUPS
1245
1354
 
1246
1355
  async def test_ensure_started_no_build_file(self) -> None:
1247
1356
  """ensure_started with no build file should pass module_root_uri=None."""
@@ -1308,6 +1417,58 @@ class TestLazyStart:
1308
1417
  # ---------------------------------------------------------------------------
1309
1418
 
1310
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
+
1311
1472
  class TestWipeDataDir:
1312
1473
  def test_wipe_removes_directory(self, tmp_path: Path) -> None:
1313
1474
  """Directory is removed and info is logged."""
@@ -1548,48 +1548,69 @@ class TestFindLombokJar:
1548
1548
 
1549
1549
 
1550
1550
  class TestCacheClear:
1551
- """Tests for _clear_cache_on_version_change()."""
1551
+ """Tests for _clear_cache_on_version_change() and _compute_cache_marker()."""
1552
1552
 
1553
1553
  def test_clears_on_version_mismatch(self, tmp_path: Any) -> None:
1554
- from java_functional_lsp.proxy import _clear_cache_on_version_change
1554
+ from java_functional_lsp.proxy import _clear_cache_on_version_change, _compute_cache_marker
1555
1555
 
1556
1556
  cache = tmp_path / "jdtls-data"
1557
1557
  cache.mkdir()
1558
1558
  stale_dir = cache / "abc123"
1559
1559
  stale_dir.mkdir()
1560
- (cache / ".version").write_text("0.7.9")
1560
+ (cache / ".version").write_text("old-stale-marker")
1561
1561
 
1562
1562
  _clear_cache_on_version_change(cache)
1563
1563
 
1564
1564
  assert not stale_dir.exists(), "Stale data-dir should be cleared"
1565
1565
  assert cache.exists(), "Cache root should be recreated"
1566
- from java_functional_lsp import __version__
1566
+ assert (cache / ".version").read_text() == _compute_cache_marker()
1567
1567
 
1568
- assert (cache / ".version").read_text() == __version__
1569
-
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
1568
+ def test_keeps_cache_on_same_marker(self, tmp_path: Any) -> None:
1569
+ from java_functional_lsp.proxy import _clear_cache_on_version_change, _compute_cache_marker
1573
1570
 
1574
1571
  cache = tmp_path / "jdtls-data"
1575
1572
  cache.mkdir()
1576
1573
  existing_dir = cache / "abc123"
1577
1574
  existing_dir.mkdir()
1578
- (cache / ".version").write_text(__version__)
1575
+ (cache / ".version").write_text(_compute_cache_marker())
1579
1576
 
1580
1577
  _clear_cache_on_version_change(cache)
1581
1578
 
1582
1579
  assert existing_dir.exists(), "Existing data-dir should be preserved"
1583
1580
 
1584
1581
  def test_creates_marker_on_first_run(self, tmp_path: Any) -> None:
1585
- from java_functional_lsp.proxy import _clear_cache_on_version_change
1582
+ from java_functional_lsp.proxy import _clear_cache_on_version_change, _compute_cache_marker
1586
1583
 
1587
1584
  cache = tmp_path / "jdtls-data"
1588
1585
  _clear_cache_on_version_change(cache)
1589
1586
 
1590
- from java_functional_lsp import __version__
1587
+ assert (cache / ".version").read_text() == _compute_cache_marker()
1588
+
1589
+ def test_python_version_bump_preserves_cache(self) -> None:
1590
+ """Our __version__ is NOT in the cache marker — pip upgrades don't wipe."""
1591
+ from unittest.mock import patch
1592
+
1593
+ from java_functional_lsp.proxy import _compute_cache_marker
1594
+
1595
+ marker_before = _compute_cache_marker()
1596
+ with patch("java_functional_lsp.__version__", "99.99.99"):
1597
+ marker_after = _compute_cache_marker()
1598
+ assert marker_before == marker_after, "Changing __version__ must not change the cache marker"
1599
+
1600
+ def test_different_jdtls_paths_produce_different_markers(self) -> None:
1601
+ """Different resolved jdtls paths produce different cache markers."""
1602
+ from pathlib import Path
1603
+ from unittest.mock import patch
1604
+
1605
+ from java_functional_lsp.proxy import _compute_cache_marker
1591
1606
 
1592
- assert (cache / ".version").read_text() == __version__
1607
+ with patch("java_functional_lsp.proxy.shutil.which", return_value="/opt/jdtls/1.57/bin/jdtls"):
1608
+ with patch("java_functional_lsp.proxy.Path.resolve", return_value=Path("/opt/jdtls/1.57/bin/jdtls")):
1609
+ marker_a = _compute_cache_marker()
1610
+ with patch("java_functional_lsp.proxy.shutil.which", return_value="/opt/jdtls/1.58/bin/jdtls"):
1611
+ with patch("java_functional_lsp.proxy.Path.resolve", return_value=Path("/opt/jdtls/1.58/bin/jdtls")):
1612
+ marker_b = _compute_cache_marker()
1613
+ assert marker_a != marker_b
1593
1614
 
1594
1615
 
1595
1616
  # Subprocess-based tests — zero mocks, real LSP transport