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.
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/PKG-INFO +2 -2
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/README.md +1 -1
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/pyproject.toml +1 -1
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/src/java_functional_lsp/__init__.py +1 -1
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/src/java_functional_lsp/proxy.py +186 -22
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/src/java_functional_lsp/server.py +75 -15
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/tests/test_proxy.py +262 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/tests/test_server.py +62 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/uv.lock +1 -1
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/.claude-plugin/plugin.json +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/.githooks/pre-commit +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/.githooks/pre-push +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/.github/CODEOWNERS +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/.github/ISSUE_TEMPLATE/bug-report.md +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/.github/ISSUE_TEMPLATE/feature-request.md +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/.github/SECURITY.md +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/.github/dependabot.yml +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/.github/release-drafter.yml +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/.github/workflows/publish.yml +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/.github/workflows/release-drafter.yml +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/.github/workflows/stale.yml +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/.github/workflows/test.yml +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/.github/workflows/update-homebrew.yml +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/.github/workflows/vscode-ext.yml +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/.gitignore +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/CONTRIBUTING.md +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/LICENSE +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/SKILL.md +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/commands/lint-java.md +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/editors/intellij/README.md +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/editors/intellij/lsp4ij-template.json +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/editors/vscode/.vscodeignore +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/editors/vscode/README.md +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/editors/vscode/package-lock.json +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/editors/vscode/package.json +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/editors/vscode/src/extension.ts +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/editors/vscode/tsconfig.json +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/hooks/hooks.json +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/hooks/java_linter_reminder.py +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/scripts/ensure-lsp.sh +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/scripts/generate-formula.py +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/src/java_functional_lsp/__main__.py +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/src/java_functional_lsp/analyzers/__init__.py +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/src/java_functional_lsp/analyzers/base.py +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/src/java_functional_lsp/analyzers/exception_checker.py +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/src/java_functional_lsp/analyzers/functional_checker.py +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/src/java_functional_lsp/analyzers/mutation_checker.py +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/src/java_functional_lsp/analyzers/null_checker.py +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/src/java_functional_lsp/analyzers/spring_checker.py +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/src/java_functional_lsp/cli.py +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/src/java_functional_lsp/fixes.py +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/tests/__init__.py +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/tests/conftest.py +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/tests/test_base.py +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/tests/test_cli.py +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/tests/test_config.py +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/tests/test_e2e.py +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/tests/test_e2e_jdtls.py +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/tests/test_exception_checker.py +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/tests/test_fixes.py +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/tests/test_functional_checker.py +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/tests/test_mutation_checker.py +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/tests/test_null_checker.py +0 -0
- {java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/tests/test_spring_checker.py +0 -0
- {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
|
+
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
|
|
|
@@ -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
|
-
"""
|
|
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")
|
|
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
|
|
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
|
-
|
|
339
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
372
|
-
|
|
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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/.github/ISSUE_TEMPLATE/bug-report.md
RENAMED
|
File without changes
|
{java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/.github/ISSUE_TEMPLATE/feature-request.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/.github/workflows/release-drafter.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/.github/workflows/update-homebrew.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/editors/intellij/lsp4ij-template.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{java_functional_lsp-0.7.3 → java_functional_lsp-0.7.4}/src/java_functional_lsp/analyzers/base.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|