java-functional-lsp 0.9.8__tar.gz → 0.9.10__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.8 → java_functional_lsp-0.9.10}/PKG-INFO +1 -1
  2. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/pyproject.toml +1 -1
  3. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/src/java_functional_lsp/__init__.py +1 -1
  4. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/src/java_functional_lsp/proxy.py +84 -38
  5. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/src/java_functional_lsp/server.py +26 -9
  6. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/tests/test_proxy.py +113 -4
  7. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/tests/test_server.py +49 -1
  8. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/.claude-plugin/plugin.json +0 -0
  9. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/.githooks/pre-commit +0 -0
  10. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/.githooks/pre-push +0 -0
  11. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/.github/CODEOWNERS +0 -0
  12. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/.github/ISSUE_TEMPLATE/bug-report.md +0 -0
  13. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/.github/ISSUE_TEMPLATE/feature-request.md +0 -0
  14. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  15. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/.github/SECURITY.md +0 -0
  16. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/.github/dependabot.yml +0 -0
  17. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/.github/release-drafter.yml +0 -0
  18. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/.github/workflows/publish.yml +0 -0
  19. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/.github/workflows/release-drafter.yml +0 -0
  20. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/.github/workflows/stale.yml +0 -0
  21. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/.github/workflows/test.yml +0 -0
  22. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/.github/workflows/update-homebrew.yml +0 -0
  23. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/.github/workflows/vscode-ext.yml +0 -0
  24. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/.gitignore +0 -0
  25. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/CONTRIBUTING.md +0 -0
  26. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/LICENSE +0 -0
  27. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/README.md +0 -0
  28. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/SKILL.md +0 -0
  29. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/commands/lint-java.md +0 -0
  30. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/editors/intellij/README.md +0 -0
  31. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/editors/intellij/lsp4ij-template.json +0 -0
  32. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/editors/vscode/.vscodeignore +0 -0
  33. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/editors/vscode/README.md +0 -0
  34. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/editors/vscode/package-lock.json +0 -0
  35. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/editors/vscode/package.json +0 -0
  36. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/editors/vscode/src/extension.ts +0 -0
  37. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/editors/vscode/tsconfig.json +0 -0
  38. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/hooks/hooks.json +0 -0
  39. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/hooks/java_linter_reminder.py +0 -0
  40. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/scripts/ensure-lsp.sh +0 -0
  41. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/scripts/generate-formula.py +0 -0
  42. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/src/java_functional_lsp/__main__.py +0 -0
  43. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/src/java_functional_lsp/analyzers/__init__.py +0 -0
  44. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/src/java_functional_lsp/analyzers/base.py +0 -0
  45. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/src/java_functional_lsp/analyzers/exception_checker.py +0 -0
  46. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/src/java_functional_lsp/analyzers/functional_checker.py +0 -0
  47. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/src/java_functional_lsp/analyzers/mutation_checker.py +0 -0
  48. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/src/java_functional_lsp/analyzers/null_checker.py +0 -0
  49. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/src/java_functional_lsp/analyzers/spring_checker.py +0 -0
  50. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/src/java_functional_lsp/cli.py +0 -0
  51. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/src/java_functional_lsp/fixes.py +0 -0
  52. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/src/java_functional_lsp/merkle.py +0 -0
  53. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/tests/__init__.py +0 -0
  54. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/tests/conftest.py +0 -0
  55. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/tests/test_base.py +0 -0
  56. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/tests/test_cli.py +0 -0
  57. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/tests/test_config.py +0 -0
  58. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/tests/test_e2e.py +0 -0
  59. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/tests/test_e2e_jdtls.py +0 -0
  60. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/tests/test_exception_checker.py +0 -0
  61. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/tests/test_fixes.py +0 -0
  62. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/tests/test_functional_checker.py +0 -0
  63. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/tests/test_merkle.py +0 -0
  64. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/tests/test_merkle_proxy.py +0 -0
  65. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/tests/test_mutation_checker.py +0 -0
  66. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/tests/test_null_checker.py +0 -0
  67. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/tests/test_spring_checker.py +0 -0
  68. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/tests/test_suppress.py +0 -0
  69. {java_functional_lsp-0.9.8 → java_functional_lsp-0.9.10}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: java-functional-lsp
3
- Version: 0.9.8
3
+ Version: 0.9.10
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.8"
7
+ version = "0.9.10"
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.8"
3
+ __version__ = "0.9.10"
@@ -28,6 +28,7 @@ _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)
31
32
 
32
33
  #: Minimum Java major version required by jdtls 1.57+. Anything below this
33
34
  #: is rejected by the jdtls Python launcher with ``"jdtls requires at least
@@ -374,6 +375,10 @@ class ModuleRegistry:
374
375
  if event is not None:
375
376
  event.set()
376
377
 
378
+ def uris(self) -> list[str]:
379
+ """Return all tracked module URIs (any state)."""
380
+ return list(self._states)
381
+
377
382
  def clear(self) -> None:
378
383
  """Reset all state. Used by tests."""
379
384
  self._states.clear()
@@ -445,6 +450,7 @@ def _version_key(name: str) -> tuple[int, ...]:
445
450
  return tuple(parts)
446
451
 
447
452
 
453
+ @lru_cache(maxsize=64)
448
454
  def _find_maven_group_root(initial_module: Path, workspace_root: Path) -> Path:
449
455
  """Return the tightest Maven multi-module parent of *initial_module*.
450
456
 
@@ -641,7 +647,7 @@ class JdtlsProxy:
641
647
  self._initial_module_uri: str | None = None
642
648
  self.modules = ModuleRegistry()
643
649
  self.has_lombok = False
644
- self._workspace_expanded = False
650
+ self._expanded_groups: set[str] = set()
645
651
  # Merkle snapshot diff: module_uri → (diff | None, current_snapshot)
646
652
  # Populated by _kick_module_diff (background task on add_module_if_new).
647
653
  # Consumed by server._apply_module_diff after module reaches READY.
@@ -887,33 +893,76 @@ class JdtlsProxy:
887
893
  pass # result storage is _kick_module_diff's responsibility
888
894
 
889
895
  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.
896
+ """Add the module's Maven group to jdtls if not already expanded.
897
+
898
+ When the user navigates to a file in a new Maven group, we add the
899
+ group root (not the individual module) as a workspace folder. This
900
+ gives jdtls the full Maven classpath for cross-module navigation
901
+ within that group.
891
902
 
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.
903
+ Returns the module URI if a new group was expanded (for use with
904
+ ``modules.wait_until_ready()``), or ``None`` if already covered.
895
905
 
896
- Calls ``modules.mark_added()`` before any ``await`` to prevent
897
- duplicate add calls from concurrent coroutines.
906
+ Capped at ``_MAX_EXPANDED_GROUPS`` to prevent jdtls OOM.
898
907
  """
899
- if not self._available or self._workspace_expanded:
908
+ if not self._available:
900
909
  return None
910
+ from pygls.uris import from_fs_path, to_fs_path
911
+
901
912
  module_uri = _resolve_module_uri(file_uri)
902
913
  if module_uri is None or self.modules.was_added(module_uri):
903
914
  return None
915
+
916
+ # Resolve the Maven group root for this module.
917
+ module_path = to_fs_path(module_uri)
918
+ workspace_path = to_fs_path(self._original_root_uri) if self._original_root_uri else None
919
+ if module_path and workspace_path:
920
+ group_root = _find_maven_group_root(Path(module_path), Path(workspace_path))
921
+ group_uri = from_fs_path(str(group_root)) or module_uri
922
+ else:
923
+ group_uri = module_uri
924
+
925
+ # Already expanded this group — module is covered, nothing to do.
926
+ if group_uri in self._expanded_groups:
927
+ return None
928
+
929
+ # Hard cap: refuse expansion beyond the limit.
930
+ if len(self._expanded_groups) >= _MAX_EXPANDED_GROUPS:
931
+ logger.warning(
932
+ "jdtls: refusing to expand group for %s — %d groups already expanded (max %d). Restart IDE to reset.",
933
+ Path(module_path or module_uri).name,
934
+ len(self._expanded_groups),
935
+ _MAX_EXPANDED_GROUPS,
936
+ )
937
+ return None
938
+
939
+ # Remove individually-added child modules within this group to avoid
940
+ # double-indexing once the group root covers them. Must run BEFORE
941
+ # mark_added so we don't remove the module we're about to add.
942
+ group_path = Path(to_fs_path(group_uri) or group_uri)
943
+ removed: list[dict[str, str]] = []
944
+ for uri in self.modules.uris():
945
+ if uri == group_uri:
946
+ continue
947
+ p = Path(to_fs_path(uri) or uri)
948
+ if p == group_path or p.is_relative_to(group_path):
949
+ removed.append({"uri": uri, "name": p.name})
950
+
904
951
  # Mark ADDED before await — atomic in asyncio, prevents duplicate sends.
905
952
  self.modules.mark_added(module_uri)
906
- from pygls.uris import to_fs_path
953
+ self.modules.mark_added(group_uri)
954
+ self._expanded_groups.add(group_uri)
907
955
 
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
956
+ group_name = Path(to_fs_path(group_uri) or group_uri).name
957
+ logger.info(
958
+ "jdtls: expanding to group %s (group %d/%d)", group_name, len(self._expanded_groups), _MAX_EXPANDED_GROUPS
959
+ )
910
960
  await self.send_notification(
911
961
  _WORKSPACE_DID_CHANGE_FOLDERS,
912
- {"event": {"added": [{"uri": module_uri, "name": mod_name}], "removed": []}},
962
+ {"event": {"added": [{"uri": group_uri, "name": group_name}], "removed": removed}},
913
963
  )
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).
964
+
965
+ # Fire-and-forget: compute snapshot diff in background.
917
966
  diff_task = asyncio.create_task(self._kick_module_diff(module_uri))
918
967
  self._proxy_bg_tasks.add(diff_task)
919
968
  diff_task.add_done_callback(self._proxy_bg_tasks.discard)
@@ -921,24 +970,16 @@ class JdtlsProxy:
921
970
  return module_uri
922
971
 
923
972
  async def expand_full_workspace(self) -> None:
924
- """Expand jdtls workspace to the nearest Maven module-group root.
973
+ """Expand jdtls workspace to the initial module's Maven group root.
925
974
 
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.
975
+ Called once during lazy start. Subsequent groups are expanded
976
+ on-demand by ``add_module_if_new()`` as the user navigates to
977
+ files in other Maven groups.
937
978
 
938
979
  Removes all individually-added module folders to avoid double-indexing
939
980
  with the newly added group root.
940
981
  """
941
- if self._workspace_expanded or not self._available or not self._original_root_uri:
982
+ if not self._available or not self._original_root_uri:
942
983
  return
943
984
  from pygls.uris import from_fs_path, to_fs_path
944
985
 
@@ -952,26 +993,31 @@ class JdtlsProxy:
952
993
 
953
994
  root_path = group_root_path
954
995
  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
996
+
997
+ # Already expanded (e.g. initial module IS the group root, or called twice).
998
+ if root_uri in self._expanded_groups or self.modules.was_added(root_uri):
999
+ self._expanded_groups.add(root_uri)
957
1000
  return
958
1001
  self.modules.mark_added(root_uri)
959
1002
 
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.
1003
+ # Remove individually-added modules that are children of the new root.
1004
+ # This covers the initial scoped module and any modules added by
1005
+ # add_module_if_new during the ensure_started window.
1006
+ root_path_obj = Path(root_path)
963
1007
  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})
1008
+ for uri in self.modules.uris():
1009
+ if uri == root_uri:
1010
+ continue
1011
+ p = Path(to_fs_path(uri) or uri)
1012
+ if p == root_path_obj or p.is_relative_to(root_path_obj):
1013
+ removed.append({"uri": uri, "name": p.name})
968
1014
 
969
1015
  logger.info("jdtls: expanding workspace to module group %s", _redact_path(root_path))
970
1016
  await self.send_notification(
971
1017
  _WORKSPACE_DID_CHANGE_FOLDERS,
972
1018
  {"event": {"added": [{"uri": root_uri, "name": Path(root_path).name}], "removed": removed}},
973
1019
  )
974
- self._workspace_expanded = True
1020
+ self._expanded_groups.add(root_uri)
975
1021
 
976
1022
  async def stop(self) -> None:
977
1023
  """Shutdown jdtls subprocess gracefully."""
@@ -76,13 +76,18 @@ class JavaFunctionalLspServer(LanguageServer):
76
76
  self._skip_jdtls_registration: bool = False
77
77
  self._init_generation: int = 0
78
78
 
79
- def _on_jdtls_diagnostics(self, uri: str, _diagnostics: list[Any]) -> None:
79
+ def _on_jdtls_diagnostics(self, uri: str, diagnostics: list[Any]) -> None:
80
80
  """Called when jdtls publishes diagnostics — merge with custom and re-publish.
81
81
 
82
82
  Also marks the file's module as READY, since receiving diagnostics from
83
83
  jdtls is a reliable signal that the module has been indexed (more reliable
84
84
  than a first non-None response which may be semantically empty).
85
85
 
86
+ Exception: if any diagnostic carries ``_JDTLS_NON_PROJECT_CODE`` (code 16),
87
+ jdtls has opened the file but hasn't resolved its Maven project yet — only
88
+ syntax errors are available. We skip the READY transition and wait for the
89
+ follow-up publish (after Maven import completes) that arrives without code 16.
90
+
86
91
  When a module transitions to READY, fires ``_apply_module_diff`` to
87
92
  notify jdtls about any externally changed files (e.g., after git pull).
88
93
  """
@@ -90,12 +95,18 @@ class JavaFunctionalLspServer(LanguageServer):
90
95
  return
91
96
  module_uri = _resolve_module_uri(uri)
92
97
  if module_uri:
93
- already_ready = self._proxy.modules.is_ready(module_uri)
94
- self._proxy.modules.mark_ready(module_uri)
95
- if not already_ready:
96
- # First READY transition only — subsequent diagnostics for the same
97
- # module are no-ops to avoid spawning O(files) redundant tasks.
98
- _fire_and_forget(_apply_module_diff(self._proxy, module_uri))
98
+ is_non_project = any(
99
+ str(d.get("code", "")) == _JDTLS_NON_PROJECT_CODE for d in diagnostics if isinstance(d, dict)
100
+ )
101
+ if is_non_project:
102
+ logger.info("jdtls: skipping READY for %s (non-project file, Maven import pending)", Path(uri).name)
103
+ else:
104
+ already_ready = self._proxy.modules.is_ready(module_uri)
105
+ self._proxy.modules.mark_ready(module_uri)
106
+ if not already_ready:
107
+ # First READY transition only — subsequent diagnostics for the same
108
+ # module are no-ops to avoid spawning O(files) redundant tasks.
109
+ _fire_and_forget(_apply_module_diff(self._proxy, module_uri))
99
110
  try:
100
111
  _analyze_and_publish(uri)
101
112
  except FileNotFoundError:
@@ -419,7 +430,7 @@ def on_initialize(params: lsp.InitializeParams) -> lsp.InitializeResult:
419
430
 
420
431
 
421
432
  @server.feature(lsp.INITIALIZED)
422
- async def on_initialized(_params: lsp.InitializedParams) -> None:
433
+ async def on_initialized(_: lsp.InitializedParams) -> None:
423
434
  """Check jdtls availability; actual start deferred to first didOpen."""
424
435
  logger.info(
425
436
  "java-functional-lsp initialized (rules: %s)",
@@ -672,6 +683,12 @@ _LIGHTWEIGHT_METHODS: frozenset[str] = frozenset(
672
683
  }
673
684
  )
674
685
 
686
+ # jdtls diagnostic code for files it opened but hasn't resolved into a Maven project yet.
687
+ # When this appears in a publishDiagnostics batch, jdtls has syntax info only — no classpath,
688
+ # no cross-file resolution. We must NOT mark the module READY yet; wait for a re-publish
689
+ # without code 16 (after Maven project import finishes).
690
+ _JDTLS_NON_PROJECT_CODE: str = "16"
691
+
675
692
 
676
693
  async def _ensure_module_and_forward(
677
694
  method: str,
@@ -707,7 +724,7 @@ async def _ensure_module_and_forward(
707
724
  return result
708
725
 
709
726
  # Cold path: add module if unknown, then wait for ready.
710
- 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))
711
728
  new_module_uri = await proxy.add_module_if_new(file_uri)
712
729
 
713
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."""
@@ -290,6 +290,54 @@ class TestServerInternals:
290
290
  finally:
291
291
  server.workspace.remove_text_document(uri)
292
292
 
293
+ def test_on_jdtls_diagnostics_non_project_skips_ready(self) -> None:
294
+ """A publishDiagnostics batch with code-16 must NOT mark the module READY."""
295
+ from unittest.mock import patch
296
+
297
+ from java_functional_lsp.proxy import ModuleState
298
+ from java_functional_lsp.server import server
299
+
300
+ server._proxy.modules.clear()
301
+ uri = "file:///mod/Foo.java"
302
+ non_project_diag = {
303
+ "code": "16",
304
+ "message": "Foo.java is a non-project file, only syntax errors are reported",
305
+ "source": "Java",
306
+ "severity": 2,
307
+ "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 1}},
308
+ }
309
+ with (
310
+ patch("java_functional_lsp.server._resolve_module_uri", return_value="file:///mod"),
311
+ patch("java_functional_lsp.server._analyze_and_publish"),
312
+ ):
313
+ server._on_jdtls_diagnostics(uri, [non_project_diag])
314
+ assert server._proxy.modules.get_state("file:///mod") != ModuleState.READY
315
+
316
+ def test_on_jdtls_diagnostics_project_file_marks_ready(self) -> None:
317
+ """A publishDiagnostics batch without code-16 MUST mark the module READY."""
318
+ from unittest.mock import patch
319
+
320
+ from java_functional_lsp.proxy import ModuleState
321
+ from java_functional_lsp.server import server
322
+
323
+ server._proxy.modules.clear()
324
+ uri = "file:///mod/Pr.java"
325
+ real_diag = {
326
+ "code": "268435844",
327
+ "message": "Some unused import",
328
+ "source": "Java",
329
+ "severity": 2,
330
+ "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 1}},
331
+ }
332
+ with (
333
+ patch("java_functional_lsp.server._resolve_module_uri", return_value="file:///mod"),
334
+ patch("java_functional_lsp.server._analyze_and_publish"),
335
+ patch("java_functional_lsp.server._fire_and_forget"),
336
+ ):
337
+ server._on_jdtls_diagnostics(uri, [real_diag])
338
+ assert server._proxy.modules.get_state("file:///mod") == ModuleState.READY
339
+ server._proxy.modules.clear()
340
+
293
341
  def test_init_capabilities_exclude_jdtls_features(self) -> None:
294
342
  """Static capabilities must NOT include hover/definition/references/completion/documentSymbol.
295
343
 
@@ -541,7 +589,7 @@ class TestServerInternals:
541
589
  mock_add = AsyncMock(return_value="file:///mod")
542
590
  mock_send = AsyncMock(side_effect=[None, {"result": "ok"}])
543
591
 
544
- async def mock_wait(uri: str, timeout: float = 30.0) -> bool: # pyright: ignore[reportUnusedParameter]
592
+ async def mock_wait(uri: str, timeout: float = 30.0) -> bool: # type: ignore[reportUnusedParameter]
545
593
  srv._proxy.modules.mark_ready(uri)
546
594
  return True
547
595