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.
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/PKG-INFO +2 -2
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/README.md +1 -1
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/pyproject.toml +1 -1
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/src/java_functional_lsp/__init__.py +1 -1
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/src/java_functional_lsp/proxy.py +186 -22
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/src/java_functional_lsp/server.py +163 -30
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/tests/test_proxy.py +262 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/tests/test_server.py +198 -1
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/uv.lock +1 -1
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/.claude-plugin/plugin.json +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/.githooks/pre-commit +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/.githooks/pre-push +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/.github/CODEOWNERS +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/.github/ISSUE_TEMPLATE/bug-report.md +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/.github/ISSUE_TEMPLATE/feature-request.md +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/.github/SECURITY.md +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/.github/dependabot.yml +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/.github/release-drafter.yml +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/.github/workflows/publish.yml +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/.github/workflows/release-drafter.yml +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/.github/workflows/stale.yml +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/.github/workflows/test.yml +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/.github/workflows/update-homebrew.yml +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/.github/workflows/vscode-ext.yml +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/.gitignore +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/CONTRIBUTING.md +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/LICENSE +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/SKILL.md +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/commands/lint-java.md +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/editors/intellij/README.md +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/editors/intellij/lsp4ij-template.json +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/editors/vscode/.vscodeignore +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/editors/vscode/README.md +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/editors/vscode/package-lock.json +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/editors/vscode/package.json +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/editors/vscode/src/extension.ts +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/editors/vscode/tsconfig.json +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/hooks/hooks.json +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/hooks/java_linter_reminder.py +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/scripts/ensure-lsp.sh +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/scripts/generate-formula.py +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/src/java_functional_lsp/__main__.py +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/src/java_functional_lsp/analyzers/__init__.py +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/src/java_functional_lsp/analyzers/base.py +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/src/java_functional_lsp/analyzers/exception_checker.py +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/src/java_functional_lsp/analyzers/functional_checker.py +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/src/java_functional_lsp/analyzers/mutation_checker.py +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/src/java_functional_lsp/analyzers/null_checker.py +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/src/java_functional_lsp/analyzers/spring_checker.py +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/src/java_functional_lsp/cli.py +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/src/java_functional_lsp/fixes.py +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/tests/__init__.py +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/tests/conftest.py +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/tests/test_base.py +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/tests/test_cli.py +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/tests/test_config.py +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/tests/test_e2e.py +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/tests/test_e2e_jdtls.py +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/tests/test_exception_checker.py +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/tests/test_fixes.py +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/tests/test_functional_checker.py +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/tests/test_mutation_checker.py +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/tests/test_null_checker.py +0 -0
- {java_functional_lsp-0.7.2 → java_functional_lsp-0.7.4}/tests/test_spring_checker.py +0 -0
- {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.
|
|
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
|
[](https://pypi.org/project/java-functional-lsp/)
|
|
34
34
|
[](https://opensource.org/licenses/MIT)
|
|
35
35
|
|
|
36
|
-
A Java Language Server that provides
|
|
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
|
[](https://pypi.org/project/java-functional-lsp/)
|
|
6
6
|
[](https://opensource.org/licenses/MIT)
|
|
7
7
|
|
|
8
|
-
A Java Language Server that provides
|
|
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.
|
|
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" }
|
|
@@ -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
|
-
|
|
343
|
-
"""
|
|
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
|
-
#
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
#
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
251
|
-
|
|
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
|
|
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
|
-
|
|
278
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
311
|
-
|
|
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
|
-
# ---
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|