java-functional-lsp 0.9.1__tar.gz → 0.9.3__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.1 → java_functional_lsp-0.9.3}/PKG-INFO +1 -1
  2. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/pyproject.toml +1 -1
  3. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/src/java_functional_lsp/__init__.py +1 -1
  4. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/src/java_functional_lsp/proxy.py +76 -5
  5. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/tests/test_proxy.py +181 -2
  6. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/.claude-plugin/plugin.json +0 -0
  7. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/.githooks/pre-commit +0 -0
  8. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/.githooks/pre-push +0 -0
  9. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/.github/CODEOWNERS +0 -0
  10. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/.github/ISSUE_TEMPLATE/bug-report.md +0 -0
  11. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/.github/ISSUE_TEMPLATE/feature-request.md +0 -0
  12. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  13. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/.github/SECURITY.md +0 -0
  14. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/.github/dependabot.yml +0 -0
  15. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/.github/release-drafter.yml +0 -0
  16. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/.github/workflows/publish.yml +0 -0
  17. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/.github/workflows/release-drafter.yml +0 -0
  18. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/.github/workflows/stale.yml +0 -0
  19. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/.github/workflows/test.yml +0 -0
  20. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/.github/workflows/update-homebrew.yml +0 -0
  21. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/.github/workflows/vscode-ext.yml +0 -0
  22. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/.gitignore +0 -0
  23. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/CONTRIBUTING.md +0 -0
  24. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/LICENSE +0 -0
  25. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/README.md +0 -0
  26. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/SKILL.md +0 -0
  27. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/commands/lint-java.md +0 -0
  28. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/editors/intellij/README.md +0 -0
  29. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/editors/intellij/lsp4ij-template.json +0 -0
  30. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/editors/vscode/.vscodeignore +0 -0
  31. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/editors/vscode/README.md +0 -0
  32. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/editors/vscode/package-lock.json +0 -0
  33. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/editors/vscode/package.json +0 -0
  34. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/editors/vscode/src/extension.ts +0 -0
  35. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/editors/vscode/tsconfig.json +0 -0
  36. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/hooks/hooks.json +0 -0
  37. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/hooks/java_linter_reminder.py +0 -0
  38. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/scripts/ensure-lsp.sh +0 -0
  39. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/scripts/generate-formula.py +0 -0
  40. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/src/java_functional_lsp/__main__.py +0 -0
  41. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/src/java_functional_lsp/analyzers/__init__.py +0 -0
  42. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/src/java_functional_lsp/analyzers/base.py +0 -0
  43. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/src/java_functional_lsp/analyzers/exception_checker.py +0 -0
  44. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/src/java_functional_lsp/analyzers/functional_checker.py +0 -0
  45. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/src/java_functional_lsp/analyzers/mutation_checker.py +0 -0
  46. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/src/java_functional_lsp/analyzers/null_checker.py +0 -0
  47. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/src/java_functional_lsp/analyzers/spring_checker.py +0 -0
  48. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/src/java_functional_lsp/cli.py +0 -0
  49. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/src/java_functional_lsp/fixes.py +0 -0
  50. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/src/java_functional_lsp/merkle.py +0 -0
  51. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/src/java_functional_lsp/server.py +0 -0
  52. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/tests/__init__.py +0 -0
  53. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/tests/conftest.py +0 -0
  54. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/tests/test_base.py +0 -0
  55. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/tests/test_cli.py +0 -0
  56. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/tests/test_config.py +0 -0
  57. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/tests/test_e2e.py +0 -0
  58. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/tests/test_e2e_jdtls.py +0 -0
  59. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/tests/test_exception_checker.py +0 -0
  60. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/tests/test_fixes.py +0 -0
  61. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/tests/test_functional_checker.py +0 -0
  62. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/tests/test_merkle.py +0 -0
  63. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/tests/test_merkle_proxy.py +0 -0
  64. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/tests/test_mutation_checker.py +0 -0
  65. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/tests/test_null_checker.py +0 -0
  66. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/tests/test_server.py +0 -0
  67. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/tests/test_spring_checker.py +0 -0
  68. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/tests/test_suppress.py +0 -0
  69. {java_functional_lsp-0.9.1 → java_functional_lsp-0.9.3}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: java-functional-lsp
3
- Version: 0.9.1
3
+ Version: 0.9.3
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.1"
7
+ version = "0.9.3"
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.1"
3
+ __version__ = "0.9.3"
@@ -445,6 +445,42 @@ def _version_key(name: str) -> tuple[int, ...]:
445
445
  return tuple(parts)
446
446
 
447
447
 
448
+ def _find_maven_group_root(initial_module: Path, workspace_root: Path) -> Path:
449
+ """Return the tightest Maven multi-module parent of *initial_module*.
450
+
451
+ Walks up from ``initial_module.parent`` (the module itself is skipped —
452
+ we want its *parent* group, not the module's own pom) toward (and
453
+ including) ``workspace_root``, returning the first ancestor whose
454
+ ``pom.xml`` contains a ``<modules>`` section. Falls back to
455
+ ``workspace_root`` when no such intermediate parent exists (e.g. flat
456
+ single-level monorepos).
457
+
458
+ This keeps the jdtls index bounded to a module *group* (typically
459
+ 5-20 modules) rather than the full IDE workspace (potentially 200+ modules)
460
+ which would cause ``OutOfMemoryError: Java heap space`` during indexing.
461
+
462
+ Both *initial_module* and *workspace_root* are resolved to real paths
463
+ before walking to avoid symlink-based boundary escapes.
464
+ """
465
+ workspace_root = workspace_root.resolve()
466
+ current = initial_module.resolve().parent
467
+ # Bail immediately if the initial module is outside the workspace.
468
+ if not current.is_relative_to(workspace_root):
469
+ return workspace_root
470
+ while True:
471
+ pom = current / "pom.xml"
472
+ if pom.is_file():
473
+ try:
474
+ if "<modules>" in pom.read_text(encoding="utf-8", errors="ignore"):
475
+ return current
476
+ except OSError:
477
+ pass
478
+ if current in (workspace_root, current.parent):
479
+ break
480
+ current = current.parent
481
+ return workspace_root
482
+
483
+
448
484
  @lru_cache(maxsize=256)
449
485
  def _module_snapshot_path(module_uri: str) -> Path:
450
486
  """Return the path for the persistent snapshot file for *module_uri*.
@@ -494,6 +530,22 @@ def _compute_module_diff(
494
530
  return (diff, current)
495
531
 
496
532
 
533
+ def _wipe_data_dir(data_dir: Path) -> None:
534
+ """Remove *data_dir* so the next jdtls startup gets a clean cold start.
535
+
536
+ Called after an initialize timeout to recover from a corrupted OSGi
537
+ data-dir (e.g. left behind after an OOM crash).
538
+ """
539
+ shutil.rmtree(data_dir, ignore_errors=True)
540
+ if data_dir.exists():
541
+ logger.warning(
542
+ "jdtls: failed to wipe data-dir %s — next retry may hang again",
543
+ _redact_path(str(data_dir)),
544
+ )
545
+ else:
546
+ logger.info("jdtls: wiped data-dir %s after init timeout", _redact_path(str(data_dir)))
547
+
548
+
497
549
  def _clear_cache_on_version_change(cache_root: Path) -> None:
498
550
  """Clear jdtls data cache when the server version changes.
499
551
 
@@ -711,6 +763,7 @@ class JdtlsProxy:
711
763
  if result is None:
712
764
  logger.error("jdtls initialize request failed or timed out")
713
765
  await self.stop()
766
+ await asyncio.get_running_loop().run_in_executor(None, _wipe_data_dir, data_dir)
714
767
  return False
715
768
 
716
769
  self._jdtls_capabilities = result.get("capabilities", {})
@@ -862,16 +915,34 @@ class JdtlsProxy:
862
915
  return module_uri
863
916
 
864
917
  async def expand_full_workspace(self) -> None:
865
- """Expand jdtls workspace to the full monorepo root (background task).
918
+ """Expand jdtls workspace to the nearest Maven module-group root.
866
919
 
867
- Removes the initial module-scoped folder and adds the full root to
868
- avoid double-indexing.
920
+ Instead of expanding all the way to the IDE workspace root (which may
921
+ contain hundreds of Maven modules and cause jdtls OOM), this walks up
922
+ from the initial module to find the *nearest* ancestor directory whose
923
+ ``pom.xml`` declares ``<modules>`` — i.e. the tightest multi-module
924
+ Maven parent. This covers sibling modules (the common case for
925
+ cross-module navigation) without ballooning the index.
926
+
927
+ Falls back to the full IDE workspace root when no intermediate
928
+ multi-module parent is found.
929
+
930
+ Removes all individually-added module folders to avoid double-indexing
931
+ with the newly added group root.
869
932
  """
870
933
  if self._workspace_expanded or not self._available or not self._original_root_uri:
871
934
  return
872
935
  from pygls.uris import from_fs_path, to_fs_path
873
936
 
874
- root_path = to_fs_path(self._original_root_uri) or self._original_root_uri
937
+ workspace_path = to_fs_path(self._original_root_uri) or self._original_root_uri
938
+
939
+ # Find the tightest multi-module Maven parent of the initial module.
940
+ group_root_path: str = workspace_path
941
+ if self._initial_module_uri:
942
+ initial_path = to_fs_path(self._initial_module_uri) or self._initial_module_uri
943
+ group_root_path = str(_find_maven_group_root(Path(initial_path), Path(workspace_path)))
944
+
945
+ root_path = group_root_path
875
946
  root_uri = from_fs_path(root_path) or self._original_root_uri
876
947
  if self.modules.was_added(root_uri):
877
948
  self._workspace_expanded = True
@@ -887,7 +958,7 @@ class JdtlsProxy:
887
958
  p = to_fs_path(uri) or uri
888
959
  removed.append({"uri": uri, "name": Path(p).name})
889
960
 
890
- logger.info("jdtls: expanding to full workspace %s", _redact_path(root_path))
961
+ logger.info("jdtls: expanding workspace to module group %s", _redact_path(root_path))
891
962
  await self.send_notification(
892
963
  _WORKSPACE_DID_CHANGE_FOLDERS,
893
964
  {"event": {"added": [{"uri": root_uri, "name": Path(root_path).name}], "removed": removed}},
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  import json
7
7
  import subprocess
8
+ from pathlib import Path
8
9
  from typing import Any
9
10
  from unittest.mock import MagicMock, patch
10
11
 
@@ -850,6 +851,7 @@ class TestStartPassesEnvToSubprocess:
850
851
  patch.object(JdtlsProxy, "_stderr_reader", side_effect=fake_stderr_reader),
851
852
  patch.object(JdtlsProxy, "send_request", side_effect=fake_send_request),
852
853
  patch.object(JdtlsProxy, "stop", side_effect=lambda: None),
854
+ patch.object(proxy_mod, "_wipe_data_dir"), # avoid blocking rmtree in tests
853
855
  ):
854
856
  ok = await proxy.start({"rootUri": f"file://{tmp_path}"})
855
857
 
@@ -858,6 +860,67 @@ class TestStartPassesEnvToSubprocess:
858
860
  # The crucial assertion: env= was passed through to the subprocess call.
859
861
  assert captured["env"] == sentinel_env
860
862
 
863
+ @pytest.mark.asyncio
864
+ async def test_wipe_runs_in_executor_on_init_timeout(self, tmp_path: Any) -> None:
865
+ """_wipe_data_dir must be dispatched via run_in_executor to avoid blocking the event loop."""
866
+ from unittest.mock import patch
867
+
868
+ import java_functional_lsp.proxy as proxy_mod
869
+
870
+ proxy = JdtlsProxy()
871
+ proxy._jdtls_on_path = True
872
+
873
+ fake_process = MagicMock()
874
+ fake_process.pid = 99999
875
+ fake_process.stdout = MagicMock()
876
+ fake_process.stderr = MagicMock()
877
+ fake_process.returncode = 0
878
+
879
+ async def fake_wait() -> int:
880
+ return 0
881
+
882
+ fake_process.wait = fake_wait
883
+
884
+ async def fake_create_subprocess(*_a: Any, **_kw: Any) -> Any:
885
+ return fake_process
886
+
887
+ async def fake_reader_loop(_: Any) -> None:
888
+ return None
889
+
890
+ async def fake_stderr_reader(_: Any) -> None:
891
+ return None
892
+
893
+ async def fake_send_request(*_a: Any, **_kw: Any) -> None:
894
+ return None # simulate initialize timeout
895
+
896
+ wipe_calls: list[Any] = []
897
+
898
+ async def fake_run_in_executor(_executor: Any, fn: Any, *args: Any) -> Any:
899
+ if fn is proxy_mod._wipe_data_dir:
900
+ wipe_calls.append((fn, args))
901
+ return None
902
+ # Other blocking calls (e.g. _blocking_startup) must run for real
903
+ return fn(*args)
904
+
905
+ fake_loop = MagicMock()
906
+ fake_loop.run_in_executor = fake_run_in_executor
907
+
908
+ with (
909
+ patch.object(proxy_mod.shutil, "which", return_value="/fake/jdtls"),
910
+ patch.object(proxy_mod, "build_jdtls_env", return_value={}),
911
+ patch.object(proxy_mod.asyncio, "create_subprocess_exec", side_effect=fake_create_subprocess),
912
+ patch.object(JdtlsProxy, "_reader_loop", side_effect=fake_reader_loop),
913
+ patch.object(JdtlsProxy, "_stderr_reader", side_effect=fake_stderr_reader),
914
+ patch.object(JdtlsProxy, "send_request", side_effect=fake_send_request),
915
+ patch.object(JdtlsProxy, "stop", side_effect=lambda: None),
916
+ patch("java_functional_lsp.proxy.asyncio.get_running_loop", return_value=fake_loop),
917
+ ):
918
+ ok = await proxy.start({"rootUri": f"file://{tmp_path}"})
919
+
920
+ assert ok is False
921
+ assert len(wipe_calls) == 1
922
+ assert wipe_calls[0][0] is proxy_mod._wipe_data_dir
923
+
861
924
 
862
925
  class TestFindModuleRoot:
863
926
  """Tests for find_module_root — build-file detection for module scoping."""
@@ -1037,6 +1100,65 @@ class TestLazyStart:
1037
1100
  assert result2 is None # Already known
1038
1101
  assert proxy.send_notification.call_count == 1 # type: ignore[attr-defined]
1039
1102
 
1103
+ async def test_expand_uses_maven_group_root_not_workspace_root(self, tmp_path: Path) -> None:
1104
+ """expand_full_workspace expands to nearest multi-module Maven parent, not the full IDE root."""
1105
+ from unittest.mock import AsyncMock
1106
+
1107
+ from java_functional_lsp.proxy import JdtlsProxy
1108
+
1109
+ # Layout: workspace/group/module — group/pom.xml has <modules>
1110
+ workspace = tmp_path / "workspace"
1111
+ group = workspace / "group"
1112
+ module = group / "module"
1113
+ module.mkdir(parents=True)
1114
+ (group / "pom.xml").write_text("<project><modules><module>module</module></modules></project>")
1115
+ (workspace / "pom.xml").write_text("<project><modules><module>group</module></modules></project>")
1116
+
1117
+ module_uri = module.as_uri()
1118
+ workspace_uri = workspace.as_uri()
1119
+
1120
+ proxy = JdtlsProxy()
1121
+ proxy._available = True
1122
+ proxy._original_root_uri = workspace_uri
1123
+ proxy._initial_module_uri = module_uri
1124
+ proxy.modules.mark_added(module_uri)
1125
+ proxy.send_notification = AsyncMock() # type: ignore[assignment]
1126
+ await proxy.expand_full_workspace()
1127
+
1128
+ call_args = proxy.send_notification.call_args[0] # type: ignore[attr-defined]
1129
+ event = call_args[1]["event"]
1130
+ # Should expand to group/, NOT workspace/
1131
+ assert event["added"][0]["uri"] == group.as_uri()
1132
+ assert event["added"][0]["uri"] != workspace_uri
1133
+
1134
+ async def test_expand_falls_back_to_workspace_root_when_no_group_pom(self, tmp_path: Path) -> None:
1135
+ """Falls back to the IDE workspace root when no intermediate multi-module parent exists."""
1136
+ from unittest.mock import AsyncMock
1137
+
1138
+ from java_functional_lsp.proxy import JdtlsProxy
1139
+
1140
+ # Flat layout: workspace/module — no intermediate pom.xml
1141
+ workspace = tmp_path / "workspace"
1142
+ module = workspace / "module"
1143
+ module.mkdir(parents=True)
1144
+ # workspace/pom.xml has <modules>, but there's nothing in between
1145
+ (workspace / "pom.xml").write_text("<project><modules><module>module</module></modules></project>")
1146
+
1147
+ module_uri = module.as_uri()
1148
+ workspace_uri = workspace.as_uri()
1149
+
1150
+ proxy = JdtlsProxy()
1151
+ proxy._available = True
1152
+ proxy._original_root_uri = workspace_uri
1153
+ proxy._initial_module_uri = module_uri
1154
+ proxy.modules.mark_added(module_uri)
1155
+ proxy.send_notification = AsyncMock() # type: ignore[assignment]
1156
+ await proxy.expand_full_workspace()
1157
+
1158
+ call_args = proxy.send_notification.call_args[0] # type: ignore[attr-defined]
1159
+ event = call_args[1]["event"]
1160
+ assert event["added"][0]["uri"] == workspace_uri
1161
+
1040
1162
  async def test_expand_full_workspace_sends_notification(self) -> None:
1041
1163
  from unittest.mock import AsyncMock
1042
1164
 
@@ -1107,7 +1229,8 @@ class TestLazyStart:
1107
1229
  proxy._jdtls_on_path = True
1108
1230
  captured: dict[str, Any] = {}
1109
1231
 
1110
- async def capturing_start(params: Any, *, module_root_uri: str | None = None, config: Any = None) -> bool:
1232
+ async def capturing_start(_params: Any, *, module_root_uri: str | None = None, config: Any = None) -> bool:
1233
+ _ = config
1111
1234
  captured["module_root_uri"] = module_root_uri
1112
1235
  return False
1113
1236
 
@@ -1131,7 +1254,8 @@ class TestLazyStart:
1131
1254
 
1132
1255
  captured: dict[str, Any] = {}
1133
1256
 
1134
- async def capturing_start(params: Any, *, module_root_uri: str | None = None, config: Any = None) -> bool:
1257
+ async def capturing_start(_params: Any, *, module_root_uri: str | None = None, config: Any = None) -> bool:
1258
+ _ = config
1135
1259
  captured["module_root_uri"] = module_root_uri
1136
1260
  return False
1137
1261
 
@@ -1155,3 +1279,58 @@ class TestLazyStart:
1155
1279
  module_root = "file:///workspace/monorepo/module-a"
1156
1280
  module_hash = hashlib.sha256(module_root.encode()).hexdigest()[:12]
1157
1281
  assert expected_hash != module_hash
1282
+
1283
+
1284
+ # ---------------------------------------------------------------------------
1285
+ # _wipe_data_dir
1286
+ # ---------------------------------------------------------------------------
1287
+
1288
+
1289
+ class TestWipeDataDir:
1290
+ def test_wipe_removes_directory(self, tmp_path: Path) -> None:
1291
+ """Directory is removed and info is logged."""
1292
+ from java_functional_lsp.proxy import _wipe_data_dir
1293
+
1294
+ data_dir = tmp_path / "data"
1295
+ data_dir.mkdir()
1296
+ (data_dir / "some_file.dat").write_text("x")
1297
+
1298
+ _wipe_data_dir(data_dir)
1299
+
1300
+ assert not data_dir.exists()
1301
+
1302
+ def test_wipe_logs_warning_when_removal_fails(self, tmp_path: Path) -> None:
1303
+ """Warning is logged when rmtree silently fails (dir still present)."""
1304
+ from unittest.mock import patch
1305
+
1306
+ from java_functional_lsp.proxy import _wipe_data_dir
1307
+
1308
+ data_dir = tmp_path / "data"
1309
+ data_dir.mkdir()
1310
+
1311
+ with patch("shutil.rmtree"): # no-op — dir remains
1312
+ with patch("java_functional_lsp.proxy.logger") as mock_logger:
1313
+ _wipe_data_dir(data_dir)
1314
+ mock_logger.warning.assert_called_once()
1315
+ # info must NOT be called when wipe failed
1316
+ mock_logger.info.assert_not_called()
1317
+
1318
+ def test_wipe_logs_info_on_success(self, tmp_path: Path) -> None:
1319
+ """Info is logged (not warning) when directory is successfully removed."""
1320
+ from unittest.mock import patch
1321
+
1322
+ from java_functional_lsp.proxy import _wipe_data_dir
1323
+
1324
+ data_dir = tmp_path / "data"
1325
+ data_dir.mkdir()
1326
+
1327
+ with patch("java_functional_lsp.proxy.logger") as mock_logger:
1328
+ _wipe_data_dir(data_dir)
1329
+ mock_logger.info.assert_called_once()
1330
+ mock_logger.warning.assert_not_called()
1331
+
1332
+ def test_wipe_nonexistent_dir_does_not_raise(self, tmp_path: Path) -> None:
1333
+ """Calling wipe on a non-existent dir is safe."""
1334
+ from java_functional_lsp.proxy import _wipe_data_dir
1335
+
1336
+ _wipe_data_dir(tmp_path / "ghost") # should not raise