diff --git a/pyproject.toml b/pyproject.toml index 8089aff..956386d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "java-functional-lsp" -version = "0.9.1" +version = "0.9.2" description = "Java LSP server enforcing functional programming best practices — null safety, immutability, no exceptions" readme = "README.md" license = { text = "MIT" } diff --git a/src/java_functional_lsp/__init__.py b/src/java_functional_lsp/__init__.py index a4daf62..cc51243 100644 --- a/src/java_functional_lsp/__init__.py +++ b/src/java_functional_lsp/__init__.py @@ -1,3 +1,3 @@ """java-functional-lsp: A Java LSP server enforcing functional programming best practices.""" -__version__ = "0.9.1" +__version__ = "0.9.2" diff --git a/src/java_functional_lsp/proxy.py b/src/java_functional_lsp/proxy.py index b73af9f..4ec9541 100644 --- a/src/java_functional_lsp/proxy.py +++ b/src/java_functional_lsp/proxy.py @@ -445,6 +445,42 @@ def _version_key(name: str) -> tuple[int, ...]: return tuple(parts) +def _find_maven_group_root(initial_module: Path, workspace_root: Path) -> Path: + """Return the tightest Maven multi-module parent of *initial_module*. + + Walks up from ``initial_module.parent`` (the module itself is skipped — + we want its *parent* group, not the module's own pom) toward (and + including) ``workspace_root``, returning the first ancestor whose + ``pom.xml`` contains a ```` section. Falls back to + ``workspace_root`` when no such intermediate parent exists (e.g. flat + single-level monorepos). + + This keeps the jdtls index bounded to a module *group* (typically + 5-20 modules) rather than the full IDE workspace (potentially 200+ modules) + which would cause ``OutOfMemoryError: Java heap space`` during indexing. + + Both *initial_module* and *workspace_root* are resolved to real paths + before walking to avoid symlink-based boundary escapes. + """ + workspace_root = workspace_root.resolve() + current = initial_module.resolve().parent + # Bail immediately if the initial module is outside the workspace. + if not current.is_relative_to(workspace_root): + return workspace_root + while True: + pom = current / "pom.xml" + if pom.is_file(): + try: + if "" in pom.read_text(encoding="utf-8", errors="ignore"): + return current + except OSError: + pass + if current in (workspace_root, current.parent): + break + current = current.parent + return workspace_root + + @lru_cache(maxsize=256) def _module_snapshot_path(module_uri: str) -> Path: """Return the path for the persistent snapshot file for *module_uri*. @@ -862,16 +898,34 @@ async def add_module_if_new(self, file_uri: str) -> str | None: return module_uri async def expand_full_workspace(self) -> None: - """Expand jdtls workspace to the full monorepo root (background task). + """Expand jdtls workspace to the nearest Maven module-group root. + + Instead of expanding all the way to the IDE workspace root (which may + contain hundreds of Maven modules and cause jdtls OOM), this walks up + from the initial module to find the *nearest* ancestor directory whose + ``pom.xml`` declares ```` — i.e. the tightest multi-module + Maven parent. This covers sibling modules (the common case for + cross-module navigation) without ballooning the index. + + Falls back to the full IDE workspace root when no intermediate + multi-module parent is found. - Removes the initial module-scoped folder and adds the full root to - avoid double-indexing. + Removes all individually-added module folders to avoid double-indexing + with the newly added group root. """ if self._workspace_expanded or not self._available or not self._original_root_uri: return from pygls.uris import from_fs_path, to_fs_path - root_path = to_fs_path(self._original_root_uri) or self._original_root_uri + workspace_path = to_fs_path(self._original_root_uri) or self._original_root_uri + + # Find the tightest multi-module Maven parent of the initial module. + group_root_path: str = workspace_path + if self._initial_module_uri: + initial_path = to_fs_path(self._initial_module_uri) or self._initial_module_uri + group_root_path = str(_find_maven_group_root(Path(initial_path), Path(workspace_path))) + + root_path = group_root_path root_uri = from_fs_path(root_path) or self._original_root_uri if self.modules.was_added(root_uri): self._workspace_expanded = True @@ -887,7 +941,7 @@ async def expand_full_workspace(self) -> None: p = to_fs_path(uri) or uri removed.append({"uri": uri, "name": Path(p).name}) - logger.info("jdtls: expanding to full workspace %s", _redact_path(root_path)) + logger.info("jdtls: expanding workspace to module group %s", _redact_path(root_path)) await self.send_notification( _WORKSPACE_DID_CHANGE_FOLDERS, {"event": {"added": [{"uri": root_uri, "name": Path(root_path).name}], "removed": removed}}, diff --git a/tests/test_proxy.py b/tests/test_proxy.py index 9159bb0..ed0aa94 100644 --- a/tests/test_proxy.py +++ b/tests/test_proxy.py @@ -5,6 +5,7 @@ import asyncio import json import subprocess +from pathlib import Path from typing import Any from unittest.mock import MagicMock, patch @@ -1037,6 +1038,65 @@ async def test_add_module_if_new_skips_duplicate(self) -> None: assert result2 is None # Already known assert proxy.send_notification.call_count == 1 # type: ignore[attr-defined] + async def test_expand_uses_maven_group_root_not_workspace_root(self, tmp_path: Path) -> None: + """expand_full_workspace expands to nearest multi-module Maven parent, not the full IDE root.""" + from unittest.mock import AsyncMock + + from java_functional_lsp.proxy import JdtlsProxy + + # Layout: workspace/group/module — group/pom.xml has + workspace = tmp_path / "workspace" + group = workspace / "group" + module = group / "module" + module.mkdir(parents=True) + (group / "pom.xml").write_text("module") + (workspace / "pom.xml").write_text("group") + + module_uri = module.as_uri() + workspace_uri = workspace.as_uri() + + proxy = JdtlsProxy() + proxy._available = True + proxy._original_root_uri = workspace_uri + proxy._initial_module_uri = module_uri + proxy.modules.mark_added(module_uri) + proxy.send_notification = AsyncMock() # type: ignore[assignment] + await proxy.expand_full_workspace() + + call_args = proxy.send_notification.call_args[0] # type: ignore[attr-defined] + event = call_args[1]["event"] + # Should expand to group/, NOT workspace/ + assert event["added"][0]["uri"] == group.as_uri() + assert event["added"][0]["uri"] != workspace_uri + + async def test_expand_falls_back_to_workspace_root_when_no_group_pom(self, tmp_path: Path) -> None: + """Falls back to the IDE workspace root when no intermediate multi-module parent exists.""" + from unittest.mock import AsyncMock + + from java_functional_lsp.proxy import JdtlsProxy + + # Flat layout: workspace/module — no intermediate pom.xml + workspace = tmp_path / "workspace" + module = workspace / "module" + module.mkdir(parents=True) + # workspace/pom.xml has , but there's nothing in between + (workspace / "pom.xml").write_text("module") + + module_uri = module.as_uri() + workspace_uri = workspace.as_uri() + + proxy = JdtlsProxy() + proxy._available = True + proxy._original_root_uri = workspace_uri + proxy._initial_module_uri = module_uri + proxy.modules.mark_added(module_uri) + proxy.send_notification = AsyncMock() # type: ignore[assignment] + await proxy.expand_full_workspace() + + call_args = proxy.send_notification.call_args[0] # type: ignore[attr-defined] + event = call_args[1]["event"] + assert event["added"][0]["uri"] == workspace_uri + async def test_expand_full_workspace_sends_notification(self) -> None: from unittest.mock import AsyncMock