Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
2 changes: 1 addition & 1 deletion src/java_functional_lsp/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""java-functional-lsp: A Java LSP server enforcing functional programming best practices."""

__version__ = "0.9.1"
__version__ = "0.9.2"
64 changes: 59 additions & 5 deletions src/java_functional_lsp/proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``<modules>`` 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 "<modules>" 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*.
Expand Down Expand Up @@ -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 ``<modules>`` — 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
Expand All @@ -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}},
Expand Down
60 changes: 60 additions & 0 deletions tests/test_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import asyncio
import json
import subprocess
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch

Expand Down Expand Up @@ -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 <modules>
workspace = tmp_path / "workspace"
group = workspace / "group"
module = group / "module"
module.mkdir(parents=True)
(group / "pom.xml").write_text("<project><modules><module>module</module></modules></project>")
(workspace / "pom.xml").write_text("<project><modules><module>group</module></modules></project>")

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 <modules>, but there's nothing in between
(workspace / "pom.xml").write_text("<project><modules><module>module</module></modules></project>")

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

Expand Down
Loading