java-functional-lsp 0.7.2__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.2 → java_functional_lsp-0.7.4}/PKG-INFO +2 -2
  2. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/README.md +1 -1
  3. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/pyproject.toml +1 -1
  4. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/src/java_functional_lsp/__init__.py +1 -1
  5. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/src/java_functional_lsp/proxy.py +186 -22
  6. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/src/java_functional_lsp/server.py +163 -30
  7. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/tests/test_proxy.py +262 -0
  8. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/tests/test_server.py +198 -1
  9. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/uv.lock +1 -1
  10. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/.claude-plugin/plugin.json +0 -0
  11. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/.githooks/pre-commit +0 -0
  12. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/.githooks/pre-push +0 -0
  13. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/.github/CODEOWNERS +0 -0
  14. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/.github/ISSUE_TEMPLATE/bug-report.md +0 -0
  15. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/.github/ISSUE_TEMPLATE/feature-request.md +0 -0
  16. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  17. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/.github/SECURITY.md +0 -0
  18. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/.github/dependabot.yml +0 -0
  19. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/.github/release-drafter.yml +0 -0
  20. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/.github/workflows/publish.yml +0 -0
  21. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/.github/workflows/release-drafter.yml +0 -0
  22. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/.github/workflows/stale.yml +0 -0
  23. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/.github/workflows/test.yml +0 -0
  24. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/.github/workflows/update-homebrew.yml +0 -0
  25. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/.github/workflows/vscode-ext.yml +0 -0
  26. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/.gitignore +0 -0
  27. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/CONTRIBUTING.md +0 -0
  28. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/LICENSE +0 -0
  29. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/SKILL.md +0 -0
  30. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/commands/lint-java.md +0 -0
  31. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/editors/intellij/README.md +0 -0
  32. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/editors/intellij/lsp4ij-template.json +0 -0
  33. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/editors/vscode/.vscodeignore +0 -0
  34. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/editors/vscode/README.md +0 -0
  35. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/editors/vscode/package-lock.json +0 -0
  36. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/editors/vscode/package.json +0 -0
  37. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/editors/vscode/src/extension.ts +0 -0
  38. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/editors/vscode/tsconfig.json +0 -0
  39. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/hooks/hooks.json +0 -0
  40. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/hooks/java_linter_reminder.py +0 -0
  41. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/scripts/ensure-lsp.sh +0 -0
  42. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/scripts/generate-formula.py +0 -0
  43. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/src/java_functional_lsp/__main__.py +0 -0
  44. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/src/java_functional_lsp/analyzers/__init__.py +0 -0
  45. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/src/java_functional_lsp/analyzers/base.py +0 -0
  46. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/src/java_functional_lsp/analyzers/exception_checker.py +0 -0
  47. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/src/java_functional_lsp/analyzers/functional_checker.py +0 -0
  48. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/src/java_functional_lsp/analyzers/mutation_checker.py +0 -0
  49. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/src/java_functional_lsp/analyzers/null_checker.py +0 -0
  50. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/src/java_functional_lsp/analyzers/spring_checker.py +0 -0
  51. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/src/java_functional_lsp/cli.py +0 -0
  52. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/src/java_functional_lsp/fixes.py +0 -0
  53. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/tests/__init__.py +0 -0
  54. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/tests/conftest.py +0 -0
  55. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/tests/test_base.py +0 -0
  56. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/tests/test_cli.py +0 -0
  57. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/tests/test_config.py +0 -0
  58. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/tests/test_e2e.py +0 -0
  59. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/tests/test_e2e_jdtls.py +0 -0
  60. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/tests/test_exception_checker.py +0 -0
  61. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/tests/test_fixes.py +0 -0
  62. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/tests/test_functional_checker.py +0 -0
  63. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/tests/test_mutation_checker.py +0 -0
  64. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/tests/test_null_checker.py +0 -0
  65. {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/tests/test_spring_checker.py +0 -0
  66. {java_functional_lsp-0.7.2 → 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.2
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.2"
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.2"
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
 
@@ -228,11 +230,11 @@ def on_initialize(params: lsp.InitializeParams) -> lsp.InitializeResult:
228
230
  change=lsp.TextDocumentSyncKind.Full,
229
231
  save=lsp.SaveOptions(include_text=True),
230
232
  ),
231
- completion_provider=lsp.CompletionOptions(trigger_characters=["."]),
232
- hover_provider=True,
233
- definition_provider=True,
234
- references_provider=True,
235
- document_symbol_provider=True,
233
+ # Only advertise capabilities we own (custom diagnostics + code actions).
234
+ # jdtls-dependent features (hover, definition, references, completion,
235
+ # documentSymbol) are registered dynamically after jdtls starts — see
236
+ # on_initialized(). This prevents us from claiming hover when jdtls
237
+ # isn't ready, which would suppress the IDE's diagnostic tooltips.
236
238
  code_action_provider=lsp.CodeActionOptions(
237
239
  code_action_kinds=[lsp.CodeActionKind.QuickFix],
238
240
  ),
@@ -242,16 +244,75 @@ 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")
252
+ if server._proxy.check_available():
253
+ logger.info("jdtls found on PATH — will start lazily on first file open")
253
254
  else:
254
- logger.info("jdtls proxy unavailable — running with custom rules only")
255
+ logger.info("jdtls not on PATH — running with custom rules only")
256
+
257
+
258
+ _JAVA_SELECTOR = [lsp.TextDocumentFilterLanguage(language="java")]
259
+
260
+ _JDTLS_REG_PREFIX = "jdtls-"
261
+
262
+ # jdtls-dependent capabilities registered dynamically after the proxy starts.
263
+ # Each entry: (id_suffix, LSP method, registration options class, extra kwargs).
264
+ _JDTLS_CAPABILITIES: list[tuple[str, str, type[Any], dict[str, Any]]] = [
265
+ ("completion", lsp.TEXT_DOCUMENT_COMPLETION, lsp.CompletionRegistrationOptions, {"trigger_characters": ["."]}),
266
+ ("hover", lsp.TEXT_DOCUMENT_HOVER, lsp.HoverRegistrationOptions, {}),
267
+ ("definition", lsp.TEXT_DOCUMENT_DEFINITION, lsp.DefinitionRegistrationOptions, {}),
268
+ ("references", lsp.TEXT_DOCUMENT_REFERENCES, lsp.ReferenceRegistrationOptions, {}),
269
+ ("document-symbol", lsp.TEXT_DOCUMENT_DOCUMENT_SYMBOL, lsp.DocumentSymbolRegistrationOptions, {}),
270
+ ]
271
+
272
+ # Maps LSP method → handler function for dynamic registration.
273
+ _JDTLS_HANDLERS: dict[str, Any] = {}
274
+
275
+ # Set after first successful registration to prevent FeatureAlreadyRegisteredError.
276
+ _jdtls_capabilities_registered = False
277
+
278
+
279
+ def _build_jdtls_registrations() -> list[lsp.Registration]:
280
+ """Build LSP Registration objects for jdtls-dependent capabilities."""
281
+ return [
282
+ lsp.Registration(
283
+ id=f"{_JDTLS_REG_PREFIX}{suffix}",
284
+ method=method,
285
+ register_options=_converter.unstructure(opts_cls(document_selector=_JAVA_SELECTOR, **extra)),
286
+ )
287
+ for suffix, method, opts_cls, extra in _JDTLS_CAPABILITIES
288
+ ]
289
+
290
+
291
+ async def _register_jdtls_capabilities() -> None:
292
+ """Dynamically register jdtls-dependent capabilities after the proxy starts.
293
+
294
+ We don't advertise these in the static InitializeResult because doing so
295
+ would make the IDE defer hover/definition/etc to us even before jdtls is
296
+ ready, which suppresses the IDE's built-in diagnostic tooltips.
297
+
298
+ Idempotent: safe to call multiple times (e.g., proxy restart).
299
+ """
300
+ global _jdtls_capabilities_registered
301
+ if _jdtls_capabilities_registered:
302
+ return
303
+
304
+ try:
305
+ # Register handlers so pygls dispatches incoming requests to them.
306
+ for method, handler in _JDTLS_HANDLERS.items():
307
+ server.feature(method)(handler)
308
+
309
+ # Tell the client we now support these capabilities.
310
+ registrations = _build_jdtls_registrations()
311
+ await server.client_register_capability_async(lsp.RegistrationParams(registrations=registrations))
312
+ _jdtls_capabilities_registered = True
313
+ logger.info("Dynamically registered jdtls capabilities (hover, definition, references, completion, symbol)")
314
+ except Exception:
315
+ logger.warning("Failed to dynamically register jdtls capabilities", exc_info=True)
255
316
 
256
317
 
257
318
  # --- Document sync (forward to jdtls + run custom analyzers) ---
@@ -270,20 +331,50 @@ async def _deferred_validate(uri: str) -> None:
270
331
  _analyze_and_publish(uri)
271
332
 
272
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
+
273
344
  @server.feature(lsp.TEXT_DOCUMENT_DID_OPEN)
274
345
  async def on_did_open(params: lsp.DidOpenTextDocumentParams) -> None:
275
- """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
+
276
355
  if server._proxy.is_available:
277
- await server._proxy.send_notification("textDocument/didOpen", _serialize_params(params))
278
- _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)
279
371
 
280
372
 
281
373
  @server.feature(lsp.TEXT_DOCUMENT_DID_CHANGE)
282
374
  async def on_did_change(params: lsp.DidChangeTextDocumentParams) -> None:
283
375
  """Forward to jdtls and schedule debounced re-analysis."""
284
376
  uri = params.text_document.uri
285
- if server._proxy.is_available:
286
- await server._proxy.send_notification("textDocument/didChange", _serialize_params(params))
377
+ _forward_or_queue("textDocument/didChange", _serialize_params(params))
287
378
  # Cancel pending validation, schedule new one (150ms debounce for IDE typing)
288
379
  if uri in _pending:
289
380
  _pending[uri].cancel()
@@ -293,8 +384,7 @@ async def on_did_change(params: lsp.DidChangeTextDocumentParams) -> None:
293
384
  @server.feature(lsp.TEXT_DOCUMENT_DID_SAVE)
294
385
  async def on_did_save(params: lsp.DidSaveTextDocumentParams) -> None:
295
386
  """Forward to jdtls and re-analyze immediately (no debounce on save)."""
296
- if server._proxy.is_available:
297
- await server._proxy.send_notification("textDocument/didSave", _serialize_params(params))
387
+ _forward_or_queue("textDocument/didSave", _serialize_params(params))
298
388
  _analyze_and_publish(params.text_document.uri)
299
389
 
300
390
 
@@ -307,15 +397,50 @@ async def on_did_close(params: lsp.DidCloseTextDocumentParams) -> None:
307
397
  del _pending[uri]
308
398
  # Clear diagnostics for the closed document (LSP best practice)
309
399
  server.text_document_publish_diagnostics(lsp.PublishDiagnosticsParams(uri=uri, diagnostics=[]))
310
- if server._proxy.is_available:
311
- 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)
312
433
 
313
434
 
314
- # --- Forwarded features (jdtls passthrough) ---
435
+ # --- jdtls passthrough handlers (registered dynamically, NOT at module level) ---
436
+ #
437
+ # These are NOT decorated with @server.feature because pygls auto-advertises
438
+ # capabilities for decorated handlers. Instead, they are collected in
439
+ # _JDTLS_HANDLERS and registered inside _register_jdtls_capabilities() so
440
+ # they only activate after jdtls starts.
315
441
 
316
442
 
317
- @server.feature(lsp.TEXT_DOCUMENT_COMPLETION)
318
- async def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList | None:
443
+ async def _on_completion(params: lsp.CompletionParams) -> lsp.CompletionList | None:
319
444
  """Forward completion request to jdtls."""
320
445
  if not server._proxy.is_available:
321
446
  return None
@@ -328,8 +453,7 @@ async def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList | No
328
453
  return None
329
454
 
330
455
 
331
- @server.feature(lsp.TEXT_DOCUMENT_HOVER)
332
- async def on_hover(params: lsp.HoverParams) -> lsp.Hover | None:
456
+ async def _on_hover(params: lsp.HoverParams) -> lsp.Hover | None:
333
457
  """Forward hover request to jdtls."""
334
458
  if not server._proxy.is_available:
335
459
  return None
@@ -342,8 +466,7 @@ async def on_hover(params: lsp.HoverParams) -> lsp.Hover | None:
342
466
  return None
343
467
 
344
468
 
345
- @server.feature(lsp.TEXT_DOCUMENT_DEFINITION)
346
- async def on_definition(params: lsp.DefinitionParams) -> list[lsp.Location] | None:
469
+ async def _on_definition(params: lsp.DefinitionParams) -> list[lsp.Location] | None:
347
470
  """Forward go-to-definition request to jdtls."""
348
471
  if not server._proxy.is_available:
349
472
  return None
@@ -358,8 +481,7 @@ async def on_definition(params: lsp.DefinitionParams) -> list[lsp.Location] | No
358
481
  return None
359
482
 
360
483
 
361
- @server.feature(lsp.TEXT_DOCUMENT_REFERENCES)
362
- async def on_references(params: lsp.ReferenceParams) -> list[lsp.Location] | None:
484
+ async def _on_references(params: lsp.ReferenceParams) -> list[lsp.Location] | None:
363
485
  """Forward find-references request to jdtls."""
364
486
  if not server._proxy.is_available:
365
487
  return None
@@ -372,8 +494,7 @@ async def on_references(params: lsp.ReferenceParams) -> list[lsp.Location] | Non
372
494
  return None
373
495
 
374
496
 
375
- @server.feature(lsp.TEXT_DOCUMENT_DOCUMENT_SYMBOL)
376
- async def on_document_symbol(params: lsp.DocumentSymbolParams) -> list[lsp.DocumentSymbol] | None:
497
+ async def _on_document_symbol(params: lsp.DocumentSymbolParams) -> list[lsp.DocumentSymbol] | None:
377
498
  """Forward document symbol request to jdtls."""
378
499
  if not server._proxy.is_available:
379
500
  return None
@@ -386,6 +507,18 @@ async def on_document_symbol(params: lsp.DocumentSymbolParams) -> list[lsp.Docum
386
507
  return None
387
508
 
388
509
 
510
+ # Populate handler map for dynamic registration.
511
+ _JDTLS_HANDLERS.update(
512
+ {
513
+ lsp.TEXT_DOCUMENT_COMPLETION: _on_completion,
514
+ lsp.TEXT_DOCUMENT_HOVER: _on_hover,
515
+ lsp.TEXT_DOCUMENT_DEFINITION: _on_definition,
516
+ lsp.TEXT_DOCUMENT_REFERENCES: _on_references,
517
+ lsp.TEXT_DOCUMENT_DOCUMENT_SYMBOL: _on_document_symbol,
518
+ }
519
+ )
520
+
521
+
389
522
  # --- Code actions (quick fixes) ---
390
523
 
391
524
  # Human-readable titles for code actions