java-functional-lsp 0.7.3__tar.gz → 0.7.4__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.3 → java_functional_lsp-0.7.4}/PKG-INFO +2 -2
  2. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/README.md +1 -1
  3. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/pyproject.toml +1 -1
  4. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/src/java_functional_lsp/__init__.py +1 -1
  5. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/src/java_functional_lsp/proxy.py +186 -22
  6. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/src/java_functional_lsp/server.py +75 -15
  7. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/tests/test_proxy.py +262 -0
  8. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/tests/test_server.py +62 -0
  9. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/uv.lock +1 -1
  10. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/.claude-plugin/plugin.json +0 -0
  11. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/.githooks/pre-commit +0 -0
  12. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/.githooks/pre-push +0 -0
  13. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/.github/CODEOWNERS +0 -0
  14. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/.github/ISSUE_TEMPLATE/bug-report.md +0 -0
  15. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/.github/ISSUE_TEMPLATE/feature-request.md +0 -0
  16. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  17. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/.github/SECURITY.md +0 -0
  18. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/.github/dependabot.yml +0 -0
  19. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/.github/release-drafter.yml +0 -0
  20. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/.github/workflows/publish.yml +0 -0
  21. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/.github/workflows/release-drafter.yml +0 -0
  22. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/.github/workflows/stale.yml +0 -0
  23. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/.github/workflows/test.yml +0 -0
  24. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/.github/workflows/update-homebrew.yml +0 -0
  25. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/.github/workflows/vscode-ext.yml +0 -0
  26. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/.gitignore +0 -0
  27. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/CONTRIBUTING.md +0 -0
  28. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/LICENSE +0 -0
  29. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/SKILL.md +0 -0
  30. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/commands/lint-java.md +0 -0
  31. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/editors/intellij/README.md +0 -0
  32. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/editors/intellij/lsp4ij-template.json +0 -0
  33. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/editors/vscode/.vscodeignore +0 -0
  34. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/editors/vscode/README.md +0 -0
  35. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/editors/vscode/package-lock.json +0 -0
  36. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/editors/vscode/package.json +0 -0
  37. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/editors/vscode/src/extension.ts +0 -0
  38. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/editors/vscode/tsconfig.json +0 -0
  39. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/hooks/hooks.json +0 -0
  40. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/hooks/java_linter_reminder.py +0 -0
  41. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/scripts/ensure-lsp.sh +0 -0
  42. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/scripts/generate-formula.py +0 -0
  43. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/src/java_functional_lsp/__main__.py +0 -0
  44. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/src/java_functional_lsp/analyzers/__init__.py +0 -0
  45. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/src/java_functional_lsp/analyzers/base.py +0 -0
  46. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/src/java_functional_lsp/analyzers/exception_checker.py +0 -0
  47. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/src/java_functional_lsp/analyzers/functional_checker.py +0 -0
  48. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/src/java_functional_lsp/analyzers/mutation_checker.py +0 -0
  49. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/src/java_functional_lsp/analyzers/null_checker.py +0 -0
  50. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/src/java_functional_lsp/analyzers/spring_checker.py +0 -0
  51. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/src/java_functional_lsp/cli.py +0 -0
  52. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/src/java_functional_lsp/fixes.py +0 -0
  53. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/tests/__init__.py +0 -0
  54. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/tests/conftest.py +0 -0
  55. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/tests/test_base.py +0 -0
  56. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/tests/test_cli.py +0 -0
  57. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/tests/test_config.py +0 -0
  58. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/tests/test_e2e.py +0 -0
  59. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/tests/test_e2e_jdtls.py +0 -0
  60. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/tests/test_exception_checker.py +0 -0
  61. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/tests/test_fixes.py +0 -0
  62. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/tests/test_functional_checker.py +0 -0
  63. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/tests/test_mutation_checker.py +0 -0
  64. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/tests/test_null_checker.py +0 -0
  65. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/tests/test_spring_checker.py +0 -0
  66. {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/tests/test_suppress.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: java-functional-lsp
3
- Version: 0.7.3
3
+ Version: 0.7.4
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
@@ -33,7 +33,7 @@ Description-Content-Type: text/markdown
33
33
  [![Python](https://img.shields.io/pypi/pyversions/java-functional-lsp?v=1)](https://pypi.org/project/java-functional-lsp/)
34
34
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
35
35
 
36
- A Java Language Server that provides two things in one:
36
+ A Java Language Server that provides three things in one:
37
37
 
38
38
  1. **Full Java language support** — completions, hover, go-to-definition, compile errors, missing imports — by proxying [Eclipse jdtls](https://github.com/eclipse-jdtls/eclipse.jdt.ls) under the hood
39
39
  2. **16 functional programming rules** — catches anti-patterns and suggests Vavr/Lombok/Spring alternatives, all before compilation
@@ -5,7 +5,7 @@
5
5
  [![Python](https://img.shields.io/pypi/pyversions/java-functional-lsp?v=1)](https://pypi.org/project/java-functional-lsp/)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
7
 
8
- A Java Language Server that provides two things in one:
8
+ A Java Language Server that provides three things in one:
9
9
 
10
10
  1. **Full Java language support** — completions, hover, go-to-definition, compile errors, missing imports — by proxying [Eclipse jdtls](https://github.com/eclipse-jdtls/eclipse.jdt.ls) under the hood
11
11
  2. **16 functional programming rules** — catches anti-patterns and suggests Vavr/Lombok/Spring alternatives, all before compilation
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "java-functional-lsp"
7
- version = "0.7.3"
7
+ version = "0.7.4"
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.3"
3
+ __version__ = "0.7.4"
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
+ import copy
6
7
  import hashlib
7
8
  import json
8
9
  import logging
@@ -12,6 +13,7 @@ import re
12
13
  import shutil
13
14
  import subprocess
14
15
  from collections.abc import Callable, Mapping
16
+ from functools import lru_cache
15
17
  from pathlib import Path
16
18
  from typing import Any
17
19
 
@@ -311,6 +313,47 @@ async def read_message(reader: asyncio.StreamReader) -> dict[str, Any] | None:
311
313
  return None
312
314
 
313
315
 
316
+ _BUILD_FILES = ("pom.xml", "build.gradle", "build.gradle.kts")
317
+ _WORKSPACE_DID_CHANGE_FOLDERS = "workspace/didChangeWorkspaceFolders"
318
+ _MAX_QUEUED_NOTIFICATIONS = 200
319
+
320
+
321
+ @lru_cache(maxsize=256)
322
+ def _cached_module_root(dir_path: str) -> str | None:
323
+ """Cached walk up from *dir_path* to find nearest directory with a build file."""
324
+ current = Path(dir_path)
325
+ while True:
326
+ if any((current / bf).is_file() for bf in _BUILD_FILES):
327
+ return str(current)
328
+ parent = current.parent
329
+ if parent == current:
330
+ return None
331
+ current = parent
332
+
333
+
334
+ def find_module_root(file_path: str) -> str | None:
335
+ """Walk up from *file_path* to find the nearest directory containing a build file.
336
+
337
+ Returns the directory path, or ``None`` if no build file is found before
338
+ reaching the filesystem root. Results are cached by parent directory.
339
+ """
340
+ return _cached_module_root(str(Path(file_path).parent))
341
+
342
+
343
+ def _resolve_module_uri(file_uri: str) -> str | None:
344
+ """Convert a file URI to the URI of its nearest module root, or None."""
345
+ from pygls.uris import from_fs_path, to_fs_path
346
+
347
+ file_path = to_fs_path(file_uri)
348
+ if not file_path:
349
+ return None
350
+ module_root = find_module_root(file_path)
351
+ if module_root is None:
352
+ return None
353
+ module_uri = from_fs_path(module_root)
354
+ return module_uri or None
355
+
356
+
314
357
  class JdtlsProxy:
315
358
  """Manages a jdtls subprocess and provides async request/notification forwarding."""
316
359
 
@@ -324,6 +367,17 @@ class JdtlsProxy:
324
367
  self._on_diagnostics = on_diagnostics
325
368
  self._available = False
326
369
  self._jdtls_capabilities: dict[str, Any] = {}
370
+ # Lazy-start state
371
+ self._start_lock = asyncio.Lock()
372
+ self._starting = False
373
+ self._start_failed = False
374
+ self._jdtls_on_path = False
375
+ self._lazy_start_fired = False
376
+ self._queued_notifications: list[tuple[str, Any]] = []
377
+ self._original_root_uri: str | None = None
378
+ self._initial_module_uri: str | None = None
379
+ self._added_module_uris: set[str] = set()
380
+ self._workspace_expanded = False
327
381
 
328
382
  @property
329
383
  def is_available(self) -> bool:
@@ -339,31 +393,55 @@ class JdtlsProxy:
339
393
  """Get the latest jdtls diagnostics for a URI."""
340
394
  return list(self._diagnostics_cache.get(uri, []))
341
395
 
342
- async def start(self, init_params: dict[str, Any]) -> bool:
343
- """Start jdtls subprocess and initialize it."""
396
+ def check_available(self) -> bool:
397
+ """Check if jdtls is on PATH (lightweight, no subprocess started)."""
398
+ self._jdtls_on_path = shutil.which("jdtls") is not None
399
+ if not self._jdtls_on_path:
400
+ logger.warning("jdtls not found on PATH — running in standalone mode (custom rules only)")
401
+ return self._jdtls_on_path
402
+
403
+ async def start(self, init_params: dict[str, Any], *, module_root_uri: str | None = None) -> bool:
404
+ """Start jdtls subprocess and initialize it.
405
+
406
+ If *module_root_uri* is provided, jdtls is scoped to that module for
407
+ fast startup. The data-directory hash is always based on the original
408
+ workspace root (from init_params) so the index persists across restarts.
409
+ """
344
410
  jdtls_path = shutil.which("jdtls")
345
411
  if not jdtls_path:
346
- logger.warning("jdtls not found on PATH — running in standalone mode (custom rules only)")
347
412
  return False
348
413
 
349
- # jdtls requires a -data directory for workspace metadata (index, classpath, build state).
350
- # Use ~/.cache/jdtls-data/<hash> so it persists across reboots and LSP restarts.
351
- # Fallback order mirrors LSP spec: rootUri → rootPath → cwd.
352
- root_uri = init_params.get("rootUri") or init_params.get("rootPath") or str(Path.cwd())
353
- workspace_hash = hashlib.sha256(root_uri.encode()).hexdigest()[:12]
414
+ # Data-dir hash based on original workspace root (stable across module changes).
415
+ original_root: str = init_params.get("rootUri") or init_params.get("rootPath") or str(Path.cwd())
416
+ self._original_root_uri = original_root
417
+ workspace_hash = hashlib.sha256(original_root.encode()).hexdigest()[:12]
354
418
  data_dir = Path.home() / ".cache" / "jdtls-data" / workspace_hash
355
419
  data_dir.mkdir(parents=True, exist_ok=True)
356
420
 
357
- # Build a clean environment for jdtls: detect Java 21+ and set JAVA_HOME
358
- # explicitly, or strip JAVA_HOME if the inherited value points at an older
359
- # Java (e.g. an IDE launched us with a project SDK of Java 8). Without this,
360
- # jdtls 1.57+ fails with "jdtls requires at least Java 21" during its
361
- # Python launcher's version check.
362
- #
363
- # build_jdtls_env() issues several blocking subprocess calls (java -version,
364
- # /usr/libexec/java_home) to detect a suitable JDK. Run it in a thread pool
365
- # so those calls don't block the asyncio event loop — the IDE's LSP handshake
366
- # messages would otherwise stall for up to a few seconds during startup.
421
+ # Deep copy to avoid mutating server._init_params.
422
+ effective_params = copy.deepcopy(init_params)
423
+ effective_root_uri = module_root_uri or original_root
424
+ if module_root_uri:
425
+ effective_params["rootUri"] = module_root_uri
426
+ from pygls.uris import to_fs_path
427
+
428
+ effective_params["rootPath"] = to_fs_path(module_root_uri)
429
+ logger.info(
430
+ "jdtls: scoping to module %s (full root: %s)",
431
+ _redact_path(module_root_uri),
432
+ _redact_path(original_root),
433
+ )
434
+
435
+ # Inject workspaceFolders capability for later expansion.
436
+ caps = effective_params.setdefault("capabilities", {})
437
+ ws = caps.setdefault("workspace", {})
438
+ ws["workspaceFolders"] = True
439
+
440
+ # Track the initial module as already loaded.
441
+ self._initial_module_uri = module_root_uri
442
+ self._added_module_uris.add(effective_root_uri)
443
+
444
+ # Build a clean environment for jdtls.
367
445
  loop = asyncio.get_running_loop()
368
446
  jdtls_env = await loop.run_in_executor(None, build_jdtls_env)
369
447
 
@@ -385,14 +463,12 @@ class JdtlsProxy:
385
463
  _redact_path(jdtls_env.get("JAVA_HOME")),
386
464
  )
387
465
 
388
- # Start background readers for stdout (JSON-RPC) and stderr (diagnostics/errors)
389
466
  assert self._process.stdout is not None
390
467
  self._reader_task = asyncio.create_task(self._reader_loop(self._process.stdout))
391
468
  if self._process.stderr is not None:
392
469
  self._stderr_task = asyncio.create_task(self._stderr_reader(self._process.stderr))
393
470
 
394
- # Send initialize request
395
- result = await self.send_request("initialize", init_params)
471
+ result = await self.send_request("initialize", effective_params)
396
472
  if result is None:
397
473
  logger.error("jdtls initialize request failed or timed out")
398
474
  await self.stop()
@@ -401,7 +477,6 @@ class JdtlsProxy:
401
477
  self._jdtls_capabilities = result.get("capabilities", {})
402
478
  logger.info("jdtls initialized (capabilities: %s)", list(self._jdtls_capabilities.keys()))
403
479
 
404
- # Send initialized notification
405
480
  await self.send_notification("initialized", {})
406
481
  self._available = True
407
482
  return True
@@ -410,6 +485,95 @@ class JdtlsProxy:
410
485
  logger.error("Failed to start jdtls: %s", e)
411
486
  return False
412
487
 
488
+ async def ensure_started(self, init_params: dict[str, Any], file_uri: str) -> bool:
489
+ """Start jdtls lazily, scoped to the module containing *file_uri*.
490
+
491
+ Thread-safe: uses asyncio.Lock to prevent double-start from rapid
492
+ didOpen calls. Sets ``_start_failed`` on failure to prevent retries.
493
+ """
494
+ if self._available:
495
+ return True
496
+ if self._start_failed or not self._jdtls_on_path:
497
+ return False
498
+
499
+ async with self._start_lock:
500
+ if self._available:
501
+ return True
502
+
503
+ self._starting = True
504
+ try:
505
+ module_uri = _resolve_module_uri(file_uri)
506
+ started = await self.start(init_params, module_root_uri=module_uri)
507
+ if not started:
508
+ self._start_failed = True
509
+ self._queued_notifications.clear()
510
+ return started
511
+ finally:
512
+ self._starting = False
513
+
514
+ def queue_notification(self, method: str, params: Any) -> None:
515
+ """Buffer a notification for replay after jdtls starts.
516
+
517
+ Capped at ``_MAX_QUEUED_NOTIFICATIONS`` to prevent unbounded memory
518
+ growth during long jdtls startup. Oldest entries are dropped on overflow.
519
+ """
520
+ if len(self._queued_notifications) >= _MAX_QUEUED_NOTIFICATIONS:
521
+ self._queued_notifications.pop(0)
522
+ self._queued_notifications.append((method, params))
523
+
524
+ async def flush_queued_notifications(self) -> None:
525
+ """Send all queued notifications to jdtls."""
526
+ queue, self._queued_notifications = self._queued_notifications, []
527
+ for method, params in queue:
528
+ await self.send_notification(method, params)
529
+
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."""
532
+ if not self._available:
533
+ return
534
+ 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)
538
+ from pygls.uris import to_fs_path
539
+
540
+ logger.info("jdtls: adding module %s", _redact_path(to_fs_path(module_uri)))
541
+ mod_name = Path(to_fs_path(module_uri) or module_uri).name
542
+ await self.send_notification(
543
+ _WORKSPACE_DID_CHANGE_FOLDERS,
544
+ {"event": {"added": [{"uri": module_uri, "name": mod_name}], "removed": []}},
545
+ )
546
+
547
+ async def expand_full_workspace(self) -> None:
548
+ """Expand jdtls workspace to the full monorepo root (background task).
549
+
550
+ Removes the initial module-scoped folder and adds the full root to
551
+ avoid double-indexing.
552
+ """
553
+ if self._workspace_expanded or not self._available or not self._original_root_uri:
554
+ return
555
+ from pygls.uris import from_fs_path, to_fs_path
556
+
557
+ root_path = to_fs_path(self._original_root_uri) or self._original_root_uri
558
+ root_uri = from_fs_path(root_path) or self._original_root_uri
559
+ if root_uri in self._added_module_uris:
560
+ self._workspace_expanded = True
561
+ return
562
+ self._added_module_uris.add(root_uri)
563
+
564
+ # Remove initial module folder to avoid double-indexing.
565
+ removed: list[dict[str, str]] = []
566
+ if self._initial_module_uri and self._initial_module_uri != root_uri:
567
+ ini_path = to_fs_path(self._initial_module_uri) or self._initial_module_uri
568
+ removed.append({"uri": self._initial_module_uri, "name": Path(ini_path).name})
569
+
570
+ logger.info("jdtls: expanding to full workspace %s", _redact_path(root_path))
571
+ await self.send_notification(
572
+ _WORKSPACE_DID_CHANGE_FOLDERS,
573
+ {"event": {"added": [{"uri": root_uri, "name": Path(root_path).name}], "removed": removed}},
574
+ )
575
+ self._workspace_expanded = True
576
+
413
577
  async def stop(self) -> None:
414
578
  """Shutdown jdtls subprocess gracefully."""
415
579
  self._available = False
@@ -77,6 +77,8 @@ server = JavaFunctionalLspServer()
77
77
 
78
78
  # Debounce state for didChange events (only affects human typing in IDEs, not agents)
79
79
  _pending: dict[str, asyncio.Task[None]] = {}
80
+ # Background tasks (prevent GC of fire-and-forget tasks)
81
+ _bg_tasks: set[asyncio.Task[None]] = set()
80
82
  _DEBOUNCE_SECONDS = 0.15
81
83
 
82
84
 
@@ -242,17 +244,15 @@ def on_initialize(params: lsp.InitializeParams) -> lsp.InitializeResult:
242
244
 
243
245
  @server.feature(lsp.INITIALIZED)
244
246
  async def on_initialized(params: lsp.InitializedParams) -> None:
245
- """Start jdtls proxy after initialization."""
247
+ """Check jdtls availability; actual start deferred to first didOpen."""
246
248
  logger.info(
247
249
  "java-functional-lsp initialized (rules: %s)",
248
250
  list(server._config.get("rules", {}).keys()) or "all defaults",
249
251
  )
250
- started = await server._proxy.start(server._init_params)
251
- if started:
252
- logger.info("jdtls proxy active — full Java language support enabled")
253
- await _register_jdtls_capabilities()
252
+ if server._proxy.check_available():
253
+ logger.info("jdtls found on PATH — will start lazily on first file open")
254
254
  else:
255
- logger.info("jdtls proxy unavailable — running with custom rules only")
255
+ logger.info("jdtls not on PATH — running with custom rules only")
256
256
 
257
257
 
258
258
  _JAVA_SELECTOR = [lsp.TextDocumentFilterLanguage(language="java")]
@@ -331,20 +331,50 @@ async def _deferred_validate(uri: str) -> None:
331
331
  _analyze_and_publish(uri)
332
332
 
333
333
 
334
+ def _forward_or_queue(method: str, serialized: Any) -> None:
335
+ """Forward a notification to jdtls if available, or queue it if starting."""
336
+ if server._proxy.is_available:
337
+ task = asyncio.create_task(server._proxy.send_notification(method, serialized))
338
+ _bg_tasks.add(task)
339
+ task.add_done_callback(_bg_tasks.discard)
340
+ elif server._proxy._starting:
341
+ server._proxy.queue_notification(method, serialized)
342
+
343
+
334
344
  @server.feature(lsp.TEXT_DOCUMENT_DID_OPEN)
335
345
  async def on_did_open(params: lsp.DidOpenTextDocumentParams) -> None:
336
- """Forward to jdtls and analyze immediately."""
346
+ """Forward to jdtls (starting lazily if needed) and analyze immediately.
347
+
348
+ Custom diagnostics always publish immediately regardless of jdtls state.
349
+ jdtls startup is non-blocking — it runs in the background so the first
350
+ didOpen response isn't delayed by jdtls cold-start.
351
+ """
352
+ uri = params.text_document.uri
353
+ serialized = _serialize_params(params)
354
+
337
355
  if server._proxy.is_available:
338
- await server._proxy.send_notification("textDocument/didOpen", _serialize_params(params))
339
- _analyze_and_publish(params.text_document.uri)
356
+ # Fast path: jdtls running. Forward didOpen + add module if new.
357
+ await server._proxy.send_notification("textDocument/didOpen", serialized)
358
+ await server._proxy.add_module_if_new(uri)
359
+ elif server._proxy._jdtls_on_path and not server._proxy._start_failed:
360
+ # Queue the didOpen (whether this is the first file or a subsequent one during startup).
361
+ server._proxy.queue_notification("textDocument/didOpen", serialized)
362
+ if not server._proxy._lazy_start_fired:
363
+ # First file: kick off lazy start in background.
364
+ server._proxy._lazy_start_fired = True
365
+ task = asyncio.create_task(_lazy_start_jdtls(uri))
366
+ _bg_tasks.add(task)
367
+ task.add_done_callback(_bg_tasks.discard)
368
+
369
+ # Custom diagnostics always publish immediately — never blocked by jdtls.
370
+ _analyze_and_publish(uri)
340
371
 
341
372
 
342
373
  @server.feature(lsp.TEXT_DOCUMENT_DID_CHANGE)
343
374
  async def on_did_change(params: lsp.DidChangeTextDocumentParams) -> None:
344
375
  """Forward to jdtls and schedule debounced re-analysis."""
345
376
  uri = params.text_document.uri
346
- if server._proxy.is_available:
347
- await server._proxy.send_notification("textDocument/didChange", _serialize_params(params))
377
+ _forward_or_queue("textDocument/didChange", _serialize_params(params))
348
378
  # Cancel pending validation, schedule new one (150ms debounce for IDE typing)
349
379
  if uri in _pending:
350
380
  _pending[uri].cancel()
@@ -354,8 +384,7 @@ async def on_did_change(params: lsp.DidChangeTextDocumentParams) -> None:
354
384
  @server.feature(lsp.TEXT_DOCUMENT_DID_SAVE)
355
385
  async def on_did_save(params: lsp.DidSaveTextDocumentParams) -> None:
356
386
  """Forward to jdtls and re-analyze immediately (no debounce on save)."""
357
- if server._proxy.is_available:
358
- await server._proxy.send_notification("textDocument/didSave", _serialize_params(params))
387
+ _forward_or_queue("textDocument/didSave", _serialize_params(params))
359
388
  _analyze_and_publish(params.text_document.uri)
360
389
 
361
390
 
@@ -368,8 +397,39 @@ async def on_did_close(params: lsp.DidCloseTextDocumentParams) -> None:
368
397
  del _pending[uri]
369
398
  # Clear diagnostics for the closed document (LSP best practice)
370
399
  server.text_document_publish_diagnostics(lsp.PublishDiagnosticsParams(uri=uri, diagnostics=[]))
371
- if server._proxy.is_available:
372
- await server._proxy.send_notification("textDocument/didClose", _serialize_params(params))
400
+ _forward_or_queue("textDocument/didClose", _serialize_params(params))
401
+
402
+
403
+ async def _lazy_start_jdtls(file_uri: str) -> None:
404
+ """Background task: start jdtls scoped to the module containing *file_uri*.
405
+
406
+ Runs in the background so ``on_did_open`` returns immediately with custom
407
+ diagnostics. After jdtls initializes, registers capabilities, flushes
408
+ queued notifications, and schedules workspace expansion.
409
+ """
410
+ try:
411
+ started = await server._proxy.ensure_started(server._init_params, file_uri)
412
+ if started:
413
+ logger.info("jdtls proxy active — full Java language support enabled")
414
+ await _register_jdtls_capabilities()
415
+ await server._proxy.flush_queued_notifications()
416
+ await _expand_workspace_background()
417
+ except Exception:
418
+ logger.warning("jdtls lazy start failed", exc_info=True)
419
+
420
+
421
+ async def _expand_workspace_background() -> None:
422
+ """Background task: expand jdtls workspace to full monorepo root.
423
+
424
+ Runs after jdtls finishes initializing with the first module scope.
425
+ The user's actively-opened modules are loaded immediately via
426
+ ``add_module_if_new()`` in ``on_did_open``; this adds the full root
427
+ so cross-module references for unopened files also work.
428
+ """
429
+ try:
430
+ await server._proxy.expand_full_workspace()
431
+ except Exception:
432
+ logger.warning("Failed to expand jdtls workspace", exc_info=True)
373
433
 
374
434
 
375
435
  # --- jdtls passthrough handlers (registered dynamically, NOT at module level) ---
@@ -857,3 +857,265 @@ class TestStartPassesEnvToSubprocess:
857
857
  assert ok is False
858
858
  # The crucial assertion: env= was passed through to the subprocess call.
859
859
  assert captured["env"] == sentinel_env
860
+
861
+
862
+ class TestFindModuleRoot:
863
+ """Tests for find_module_root — build-file detection for module scoping."""
864
+
865
+ def test_finds_pom_xml(self, tmp_path: Any) -> None:
866
+ from java_functional_lsp.proxy import find_module_root
867
+
868
+ (tmp_path / "pom.xml").touch()
869
+ java_file = tmp_path / "src" / "Main.java"
870
+ java_file.parent.mkdir()
871
+ java_file.touch()
872
+ assert find_module_root(str(java_file)) == str(tmp_path)
873
+
874
+ def test_finds_build_gradle(self, tmp_path: Any) -> None:
875
+ from java_functional_lsp.proxy import find_module_root
876
+
877
+ (tmp_path / "build.gradle").touch()
878
+ java_file = tmp_path / "src" / "Main.java"
879
+ java_file.parent.mkdir()
880
+ java_file.touch()
881
+ assert find_module_root(str(java_file)) == str(tmp_path)
882
+
883
+ def test_finds_build_gradle_kts(self, tmp_path: Any) -> None:
884
+ from java_functional_lsp.proxy import find_module_root
885
+
886
+ (tmp_path / "build.gradle.kts").touch()
887
+ java_file = tmp_path / "src" / "Main.java"
888
+ java_file.parent.mkdir()
889
+ java_file.touch()
890
+ assert find_module_root(str(java_file)) == str(tmp_path)
891
+
892
+ def test_finds_nearest_not_parent(self, tmp_path: Any) -> None:
893
+ """Nested modules: should find the innermost module root."""
894
+ from java_functional_lsp.proxy import find_module_root
895
+
896
+ (tmp_path / "pom.xml").touch() # parent module
897
+ child = tmp_path / "child-module"
898
+ child.mkdir()
899
+ (child / "pom.xml").touch() # child module
900
+ java_file = child / "src" / "Main.java"
901
+ java_file.parent.mkdir()
902
+ java_file.touch()
903
+ assert find_module_root(str(java_file)) == str(child)
904
+
905
+ def test_returns_none_when_no_build_file(self, tmp_path: Any) -> None:
906
+ from java_functional_lsp.proxy import find_module_root
907
+
908
+ java_file = tmp_path / "src" / "Main.java"
909
+ java_file.parent.mkdir()
910
+ java_file.touch()
911
+ assert find_module_root(str(java_file)) is None
912
+
913
+
914
+ class TestLazyStart:
915
+ """Tests for lazy-start proxy features."""
916
+
917
+ def test_check_available_true(self) -> None:
918
+ from java_functional_lsp.proxy import JdtlsProxy
919
+
920
+ proxy = JdtlsProxy()
921
+ with patch("java_functional_lsp.proxy.shutil.which", return_value="/usr/bin/jdtls"):
922
+ assert proxy.check_available() is True
923
+ assert proxy._jdtls_on_path is True
924
+
925
+ def test_check_available_false(self) -> None:
926
+ from java_functional_lsp.proxy import JdtlsProxy
927
+
928
+ proxy = JdtlsProxy()
929
+ with patch("java_functional_lsp.proxy.shutil.which", return_value=None):
930
+ assert proxy.check_available() is False
931
+ assert proxy._jdtls_on_path is False
932
+
933
+ async def test_queue_and_flush(self) -> None:
934
+ from java_functional_lsp.proxy import JdtlsProxy
935
+
936
+ proxy = JdtlsProxy()
937
+ proxy.queue_notification("textDocument/didOpen", {"uri": "a"})
938
+ proxy.queue_notification("textDocument/didChange", {"uri": "b"})
939
+ assert len(proxy._queued_notifications) == 2
940
+
941
+ flushed: list[tuple[str, Any]] = []
942
+
943
+ async def mock_send(method: str, params: Any) -> None:
944
+ flushed.append((method, params))
945
+
946
+ proxy.send_notification = mock_send # type: ignore[assignment]
947
+ await proxy.flush_queued_notifications()
948
+ assert len(flushed) == 2
949
+ assert flushed[0] == ("textDocument/didOpen", {"uri": "a"})
950
+ assert flushed[1] == ("textDocument/didChange", {"uri": "b"})
951
+ assert len(proxy._queued_notifications) == 0
952
+
953
+ def test_queue_caps_at_max(self) -> None:
954
+ from java_functional_lsp.proxy import _MAX_QUEUED_NOTIFICATIONS, JdtlsProxy
955
+
956
+ proxy = JdtlsProxy()
957
+ for i in range(_MAX_QUEUED_NOTIFICATIONS + 50):
958
+ proxy.queue_notification("textDocument/didChange", {"i": i})
959
+ assert len(proxy._queued_notifications) == _MAX_QUEUED_NOTIFICATIONS
960
+ # Oldest entries dropped — last entry should be the most recent
961
+ assert proxy._queued_notifications[-1] == ("textDocument/didChange", {"i": _MAX_QUEUED_NOTIFICATIONS + 49})
962
+
963
+ async def test_ensure_started_no_retry_after_failure(self) -> None:
964
+ from unittest.mock import AsyncMock
965
+
966
+ from java_functional_lsp.proxy import JdtlsProxy
967
+
968
+ proxy = JdtlsProxy()
969
+ proxy._jdtls_on_path = True
970
+ proxy.queue_notification("textDocument/didOpen", {"uri": "test"})
971
+ proxy.start = AsyncMock(return_value=False) # type: ignore[assignment]
972
+ result = await proxy.ensure_started({"rootUri": "file:///tmp"}, "file:///tmp/F.java")
973
+ assert result is False
974
+ assert proxy._start_failed is True
975
+ # Queue should be cleared on failure
976
+ assert len(proxy._queued_notifications) == 0
977
+ # Second call should return immediately without calling start()
978
+ proxy.start.reset_mock() # type: ignore[attr-defined]
979
+ result2 = await proxy.ensure_started({"rootUri": "file:///tmp"}, "file:///tmp/F.java")
980
+ assert result2 is False
981
+ proxy.start.assert_not_called() # type: ignore[attr-defined]
982
+
983
+ async def test_add_module_if_new_sends_notification(self) -> None:
984
+ from unittest.mock import AsyncMock
985
+
986
+ from java_functional_lsp.proxy import JdtlsProxy
987
+
988
+ proxy = JdtlsProxy()
989
+ proxy._available = True
990
+ proxy.send_notification = AsyncMock() # type: ignore[assignment]
991
+ # Create a tmp dir with pom.xml
992
+ import tempfile
993
+ from pathlib import Path
994
+
995
+ with tempfile.TemporaryDirectory() as td:
996
+ (Path(td) / "pom.xml").touch()
997
+ java_file = Path(td) / "src" / "Main.java"
998
+ java_file.parent.mkdir()
999
+ java_file.touch()
1000
+ uri = java_file.as_uri()
1001
+ await proxy.add_module_if_new(uri)
1002
+ proxy.send_notification.assert_called_once() # type: ignore[attr-defined]
1003
+ call_args = proxy.send_notification.call_args # type: ignore[attr-defined]
1004
+ assert call_args[0][0] == "workspace/didChangeWorkspaceFolders"
1005
+
1006
+ async def test_add_module_if_new_skips_duplicate(self) -> None:
1007
+ from unittest.mock import AsyncMock
1008
+
1009
+ from java_functional_lsp.proxy import JdtlsProxy
1010
+
1011
+ proxy = JdtlsProxy()
1012
+ proxy._available = True
1013
+ proxy.send_notification = AsyncMock() # type: ignore[assignment]
1014
+ import tempfile
1015
+ from pathlib import Path
1016
+
1017
+ with tempfile.TemporaryDirectory() as td:
1018
+ (Path(td) / "pom.xml").touch()
1019
+ java_file = Path(td) / "src" / "Main.java"
1020
+ java_file.parent.mkdir()
1021
+ java_file.touch()
1022
+ uri = java_file.as_uri()
1023
+ await proxy.add_module_if_new(uri)
1024
+ await proxy.add_module_if_new(uri) # duplicate
1025
+ assert proxy.send_notification.call_count == 1 # type: ignore[attr-defined]
1026
+
1027
+ async def test_expand_full_workspace_sends_notification(self) -> None:
1028
+ from unittest.mock import AsyncMock
1029
+
1030
+ from java_functional_lsp.proxy import JdtlsProxy
1031
+
1032
+ proxy = JdtlsProxy()
1033
+ proxy._available = True
1034
+ proxy._original_root_uri = "file:///workspace/monorepo"
1035
+ proxy.send_notification = AsyncMock() # type: ignore[assignment]
1036
+ await proxy.expand_full_workspace()
1037
+ proxy.send_notification.assert_called_once() # type: ignore[attr-defined]
1038
+ assert proxy._workspace_expanded is True
1039
+
1040
+ async def test_expand_full_workspace_noop_when_not_available(self) -> None:
1041
+ from unittest.mock import AsyncMock
1042
+
1043
+ from java_functional_lsp.proxy import JdtlsProxy
1044
+
1045
+ proxy = JdtlsProxy()
1046
+ proxy._original_root_uri = "file:///workspace/monorepo"
1047
+ proxy.send_notification = AsyncMock() # type: ignore[assignment]
1048
+ await proxy.expand_full_workspace()
1049
+ proxy.send_notification.assert_not_called() # type: ignore[attr-defined]
1050
+ assert proxy._workspace_expanded is False
1051
+
1052
+ async def test_expand_full_workspace_noop_when_already_added(self) -> None:
1053
+ from unittest.mock import AsyncMock
1054
+
1055
+ from java_functional_lsp.proxy import JdtlsProxy
1056
+
1057
+ proxy = JdtlsProxy()
1058
+ proxy._available = True
1059
+ proxy._original_root_uri = "file:///workspace/monorepo"
1060
+ proxy._added_module_uris.add("file:///workspace/monorepo")
1061
+ proxy.send_notification = AsyncMock() # type: ignore[assignment]
1062
+ await proxy.expand_full_workspace()
1063
+ proxy.send_notification.assert_not_called() # type: ignore[attr-defined]
1064
+ assert proxy._workspace_expanded is True
1065
+
1066
+ async def test_ensure_started_no_build_file(self) -> None:
1067
+ """ensure_started with no build file should pass module_root_uri=None."""
1068
+ from java_functional_lsp.proxy import JdtlsProxy
1069
+
1070
+ proxy = JdtlsProxy()
1071
+ proxy._jdtls_on_path = True
1072
+ captured: dict[str, Any] = {}
1073
+
1074
+ async def capturing_start(params: Any, *, module_root_uri: str | None = None) -> bool:
1075
+ captured["module_root_uri"] = module_root_uri
1076
+ return False
1077
+
1078
+ proxy.start = capturing_start # type: ignore[assignment]
1079
+ await proxy.ensure_started(
1080
+ {"rootUri": "file:///monorepo", "capabilities": {}},
1081
+ "file:///nonexistent/src/Main.java",
1082
+ )
1083
+ assert captured["module_root_uri"] is None
1084
+
1085
+ async def test_ensure_started_with_build_file(self, tmp_path: Any) -> None:
1086
+ """ensure_started should find module root and pass it to start()."""
1087
+ from java_functional_lsp.proxy import JdtlsProxy
1088
+
1089
+ proxy = JdtlsProxy()
1090
+ proxy._jdtls_on_path = True
1091
+ (tmp_path / "pom.xml").touch()
1092
+ java_file = tmp_path / "src" / "Main.java"
1093
+ java_file.parent.mkdir()
1094
+ java_file.touch()
1095
+
1096
+ captured: dict[str, Any] = {}
1097
+
1098
+ async def capturing_start(params: Any, *, module_root_uri: str | None = None) -> bool:
1099
+ captured["module_root_uri"] = module_root_uri
1100
+ return False
1101
+
1102
+ proxy.start = capturing_start # type: ignore[assignment]
1103
+ await proxy.ensure_started(
1104
+ {"rootUri": "file:///monorepo", "capabilities": {}},
1105
+ java_file.as_uri(),
1106
+ )
1107
+ assert captured["module_root_uri"] is not None
1108
+ assert str(tmp_path) in captured["module_root_uri"]
1109
+
1110
+ def test_data_dir_hash_uses_original_root(self) -> None:
1111
+ """Data-dir hash should be based on original rootUri, not module root."""
1112
+ import hashlib
1113
+
1114
+ # The hash is computed from the original rootUri, not the module root.
1115
+ # Verify these produce different hashes, confirming start() must use
1116
+ # the original root for stability.
1117
+ root = "file:///workspace/monorepo"
1118
+ expected_hash = hashlib.sha256(root.encode()).hexdigest()[:12]
1119
+ module_root = "file:///workspace/monorepo/module-a"
1120
+ module_hash = hashlib.sha256(module_root.encode()).hexdigest()[:12]
1121
+ assert expected_hash != module_hash
@@ -414,6 +414,68 @@ class TestServerInternals:
414
414
  srv_mod._jdtls_capabilities_registered = old_flag
415
415
  mock_reg.assert_not_called()
416
416
 
417
+ async def test_lazy_start_jdtls_success(self, caplog: Any) -> None:
418
+ """_lazy_start_jdtls logs success, flushes queue, and expands workspace."""
419
+ import logging
420
+ from unittest.mock import AsyncMock, MagicMock, patch
421
+
422
+ import java_functional_lsp.server as srv_mod
423
+ from java_functional_lsp.server import _lazy_start_jdtls
424
+ from java_functional_lsp.server import server as srv
425
+
426
+ mock_flush = AsyncMock()
427
+ mock_expand = AsyncMock()
428
+ old_flag = srv_mod._jdtls_capabilities_registered
429
+ srv_mod._jdtls_capabilities_registered = False
430
+ try:
431
+ with (
432
+ caplog.at_level(logging.INFO, logger="java_functional_lsp.server"),
433
+ patch.object(srv._proxy, "ensure_started", AsyncMock(return_value=True)),
434
+ patch.object(srv._proxy, "flush_queued_notifications", mock_flush),
435
+ patch.object(srv._proxy, "expand_full_workspace", mock_expand),
436
+ patch.object(srv, "feature", MagicMock(return_value=lambda fn: fn)),
437
+ patch.object(srv, "client_register_capability_async", AsyncMock()),
438
+ ):
439
+ await _lazy_start_jdtls("file:///test/F.java")
440
+ finally:
441
+ srv_mod._jdtls_capabilities_registered = old_flag
442
+ assert any("jdtls proxy active" in r.getMessage() for r in caplog.records)
443
+ mock_flush.assert_called_once()
444
+ mock_expand.assert_called_once()
445
+
446
+ async def test_lazy_start_jdtls_failure_logged(self, caplog: Any) -> None:
447
+ """_lazy_start_jdtls logs warning on exception."""
448
+ import logging
449
+ from unittest.mock import AsyncMock, patch
450
+
451
+ from java_functional_lsp.server import _lazy_start_jdtls
452
+ from java_functional_lsp.server import server as srv
453
+
454
+ with (
455
+ caplog.at_level(logging.WARNING, logger="java_functional_lsp.server"),
456
+ patch.object(srv._proxy, "ensure_started", AsyncMock(side_effect=Exception("boom"))),
457
+ ):
458
+ await _lazy_start_jdtls("file:///test/F.java")
459
+ assert any("lazy start failed" in r.getMessage() for r in caplog.records)
460
+
461
+ async def test_lazy_start_jdtls_silent_failure(self) -> None:
462
+ """When ensure_started returns False, flush/expand are not called."""
463
+ from unittest.mock import AsyncMock, patch
464
+
465
+ from java_functional_lsp.server import _lazy_start_jdtls
466
+ from java_functional_lsp.server import server as srv
467
+
468
+ mock_flush = AsyncMock()
469
+ mock_expand = AsyncMock()
470
+ with (
471
+ patch.object(srv._proxy, "ensure_started", AsyncMock(return_value=False)),
472
+ patch.object(srv._proxy, "flush_queued_notifications", mock_flush),
473
+ patch.object(srv._proxy, "expand_full_workspace", mock_expand),
474
+ ):
475
+ await _lazy_start_jdtls("file:///test/F.java")
476
+ mock_flush.assert_not_called()
477
+ mock_expand.assert_not_called()
478
+
417
479
  def test_serialize_params_camelcase(self) -> None:
418
480
  from java_functional_lsp.server import _serialize_params
419
481
 
@@ -184,7 +184,7 @@ wheels = [
184
184
 
185
185
  [[package]]
186
186
  name = "java-functional-lsp"
187
- version = "0.7.3"
187
+ version = "0.7.4"
188
188
  source = { editable = "." }
189
189
  dependencies = [
190
190
  { name = "pygls" },