java-functional-lsp 0.7.4__tar.gz → 0.7.5__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 (66) hide show
  1. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/PKG-INFO +1 -1
  2. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/pyproject.toml +1 -1
  3. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/src/java_functional_lsp/__init__.py +1 -1
  4. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/src/java_functional_lsp/proxy.py +109 -19
  5. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/src/java_functional_lsp/server.py +53 -17
  6. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/tests/test_proxy.py +37 -5
  7. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/tests/test_server.py +72 -0
  8. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/.claude-plugin/plugin.json +0 -0
  9. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/.githooks/pre-commit +0 -0
  10. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/.githooks/pre-push +0 -0
  11. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/.github/CODEOWNERS +0 -0
  12. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/.github/ISSUE_TEMPLATE/bug-report.md +0 -0
  13. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/.github/ISSUE_TEMPLATE/feature-request.md +0 -0
  14. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  15. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/.github/SECURITY.md +0 -0
  16. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/.github/dependabot.yml +0 -0
  17. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/.github/release-drafter.yml +0 -0
  18. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/.github/workflows/publish.yml +0 -0
  19. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/.github/workflows/release-drafter.yml +0 -0
  20. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/.github/workflows/stale.yml +0 -0
  21. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/.github/workflows/test.yml +0 -0
  22. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/.github/workflows/update-homebrew.yml +0 -0
  23. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/.github/workflows/vscode-ext.yml +0 -0
  24. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/.gitignore +0 -0
  25. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/CONTRIBUTING.md +0 -0
  26. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/LICENSE +0 -0
  27. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/README.md +0 -0
  28. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/SKILL.md +0 -0
  29. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/commands/lint-java.md +0 -0
  30. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/editors/intellij/README.md +0 -0
  31. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/editors/intellij/lsp4ij-template.json +0 -0
  32. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/editors/vscode/.vscodeignore +0 -0
  33. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/editors/vscode/README.md +0 -0
  34. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/editors/vscode/package-lock.json +0 -0
  35. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/editors/vscode/package.json +0 -0
  36. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/editors/vscode/src/extension.ts +0 -0
  37. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/editors/vscode/tsconfig.json +0 -0
  38. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/hooks/hooks.json +0 -0
  39. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/hooks/java_linter_reminder.py +0 -0
  40. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/scripts/ensure-lsp.sh +0 -0
  41. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/scripts/generate-formula.py +0 -0
  42. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/src/java_functional_lsp/__main__.py +0 -0
  43. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/src/java_functional_lsp/analyzers/__init__.py +0 -0
  44. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/src/java_functional_lsp/analyzers/base.py +0 -0
  45. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/src/java_functional_lsp/analyzers/exception_checker.py +0 -0
  46. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/src/java_functional_lsp/analyzers/functional_checker.py +0 -0
  47. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/src/java_functional_lsp/analyzers/mutation_checker.py +0 -0
  48. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/src/java_functional_lsp/analyzers/null_checker.py +0 -0
  49. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/src/java_functional_lsp/analyzers/spring_checker.py +0 -0
  50. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/src/java_functional_lsp/cli.py +0 -0
  51. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/src/java_functional_lsp/fixes.py +0 -0
  52. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/tests/__init__.py +0 -0
  53. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/tests/conftest.py +0 -0
  54. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/tests/test_base.py +0 -0
  55. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/tests/test_cli.py +0 -0
  56. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/tests/test_config.py +0 -0
  57. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/tests/test_e2e.py +0 -0
  58. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/tests/test_e2e_jdtls.py +0 -0
  59. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/tests/test_exception_checker.py +0 -0
  60. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/tests/test_fixes.py +0 -0
  61. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/tests/test_functional_checker.py +0 -0
  62. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/tests/test_mutation_checker.py +0 -0
  63. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/tests/test_null_checker.py +0 -0
  64. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/tests/test_spring_checker.py +0 -0
  65. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/tests/test_suppress.py +0 -0
  66. {java_functional_lsp-0.7.4 → java_functional_lsp-0.7.5}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: java-functional-lsp
3
- Version: 0.7.4
3
+ Version: 0.7.5
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.7.4"
7
+ version = "0.7.5"
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.7.4"
3
+ __version__ = "0.7.5"
@@ -12,6 +12,7 @@ import platform
12
12
  import re
13
13
  import shutil
14
14
  import subprocess
15
+ from collections import deque
15
16
  from collections.abc import Callable, Mapping
16
17
  from functools import lru_cache
17
18
  from pathlib import Path
@@ -19,7 +20,8 @@ from typing import Any
19
20
 
20
21
  logger = logging.getLogger(__name__)
21
22
 
22
- REQUEST_TIMEOUT = 30.0 # seconds
23
+ REQUEST_TIMEOUT = 30.0 # seconds — per-request timeout for normal operations
24
+ _INITIALIZE_TIMEOUT = 120.0 # seconds — module-scoped init can still be slow (Maven classpath resolution)
23
25
  DEFAULT_JVM_MAX_HEAP = "4g"
24
26
  _STDERR_LINE_MAX = 1000
25
27
 
@@ -316,6 +318,78 @@ async def read_message(reader: asyncio.StreamReader) -> dict[str, Any] | None:
316
318
  _BUILD_FILES = ("pom.xml", "build.gradle", "build.gradle.kts")
317
319
  _WORKSPACE_DID_CHANGE_FOLDERS = "workspace/didChangeWorkspaceFolders"
318
320
  _MAX_QUEUED_NOTIFICATIONS = 200
321
+ _MODULE_READY_TIMEOUT = 30.0
322
+
323
+
324
+ class ModuleState:
325
+ """Module import states — UNKNOWN → ADDED → READY."""
326
+
327
+ UNKNOWN = "unknown"
328
+ ADDED = "added"
329
+ READY = "ready"
330
+
331
+
332
+ class ModuleRegistry:
333
+ """Thread-safe (asyncio) registry tracking jdtls module import states.
334
+
335
+ Uses a plain dict for O(1) hot-path lookups and per-module ``asyncio.Event``
336
+ for adaptive waiting — coroutines blocked on ``wait_until_ready()`` wake
337
+ instantly when ``mark_ready()`` is called, instead of a fixed sleep.
338
+
339
+ Safe without locks because asyncio is single-threaded: dict mutations that
340
+ don't span an ``await`` are atomic.
341
+ """
342
+
343
+ def __init__(self) -> None:
344
+ self._states: dict[str, str] = {}
345
+ self._ready_events: dict[str, asyncio.Event] = {}
346
+
347
+ def get_state(self, uri: str) -> str:
348
+ """O(1) state lookup. Returns ModuleState constant."""
349
+ return self._states.get(uri, ModuleState.UNKNOWN)
350
+
351
+ def is_ready(self, uri: str) -> bool:
352
+ """O(1) hot-path check — zero overhead when module is ready."""
353
+ return self._states.get(uri) == ModuleState.READY
354
+
355
+ def was_added(self, uri: str) -> bool:
356
+ """True if module was sent to jdtls (ADDED or READY)."""
357
+ return uri in self._states
358
+
359
+ def mark_added(self, uri: str) -> None:
360
+ """Mark module as sent to jdtls. Pre-creates the Event for waiters.
361
+
362
+ Must be called before any ``await`` to prevent duplicate add_module calls.
363
+ """
364
+ self._states[uri] = ModuleState.ADDED
365
+ self._ready_events.setdefault(uri, asyncio.Event())
366
+
367
+ def mark_ready(self, uri: str) -> None:
368
+ """Mark module as confirmed working. Wakes all coroutines waiting on it."""
369
+ self._states[uri] = ModuleState.READY
370
+ event = self._ready_events.pop(uri, None)
371
+ if event is not None:
372
+ event.set()
373
+
374
+ def clear(self) -> None:
375
+ """Reset all state. Used by tests."""
376
+ self._states.clear()
377
+ self._ready_events.clear()
378
+
379
+ async def wait_until_ready(self, uri: str, timeout: float = _MODULE_READY_TIMEOUT) -> bool:
380
+ """Suspend until the module is ready or timeout expires.
381
+
382
+ Returns True if ready, False on timeout. If already READY, returns
383
+ immediately without suspending.
384
+ """
385
+ event = self._ready_events.setdefault(uri, asyncio.Event())
386
+ if event.is_set():
387
+ return True
388
+ try:
389
+ await asyncio.wait_for(event.wait(), timeout=timeout)
390
+ return True
391
+ except asyncio.TimeoutError:
392
+ return False
319
393
 
320
394
 
321
395
  @lru_cache(maxsize=256)
@@ -336,6 +410,9 @@ def find_module_root(file_path: str) -> str | None:
336
410
 
337
411
  Returns the directory path, or ``None`` if no build file is found before
338
412
  reaching the filesystem root. Results are cached by parent directory.
413
+
414
+ **Note:** cache entries are never invalidated. Build files added after the
415
+ first lookup for a given directory will not be detected until process restart.
339
416
  """
340
417
  return _cached_module_root(str(Path(file_path).parent))
341
418
 
@@ -373,10 +450,10 @@ class JdtlsProxy:
373
450
  self._start_failed = False
374
451
  self._jdtls_on_path = False
375
452
  self._lazy_start_fired = False
376
- self._queued_notifications: list[tuple[str, Any]] = []
453
+ self._queued_notifications: deque[tuple[str, Any]] = deque(maxlen=_MAX_QUEUED_NOTIFICATIONS)
377
454
  self._original_root_uri: str | None = None
378
455
  self._initial_module_uri: str | None = None
379
- self._added_module_uris: set[str] = set()
456
+ self.modules = ModuleRegistry()
380
457
  self._workspace_expanded = False
381
458
 
382
459
  @property
@@ -437,9 +514,9 @@ class JdtlsProxy:
437
514
  ws = caps.setdefault("workspace", {})
438
515
  ws["workspaceFolders"] = True
439
516
 
440
- # Track the initial module as already loaded.
517
+ # Track the initial module as already loaded (mark ADDED before await).
441
518
  self._initial_module_uri = module_root_uri
442
- self._added_module_uris.add(effective_root_uri)
519
+ self.modules.mark_added(effective_root_uri)
443
520
 
444
521
  # Build a clean environment for jdtls.
445
522
  loop = asyncio.get_running_loop()
@@ -468,7 +545,7 @@ class JdtlsProxy:
468
545
  if self._process.stderr is not None:
469
546
  self._stderr_task = asyncio.create_task(self._stderr_reader(self._process.stderr))
470
547
 
471
- result = await self.send_request("initialize", effective_params)
548
+ result = await self.send_request("initialize", effective_params, timeout=_INITIALIZE_TIMEOUT)
472
549
  if result is None:
473
550
  logger.error("jdtls initialize request failed or timed out")
474
551
  await self.stop()
@@ -508,33 +585,45 @@ class JdtlsProxy:
508
585
  self._start_failed = True
509
586
  self._queued_notifications.clear()
510
587
  return started
588
+ except Exception:
589
+ self._start_failed = True
590
+ self._queued_notifications.clear()
591
+ raise
511
592
  finally:
512
593
  self._starting = False
513
594
 
514
595
  def queue_notification(self, method: str, params: Any) -> None:
515
596
  """Buffer a notification for replay after jdtls starts.
516
597
 
517
- Capped at ``_MAX_QUEUED_NOTIFICATIONS`` to prevent unbounded memory
518
- growth during long jdtls startup. Oldest entries are dropped on overflow.
598
+ Uses a ``deque(maxlen=200)`` so oldest entries are dropped in O(1)
599
+ when the queue overflows during long jdtls startup.
519
600
  """
520
- if len(self._queued_notifications) >= _MAX_QUEUED_NOTIFICATIONS:
521
- self._queued_notifications.pop(0)
522
601
  self._queued_notifications.append((method, params))
523
602
 
524
603
  async def flush_queued_notifications(self) -> None:
525
604
  """Send all queued notifications to jdtls."""
526
- queue, self._queued_notifications = self._queued_notifications, []
605
+ queue = list(self._queued_notifications)
606
+ self._queued_notifications.clear()
527
607
  for method, params in queue:
528
608
  await self.send_notification(method, params)
529
609
 
530
- async def add_module_if_new(self, file_uri: str) -> None:
531
- """Add the module containing *file_uri* to jdtls if not already added."""
610
+ async def add_module_if_new(self, file_uri: str) -> str | None:
611
+ """Add the module containing *file_uri* to jdtls if not already added.
612
+
613
+ Returns the module URI if a new module was added (UNKNOWN → ADDED),
614
+ or ``None`` if already known or unavailable. The returned URI can be
615
+ used with ``modules.wait_until_ready()`` for adaptive waiting.
616
+
617
+ Calls ``modules.mark_added()`` before any ``await`` to prevent
618
+ duplicate add calls from concurrent coroutines.
619
+ """
532
620
  if not self._available:
533
- return
621
+ return None
534
622
  module_uri = _resolve_module_uri(file_uri)
535
- if module_uri is None or module_uri in self._added_module_uris:
536
- return
537
- self._added_module_uris.add(module_uri)
623
+ if module_uri is None or self.modules.was_added(module_uri):
624
+ return None
625
+ # Mark ADDED before await — atomic in asyncio, prevents duplicate sends.
626
+ self.modules.mark_added(module_uri)
538
627
  from pygls.uris import to_fs_path
539
628
 
540
629
  logger.info("jdtls: adding module %s", _redact_path(to_fs_path(module_uri)))
@@ -543,6 +632,7 @@ class JdtlsProxy:
543
632
  _WORKSPACE_DID_CHANGE_FOLDERS,
544
633
  {"event": {"added": [{"uri": module_uri, "name": mod_name}], "removed": []}},
545
634
  )
635
+ return module_uri
546
636
 
547
637
  async def expand_full_workspace(self) -> None:
548
638
  """Expand jdtls workspace to the full monorepo root (background task).
@@ -556,10 +646,10 @@ class JdtlsProxy:
556
646
 
557
647
  root_path = to_fs_path(self._original_root_uri) or self._original_root_uri
558
648
  root_uri = from_fs_path(root_path) or self._original_root_uri
559
- if root_uri in self._added_module_uris:
649
+ if self.modules.was_added(root_uri):
560
650
  self._workspace_expanded = True
561
651
  return
562
- self._added_module_uris.add(root_uri)
652
+ self.modules.mark_added(root_uri)
563
653
 
564
654
  # Remove initial module folder to avoid double-indexing.
565
655
  removed: list[dict[str, str]] = []
@@ -26,7 +26,7 @@ from .analyzers.mutation_checker import MutationChecker
26
26
  from .analyzers.null_checker import NullChecker
27
27
  from .analyzers.spring_checker import SpringChecker
28
28
  from .fixes import get_fix, get_fix_registry_keys
29
- from .proxy import JdtlsProxy
29
+ from .proxy import JdtlsProxy, _resolve_module_uri
30
30
 
31
31
  logger = logging.getLogger(__name__)
32
32
 
@@ -337,7 +337,7 @@ def _forward_or_queue(method: str, serialized: Any) -> None:
337
337
  task = asyncio.create_task(server._proxy.send_notification(method, serialized))
338
338
  _bg_tasks.add(task)
339
339
  task.add_done_callback(_bg_tasks.discard)
340
- elif server._proxy._starting:
340
+ elif server._proxy._lazy_start_fired and not server._proxy._start_failed:
341
341
  server._proxy.queue_notification(method, serialized)
342
342
 
343
343
 
@@ -440,11 +440,55 @@ async def _expand_workspace_background() -> None:
440
440
  # they only activate after jdtls starts.
441
441
 
442
442
 
443
+ async def _ensure_module_and_forward(method: str, params: Any, file_uri: str) -> Any | None:
444
+ """Forward a request to jdtls, ensuring the file's module is loaded.
445
+
446
+ Uses ``ModuleRegistry`` for adaptive waiting:
447
+ - **READY**: forward immediately (zero overhead on hot path)
448
+ - **UNKNOWN**: add module, wait until ready (adaptive, not fixed sleep)
449
+ - **ADDED**: module sent but not confirmed — wait until ready
450
+
451
+ When a request succeeds, marks the module as READY so subsequent
452
+ requests skip the wait entirely.
453
+ """
454
+ proxy = server._proxy
455
+ if not proxy.is_available:
456
+ return None
457
+
458
+ module_uri = _resolve_module_uri(file_uri)
459
+
460
+ # Hot path: module already confirmed working.
461
+ if module_uri and proxy.modules.is_ready(module_uri):
462
+ return await proxy.send_request(method, _serialize_params(params))
463
+
464
+ # Cold path: add module if unknown, then wait for ready.
465
+ new_module_uri = await proxy.add_module_if_new(file_uri)
466
+
467
+ serialized = _serialize_params(params)
468
+ result = await proxy.send_request(method, serialized)
469
+
470
+ if result is not None:
471
+ # Success — mark module as ready so future requests are instant.
472
+ if module_uri:
473
+ proxy.modules.mark_ready(module_uri)
474
+ return result
475
+
476
+ # Null result and module is not yet ready — wait then retry once.
477
+ # Use a short timeout (5s) so single-caller case doesn't block for 30s.
478
+ # If a concurrent request succeeds, Event.set() wakes us early.
479
+ wait_uri = new_module_uri or module_uri
480
+ if wait_uri and not proxy.modules.is_ready(wait_uri):
481
+ await proxy.modules.wait_until_ready(wait_uri, timeout=5.0)
482
+ # Always retry once after waiting — even on timeout the module may be ready.
483
+ result = await proxy.send_request(method, serialized)
484
+ if result is not None and module_uri:
485
+ proxy.modules.mark_ready(module_uri)
486
+ return result
487
+
488
+
443
489
  async def _on_completion(params: lsp.CompletionParams) -> lsp.CompletionList | None:
444
490
  """Forward completion request to jdtls."""
445
- if not server._proxy.is_available:
446
- return None
447
- result = await server._proxy.send_request("textDocument/completion", _serialize_params(params))
491
+ result = await _ensure_module_and_forward("textDocument/completion", params, params.text_document.uri)
448
492
  if result is None:
449
493
  return None
450
494
  try:
@@ -455,9 +499,7 @@ async def _on_completion(params: lsp.CompletionParams) -> lsp.CompletionList | N
455
499
 
456
500
  async def _on_hover(params: lsp.HoverParams) -> lsp.Hover | None:
457
501
  """Forward hover request to jdtls."""
458
- if not server._proxy.is_available:
459
- return None
460
- result = await server._proxy.send_request("textDocument/hover", _serialize_params(params))
502
+ result = await _ensure_module_and_forward("textDocument/hover", params, params.text_document.uri)
461
503
  if result is None:
462
504
  return None
463
505
  try:
@@ -468,9 +510,7 @@ async def _on_hover(params: lsp.HoverParams) -> lsp.Hover | None:
468
510
 
469
511
  async def _on_definition(params: lsp.DefinitionParams) -> list[lsp.Location] | None:
470
512
  """Forward go-to-definition request to jdtls."""
471
- if not server._proxy.is_available:
472
- return None
473
- result = await server._proxy.send_request("textDocument/definition", _serialize_params(params))
513
+ result = await _ensure_module_and_forward("textDocument/definition", params, params.text_document.uri)
474
514
  if result is None:
475
515
  return None
476
516
  try:
@@ -483,9 +523,7 @@ async def _on_definition(params: lsp.DefinitionParams) -> list[lsp.Location] | N
483
523
 
484
524
  async def _on_references(params: lsp.ReferenceParams) -> list[lsp.Location] | None:
485
525
  """Forward find-references request to jdtls."""
486
- if not server._proxy.is_available:
487
- return None
488
- result = await server._proxy.send_request("textDocument/references", _serialize_params(params))
526
+ result = await _ensure_module_and_forward("textDocument/references", params, params.text_document.uri)
489
527
  if result is None:
490
528
  return None
491
529
  try:
@@ -496,9 +534,7 @@ async def _on_references(params: lsp.ReferenceParams) -> list[lsp.Location] | No
496
534
 
497
535
  async def _on_document_symbol(params: lsp.DocumentSymbolParams) -> list[lsp.DocumentSymbol] | None:
498
536
  """Forward document symbol request to jdtls."""
499
- if not server._proxy.is_available:
500
- return None
501
- result = await server._proxy.send_request("textDocument/documentSymbol", _serialize_params(params))
537
+ result = await _ensure_module_and_forward("textDocument/documentSymbol", params, params.text_document.uri)
502
538
  if result is None:
503
539
  return None
504
540
  try:
@@ -862,6 +862,12 @@ class TestStartPassesEnvToSubprocess:
862
862
  class TestFindModuleRoot:
863
863
  """Tests for find_module_root — build-file detection for module scoping."""
864
864
 
865
+ @pytest.fixture(autouse=True)
866
+ def _clear_cache(self) -> None:
867
+ from java_functional_lsp.proxy import _cached_module_root
868
+
869
+ _cached_module_root.cache_clear()
870
+
865
871
  def test_finds_pom_xml(self, tmp_path: Any) -> None:
866
872
  from java_functional_lsp.proxy import find_module_root
867
873
 
@@ -957,7 +963,9 @@ class TestLazyStart:
957
963
  for i in range(_MAX_QUEUED_NOTIFICATIONS + 50):
958
964
  proxy.queue_notification("textDocument/didChange", {"i": i})
959
965
  assert len(proxy._queued_notifications) == _MAX_QUEUED_NOTIFICATIONS
960
- # Oldest entries dropped — last entry should be the most recent
966
+ # Oldest entries dropped — first surviving entry is i=50
967
+ assert proxy._queued_notifications[0] == ("textDocument/didChange", {"i": 50})
968
+ # Last entry is the most recent
961
969
  assert proxy._queued_notifications[-1] == ("textDocument/didChange", {"i": _MAX_QUEUED_NOTIFICATIONS + 49})
962
970
 
963
971
  async def test_ensure_started_no_retry_after_failure(self) -> None:
@@ -998,10 +1006,13 @@ class TestLazyStart:
998
1006
  java_file.parent.mkdir()
999
1007
  java_file.touch()
1000
1008
  uri = java_file.as_uri()
1001
- await proxy.add_module_if_new(uri)
1009
+ result = await proxy.add_module_if_new(uri)
1010
+ assert result is not None # Returns module URI string
1002
1011
  proxy.send_notification.assert_called_once() # type: ignore[attr-defined]
1003
1012
  call_args = proxy.send_notification.call_args # type: ignore[attr-defined]
1004
1013
  assert call_args[0][0] == "workspace/didChangeWorkspaceFolders"
1014
+ # Module should be marked ADDED in registry
1015
+ assert proxy.modules.was_added(result)
1005
1016
 
1006
1017
  async def test_add_module_if_new_skips_duplicate(self) -> None:
1007
1018
  from unittest.mock import AsyncMock
@@ -1020,8 +1031,10 @@ class TestLazyStart:
1020
1031
  java_file.parent.mkdir()
1021
1032
  java_file.touch()
1022
1033
  uri = java_file.as_uri()
1023
- await proxy.add_module_if_new(uri)
1024
- await proxy.add_module_if_new(uri) # duplicate
1034
+ result1 = await proxy.add_module_if_new(uri)
1035
+ result2 = await proxy.add_module_if_new(uri) # duplicate
1036
+ assert result1 is not None # New module URI
1037
+ assert result2 is None # Already known
1025
1038
  assert proxy.send_notification.call_count == 1 # type: ignore[attr-defined]
1026
1039
 
1027
1040
  async def test_expand_full_workspace_sends_notification(self) -> None:
@@ -1037,6 +1050,25 @@ class TestLazyStart:
1037
1050
  proxy.send_notification.assert_called_once() # type: ignore[attr-defined]
1038
1051
  assert proxy._workspace_expanded is True
1039
1052
 
1053
+ async def test_expand_full_workspace_removes_initial_module(self) -> None:
1054
+ """When _initial_module_uri differs from root, it should be in the removed list."""
1055
+ from unittest.mock import AsyncMock
1056
+
1057
+ from java_functional_lsp.proxy import JdtlsProxy
1058
+
1059
+ proxy = JdtlsProxy()
1060
+ proxy._available = True
1061
+ proxy._original_root_uri = "file:///workspace/monorepo"
1062
+ proxy._initial_module_uri = "file:///workspace/monorepo/module-a"
1063
+ proxy.send_notification = AsyncMock() # type: ignore[assignment]
1064
+ await proxy.expand_full_workspace()
1065
+ proxy.send_notification.assert_called_once() # type: ignore[attr-defined]
1066
+ call_args = proxy.send_notification.call_args[0] # type: ignore[attr-defined]
1067
+ event = call_args[1]["event"]
1068
+ assert len(event["removed"]) == 1
1069
+ assert event["removed"][0]["uri"] == "file:///workspace/monorepo/module-a"
1070
+ assert event["added"][0]["uri"] == "file:///workspace/monorepo"
1071
+
1040
1072
  async def test_expand_full_workspace_noop_when_not_available(self) -> None:
1041
1073
  from unittest.mock import AsyncMock
1042
1074
 
@@ -1057,7 +1089,7 @@ class TestLazyStart:
1057
1089
  proxy = JdtlsProxy()
1058
1090
  proxy._available = True
1059
1091
  proxy._original_root_uri = "file:///workspace/monorepo"
1060
- proxy._added_module_uris.add("file:///workspace/monorepo")
1092
+ proxy.modules.mark_added("file:///workspace/monorepo")
1061
1093
  proxy.send_notification = AsyncMock() # type: ignore[assignment]
1062
1094
  await proxy.expand_full_workspace()
1063
1095
  proxy.send_notification.assert_not_called() # type: ignore[attr-defined]
@@ -476,6 +476,78 @@ class TestServerInternals:
476
476
  mock_flush.assert_not_called()
477
477
  mock_expand.assert_not_called()
478
478
 
479
+ async def test_ensure_module_and_forward_ready_module_fast_path(self) -> None:
480
+ """READY module → single send_request, no add_module call."""
481
+ from unittest.mock import AsyncMock, patch
482
+
483
+ from java_functional_lsp.server import _ensure_module_and_forward
484
+ from java_functional_lsp.server import server as srv
485
+
486
+ srv._proxy.modules.mark_added("file:///mod")
487
+ srv._proxy.modules.mark_ready("file:///mod")
488
+ mock_send = AsyncMock(return_value={"result": "ok"})
489
+ try:
490
+ with (
491
+ patch.object(srv._proxy, "send_request", mock_send),
492
+ patch.object(srv._proxy, "_available", True),
493
+ patch("java_functional_lsp.server._resolve_module_uri", return_value="file:///mod"),
494
+ ):
495
+ result = await _ensure_module_and_forward("textDocument/hover", {}, "file:///mod/F.java")
496
+ finally:
497
+ srv._proxy.modules.clear()
498
+ assert result == {"result": "ok"}
499
+ assert mock_send.call_count == 1
500
+
501
+ async def test_ensure_module_and_forward_new_module_waits_and_retries(self) -> None:
502
+ """UNKNOWN module → add, first request null, wait_until_ready, retry succeeds."""
503
+ from unittest.mock import AsyncMock, patch
504
+
505
+ from java_functional_lsp.server import _ensure_module_and_forward
506
+ from java_functional_lsp.server import server as srv
507
+
508
+ mock_add = AsyncMock(return_value="file:///mod")
509
+ mock_send = AsyncMock(side_effect=[None, {"result": "ok"}])
510
+
511
+ async def mock_wait(uri: str, timeout: float = 30.0) -> bool:
512
+ srv._proxy.modules.mark_ready(uri)
513
+ return True
514
+
515
+ try:
516
+ with (
517
+ patch.object(srv._proxy, "add_module_if_new", mock_add),
518
+ patch.object(srv._proxy, "send_request", mock_send),
519
+ patch.object(srv._proxy, "_available", True),
520
+ patch.object(srv._proxy.modules, "wait_until_ready", mock_wait),
521
+ patch("java_functional_lsp.server._resolve_module_uri", return_value="file:///mod"),
522
+ ):
523
+ result = await _ensure_module_and_forward("textDocument/hover", {}, "file:///mod/F.java")
524
+ finally:
525
+ srv._proxy.modules.clear()
526
+ assert result == {"result": "ok"}
527
+ assert mock_send.call_count == 2
528
+
529
+ async def test_ensure_module_and_forward_success_marks_ready(self) -> None:
530
+ """First successful request marks module as READY."""
531
+ from unittest.mock import AsyncMock, patch
532
+
533
+ from java_functional_lsp.proxy import ModuleState
534
+ from java_functional_lsp.server import _ensure_module_and_forward
535
+ from java_functional_lsp.server import server as srv
536
+
537
+ mock_add = AsyncMock(return_value="file:///mod")
538
+ mock_send = AsyncMock(return_value={"result": "ok"})
539
+ try:
540
+ with (
541
+ patch.object(srv._proxy, "add_module_if_new", mock_add),
542
+ patch.object(srv._proxy, "send_request", mock_send),
543
+ patch.object(srv._proxy, "_available", True),
544
+ patch("java_functional_lsp.server._resolve_module_uri", return_value="file:///mod"),
545
+ ):
546
+ await _ensure_module_and_forward("textDocument/hover", {}, "file:///mod/F.java")
547
+ assert srv._proxy.modules.get_state("file:///mod") == ModuleState.READY
548
+ finally:
549
+ srv._proxy.modules.clear()
550
+
479
551
  def test_serialize_params_camelcase(self) -> None:
480
552
  from java_functional_lsp.server import _serialize_params
481
553