java-functional-lsp 0.9.12__tar.gz → 0.9.14__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.9.12 → java_functional_lsp-0.9.14}/PKG-INFO +1 -1
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/pyproject.toml +1 -1
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/src/java_functional_lsp/__init__.py +1 -1
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/src/java_functional_lsp/proxy.py +84 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/src/java_functional_lsp/server.py +98 -3
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/tests/test_e2e_jdtls.py +16 -2
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/tests/test_proxy.py +235 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/tests/test_server.py +507 -4
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/uv.lock +1 -1
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/.claude-plugin/plugin.json +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/.githooks/pre-commit +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/.githooks/pre-push +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/.github/CODEOWNERS +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/.github/ISSUE_TEMPLATE/bug-report.md +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/.github/ISSUE_TEMPLATE/feature-request.md +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/.github/SECURITY.md +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/.github/dependabot.yml +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/.github/release-drafter.yml +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/.github/workflows/publish.yml +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/.github/workflows/release-drafter.yml +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/.github/workflows/stale.yml +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/.github/workflows/test.yml +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/.github/workflows/update-homebrew.yml +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/.github/workflows/vscode-ext.yml +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/.gitignore +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/CONTRIBUTING.md +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/LICENSE +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/README.md +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/SKILL.md +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/commands/lint-java.md +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/editors/intellij/README.md +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/editors/intellij/lsp4ij-template.json +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/editors/vscode/.vscodeignore +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/editors/vscode/README.md +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/editors/vscode/package-lock.json +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/editors/vscode/package.json +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/editors/vscode/src/extension.ts +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/editors/vscode/tsconfig.json +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/hooks/hooks.json +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/hooks/java_linter_reminder.py +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/scripts/ensure-lsp.sh +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/scripts/generate-formula.py +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/src/java_functional_lsp/__main__.py +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/src/java_functional_lsp/analyzers/__init__.py +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/src/java_functional_lsp/analyzers/base.py +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/src/java_functional_lsp/analyzers/exception_checker.py +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/src/java_functional_lsp/analyzers/functional_checker.py +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/src/java_functional_lsp/analyzers/mutation_checker.py +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/src/java_functional_lsp/analyzers/null_checker.py +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/src/java_functional_lsp/analyzers/spring_checker.py +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/src/java_functional_lsp/cli.py +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/src/java_functional_lsp/fixes.py +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/src/java_functional_lsp/merkle.py +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/tests/__init__.py +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/tests/conftest.py +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/tests/test_base.py +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/tests/test_cli.py +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/tests/test_config.py +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/tests/test_e2e.py +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/tests/test_exception_checker.py +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/tests/test_fixes.py +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/tests/test_functional_checker.py +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/tests/test_merkle.py +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/tests/test_merkle_proxy.py +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/tests/test_mutation_checker.py +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/tests/test_null_checker.py +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/tests/test_spring_checker.py +0 -0
- {java_functional_lsp-0.9.12 → java_functional_lsp-0.9.14}/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.9.
|
|
3
|
+
Version: 0.9.14
|
|
4
4
|
Summary: Java LSP server enforcing functional programming best practices — null safety, immutability, no exceptions
|
|
5
5
|
Project-URL: Homepage, https://github.com/aviadshiber/java-functional-lsp
|
|
6
6
|
Project-URL: Repository, https://github.com/aviadshiber/java-functional-lsp
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "java-functional-lsp"
|
|
7
|
-
version = "0.9.
|
|
7
|
+
version = "0.9.14"
|
|
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" }
|
|
@@ -29,6 +29,7 @@ _START_RETRY_COOLDOWN = 300.0 # seconds — retry jdtls startup after transient
|
|
|
29
29
|
DEFAULT_JVM_MAX_HEAP = "4g"
|
|
30
30
|
_STDERR_LINE_MAX = 1000
|
|
31
31
|
_MAX_EXPANDED_GROUPS: int = 5 # hard cap on concurrent Maven group workspace folders (prevents jdtls OOM)
|
|
32
|
+
_WORKSPACE_CACHE_MAX_SIZE: int = 10 # default LRU cap — override via {"cache": {"maxWorkspaces": N}}
|
|
32
33
|
|
|
33
34
|
# Default jdtls initialization settings. Sent via initializationOptions.settings
|
|
34
35
|
# so they apply BEFORE the Maven import scan (didChangeConfiguration is too late).
|
|
@@ -563,6 +564,87 @@ def _compute_module_diff(
|
|
|
563
564
|
return (diff, current)
|
|
564
565
|
|
|
565
566
|
|
|
567
|
+
def _dir_mtime(path: Path) -> float:
|
|
568
|
+
"""Return ``st_mtime`` of *path*, or ``float('inf')`` on any OSError.
|
|
569
|
+
|
|
570
|
+
Returning infinity on error sorts the directory as the *newest* entry,
|
|
571
|
+
so stat-inaccessible directories are conservatively kept rather than
|
|
572
|
+
silently evicted.
|
|
573
|
+
"""
|
|
574
|
+
try:
|
|
575
|
+
return path.stat().st_mtime
|
|
576
|
+
except OSError:
|
|
577
|
+
return float("inf")
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _parse_max_workspaces(config: Mapping[str, Any] | None) -> int:
|
|
581
|
+
"""Parse and validate ``cache.maxWorkspaces`` from the project config.
|
|
582
|
+
|
|
583
|
+
Returns *_WORKSPACE_CACHE_MAX_SIZE* when the key is absent, when the value
|
|
584
|
+
cannot be converted to ``int``, or when the result is not positive. Logs a
|
|
585
|
+
warning in each error case so the user can diagnose the misconfiguration.
|
|
586
|
+
"""
|
|
587
|
+
raw = (config or {}).get("cache", {}).get("maxWorkspaces")
|
|
588
|
+
if raw is None:
|
|
589
|
+
return _WORKSPACE_CACHE_MAX_SIZE
|
|
590
|
+
try:
|
|
591
|
+
value = int(raw)
|
|
592
|
+
except (TypeError, ValueError):
|
|
593
|
+
logger.warning(
|
|
594
|
+
"jdtls: invalid cache.maxWorkspaces %r — using default %d",
|
|
595
|
+
raw,
|
|
596
|
+
_WORKSPACE_CACHE_MAX_SIZE,
|
|
597
|
+
)
|
|
598
|
+
return _WORKSPACE_CACHE_MAX_SIZE
|
|
599
|
+
if value <= 0:
|
|
600
|
+
logger.warning(
|
|
601
|
+
"jdtls: cache.maxWorkspaces must be >= 1, got %d — using default %d",
|
|
602
|
+
value,
|
|
603
|
+
_WORKSPACE_CACHE_MAX_SIZE,
|
|
604
|
+
)
|
|
605
|
+
return _WORKSPACE_CACHE_MAX_SIZE
|
|
606
|
+
return value
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _evict_lru_workspaces(cache_root: Path, *, max_size: int = _WORKSPACE_CACHE_MAX_SIZE) -> None:
|
|
610
|
+
"""Evict least-recently-used jdtls workspace directories when count exceeds *max_size*.
|
|
611
|
+
|
|
612
|
+
Implements Caffeine's ``maximumSize`` eviction policy for a filesystem cache.
|
|
613
|
+
Directories are ordered by ``st_mtime`` (last modification time), which is a
|
|
614
|
+
reliable proxy for last-used time because jdtls always writes to its workspace
|
|
615
|
+
on startup. On macOS APFS (``noatime`` default) ``atime`` is not updated on
|
|
616
|
+
reads, so ``mtime`` is used exclusively.
|
|
617
|
+
|
|
618
|
+
The *max_size* most-recently-modified real directories are kept; the rest are
|
|
619
|
+
removed. Dotfiles (e.g. ``.version``), symlinks, and non-directory entries
|
|
620
|
+
are excluded from the eviction candidates.
|
|
621
|
+
|
|
622
|
+
A directory whose ``stat()`` raises ``OSError`` is treated as the *newest*
|
|
623
|
+
entry (``float('inf')`` mtime) so it is conservatively kept.
|
|
624
|
+
|
|
625
|
+
Runs in the executor thread (``_blocking_startup``) — never blocks the event loop.
|
|
626
|
+
|
|
627
|
+
Users can tune the cap via ``.java-functional-lsp.json``:
|
|
628
|
+
``{"cache": {"maxWorkspaces": 20}}``
|
|
629
|
+
"""
|
|
630
|
+
if not cache_root.is_dir():
|
|
631
|
+
return
|
|
632
|
+
try:
|
|
633
|
+
dirs = [d for d in cache_root.iterdir() if d.is_dir() and not d.is_symlink() and not d.name.startswith(".")]
|
|
634
|
+
except OSError as e:
|
|
635
|
+
logger.warning("jdtls: cannot scan cache root for eviction: %s", e)
|
|
636
|
+
return
|
|
637
|
+
if len(dirs) <= max_size:
|
|
638
|
+
return
|
|
639
|
+
dirs.sort(key=_dir_mtime, reverse=True) # newest first
|
|
640
|
+
for stale in dirs[max_size:]:
|
|
641
|
+
try:
|
|
642
|
+
shutil.rmtree(stale)
|
|
643
|
+
logger.info("jdtls: evicted workspace cache %s (LRU)", stale.name)
|
|
644
|
+
except OSError as e:
|
|
645
|
+
logger.warning("jdtls: failed to evict workspace cache %s: %s", stale.name, e)
|
|
646
|
+
|
|
647
|
+
|
|
566
648
|
def _wipe_data_dir(data_dir: Path) -> None:
|
|
567
649
|
"""Remove *data_dir* so the next jdtls startup gets a clean cold start.
|
|
568
650
|
|
|
@@ -793,6 +875,8 @@ class JdtlsProxy:
|
|
|
793
875
|
|
|
794
876
|
def _blocking_startup() -> tuple[dict[str, str], str | None]:
|
|
795
877
|
_clear_cache_on_version_change(cache_root)
|
|
878
|
+
max_workspaces = _parse_max_workspaces(config)
|
|
879
|
+
_evict_lru_workspaces(cache_root, max_size=max_workspaces)
|
|
796
880
|
return build_jdtls_env(), _find_lombok_jar(config)
|
|
797
881
|
|
|
798
882
|
loop = asyncio.get_running_loop()
|
|
@@ -13,6 +13,7 @@ import os
|
|
|
13
13
|
import re
|
|
14
14
|
import sys
|
|
15
15
|
import time
|
|
16
|
+
from collections import OrderedDict
|
|
16
17
|
from collections.abc import Coroutine
|
|
17
18
|
from pathlib import Path
|
|
18
19
|
from typing import Any
|
|
@@ -20,7 +21,7 @@ from typing import Any
|
|
|
20
21
|
from lsprotocol import types as lsp
|
|
21
22
|
from lsprotocol.converters import get_converter
|
|
22
23
|
from pygls.lsp.server import LanguageServer
|
|
23
|
-
from pygls.uris import to_fs_path
|
|
24
|
+
from pygls.uris import from_fs_path, to_fs_path
|
|
24
25
|
|
|
25
26
|
from .analyzers.base import Analyzer, Severity, get_parser, is_excluded, is_suppressed
|
|
26
27
|
from .analyzers.base import Diagnostic as LintDiagnostic
|
|
@@ -62,6 +63,34 @@ _JDTLS_SKIP_CLIENTS = ("IntelliJ", "JetBrains")
|
|
|
62
63
|
#: go-to-definition, references, etc.
|
|
63
64
|
_converter = get_converter()
|
|
64
65
|
|
|
66
|
+
# Cap on _session_opened_uris: a session navigating many files still bounds memory.
|
|
67
|
+
# 4096 comfortably covers even aggressive monorepo navigation.
|
|
68
|
+
_MAX_OPENED_URIS = 4096
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _normalize_uri(uri: str) -> str:
|
|
72
|
+
"""Canonicalize a file:// URI so equivalent paths compare equal.
|
|
73
|
+
|
|
74
|
+
Round-trips through pygls.uris to normalize URL encoding differences
|
|
75
|
+
(``%20`` vs literal space, ``%3A`` vs ``:``, trailing slashes, double
|
|
76
|
+
slashes) between what the client sends and what jdtls echoes back.
|
|
77
|
+
|
|
78
|
+
Non-file URIs (``jdt://``, ``untitled://``, etc.) are returned unchanged —
|
|
79
|
+
they're never stored in ``_session_opened_uris`` so they'll fall through the gate
|
|
80
|
+
regardless. Case-insensitive filesystems (macOS HFS+, NTFS) are not
|
|
81
|
+
normalized here: attempting that would require ``Path.resolve()``, which
|
|
82
|
+
would stat the filesystem and follow symlinks — too expensive for a hot
|
|
83
|
+
path that runs on every jdtls publish. On Windows, drive-letter case
|
|
84
|
+
(``C:`` vs ``c:``) is similarly not normalized.
|
|
85
|
+
"""
|
|
86
|
+
if not uri.startswith("file:"):
|
|
87
|
+
return uri
|
|
88
|
+
fs = to_fs_path(uri)
|
|
89
|
+
if fs is None:
|
|
90
|
+
return uri
|
|
91
|
+
normalized = from_fs_path(fs)
|
|
92
|
+
return normalized if normalized is not None else uri
|
|
93
|
+
|
|
65
94
|
|
|
66
95
|
class JavaFunctionalLspServer(LanguageServer):
|
|
67
96
|
def __init__(self) -> None:
|
|
@@ -76,6 +105,33 @@ class JavaFunctionalLspServer(LanguageServer):
|
|
|
76
105
|
self._skip_jdtls: bool = False
|
|
77
106
|
self._skip_jdtls_registration: bool = False
|
|
78
107
|
self._init_generation: int = 0
|
|
108
|
+
# URIs the client has opened at least once this session (normalized form).
|
|
109
|
+
# Jdtls indexes the whole workspace and publishes diagnostics for files the
|
|
110
|
+
# client never touched; we only republish for URIs present here so unrelated
|
|
111
|
+
# modules don't flood the client. Cumulative (not cleared on didClose) to
|
|
112
|
+
# survive the async race between didClose and late jdtls publishes.
|
|
113
|
+
# Bounded LRU: insertion-ordered, oldest evicted on overflow so a long
|
|
114
|
+
# session navigating many files can't grow the set unboundedly.
|
|
115
|
+
# Reset on initialize.
|
|
116
|
+
self._session_opened_uris: OrderedDict[str, None] = OrderedDict()
|
|
117
|
+
|
|
118
|
+
def _record_opened(self, uri: str) -> None:
|
|
119
|
+
"""Record *uri* as opened this session, evicting the oldest entry at cap.
|
|
120
|
+
|
|
121
|
+
Stores the normalized form so gate lookups in ``_on_jdtls_diagnostics``
|
|
122
|
+
can use a fast raw-first check without redundant normalization.
|
|
123
|
+
"""
|
|
124
|
+
uri = _normalize_uri(uri)
|
|
125
|
+
if uri in self._session_opened_uris:
|
|
126
|
+
self._session_opened_uris.move_to_end(uri)
|
|
127
|
+
return
|
|
128
|
+
self._session_opened_uris[uri] = None
|
|
129
|
+
# Size check uses > so the dict stabilises at exactly _MAX_OPENED_URIS
|
|
130
|
+
# entries after the pop: we insert first (reaching MAX+1 momentarily),
|
|
131
|
+
# then evict. The transient overshoot is invisible to other callers
|
|
132
|
+
# because asyncio is single-threaded and no await intervenes here.
|
|
133
|
+
if len(self._session_opened_uris) > _MAX_OPENED_URIS:
|
|
134
|
+
self._session_opened_uris.popitem(last=False)
|
|
79
135
|
|
|
80
136
|
def _on_jdtls_diagnostics(self, uri: str, diagnostics: list[Any]) -> None:
|
|
81
137
|
"""Called when jdtls publishes diagnostics — merge with custom and re-publish.
|
|
@@ -108,10 +164,25 @@ class JavaFunctionalLspServer(LanguageServer):
|
|
|
108
164
|
# First READY transition only — subsequent diagnostics for the same
|
|
109
165
|
# module are no-ops to avoid spawning O(files) redundant tasks.
|
|
110
166
|
_fire_and_forget(_apply_module_diff(self._proxy, module_uri))
|
|
167
|
+
# Only republish for files the client has opened at any point this
|
|
168
|
+
# session (cumulative). Jdtls scans the whole workspace; without this
|
|
169
|
+
# guard, diagnostics for 200+ unrelated modules leak out as
|
|
170
|
+
# <new-diagnostics> tags (see #71). Non-file URIs (jdt://, untitled://,
|
|
171
|
+
# etc.) are never recorded by _record_opened so they're dropped here.
|
|
172
|
+
#
|
|
173
|
+
# Two-phase lookup: check the raw URI first (O(1), no allocation) since
|
|
174
|
+
# jdtls typically echoes URIs in the same form as the client — the fast
|
|
175
|
+
# path avoids the to_fs_path/from_fs_path round-trip on every publish.
|
|
176
|
+
# Fall back to the normalized form only on a miss to handle encoding
|
|
177
|
+
# mismatches (e.g., client sends literal space, jdtls echoes %20).
|
|
178
|
+
if uri not in self._session_opened_uris and _normalize_uri(uri) not in self._session_opened_uris:
|
|
179
|
+
return
|
|
111
180
|
try:
|
|
112
181
|
_analyze_and_publish(uri)
|
|
113
182
|
except FileNotFoundError:
|
|
114
|
-
|
|
183
|
+
# The file was in the opened-URIs set but vanished from disk
|
|
184
|
+
# between didOpen and this late jdtls publish. Benign.
|
|
185
|
+
pass
|
|
115
186
|
except Exception as e:
|
|
116
187
|
logger.error("Error re-publishing diagnostics for %s: %s", uri, e)
|
|
117
188
|
|
|
@@ -298,6 +369,24 @@ def _jdtls_raw_to_lsp_diagnostics(raw_diagnostics: list[Any]) -> list[lsp.Diagno
|
|
|
298
369
|
|
|
299
370
|
_MAX_PATTERN_LENGTH = 500 # Cap regex length to mitigate ReDoS from pathological patterns
|
|
300
371
|
|
|
372
|
+
_M2E_SOURCES: frozenset[str] = frozenset({"org.eclipse.m2e"})
|
|
373
|
+
_M2E_MESSAGE_PATTERNS: re.Pattern[str] = re.compile(
|
|
374
|
+
r"Plugin execution not covered by lifecycle configuration"
|
|
375
|
+
r"|The project cannot be built until its prerequisite"
|
|
376
|
+
r"|Failed to execute mojo"
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _is_m2e_marker(diag: dict[str, Any]) -> bool:
|
|
381
|
+
"""Return True if the diagnostic is an Eclipse/M2E lifecycle marker (not a Java error)."""
|
|
382
|
+
if str(diag.get("code", "")) == "0":
|
|
383
|
+
return True
|
|
384
|
+
if diag.get("source", "") in _M2E_SOURCES:
|
|
385
|
+
return True
|
|
386
|
+
if _M2E_MESSAGE_PATTERNS.search(diag.get("message", "")):
|
|
387
|
+
return True
|
|
388
|
+
return False
|
|
389
|
+
|
|
301
390
|
|
|
302
391
|
def _compile_user_patterns(config: dict[str, Any]) -> list[re.Pattern[str]]:
|
|
303
392
|
"""Compile user-defined suppressJdtlsPatterns from config."""
|
|
@@ -346,7 +435,9 @@ def _run_analysis(source: str, uri: str) -> list[lsp.Diagnostic]:
|
|
|
346
435
|
if server._proxy.is_available:
|
|
347
436
|
try:
|
|
348
437
|
raw = server._proxy.get_cached_diagnostics(uri)
|
|
349
|
-
raw = [
|
|
438
|
+
raw = [
|
|
439
|
+
d for d in raw if not _is_m2e_marker(d) and not _is_jdtls_suppressed(d, server._user_suppress_patterns)
|
|
440
|
+
]
|
|
350
441
|
jdtls_diags = _jdtls_raw_to_lsp_diagnostics(raw)
|
|
351
442
|
except Exception as e:
|
|
352
443
|
logger.warning("jdtls diagnostic processing failed for %s: %s", uri, e)
|
|
@@ -390,6 +481,7 @@ def on_initialize(params: lsp.InitializeParams) -> lsp.InitializeResult:
|
|
|
390
481
|
server._skip_jdtls = False
|
|
391
482
|
server._skip_jdtls_registration = False
|
|
392
483
|
server._init_generation += 1
|
|
484
|
+
server._session_opened_uris.clear()
|
|
393
485
|
_jdtls_capabilities_registered = False
|
|
394
486
|
|
|
395
487
|
jdtls_override = os.environ.get("JAVA_FUNCTIONAL_LSP_JDTLS", "").strip().lower()
|
|
@@ -578,6 +670,9 @@ async def on_did_open(params: lsp.DidOpenTextDocumentParams) -> None:
|
|
|
578
670
|
didOpen response isn't delayed by jdtls cold-start.
|
|
579
671
|
"""
|
|
580
672
|
uri = params.text_document.uri
|
|
673
|
+
# Must precede any await: same-loop jdtls diagnostic callbacks need to
|
|
674
|
+
# observe this URI before they arrive to avoid the publish getting gated.
|
|
675
|
+
server._record_opened(uri)
|
|
581
676
|
|
|
582
677
|
if server._skip_jdtls:
|
|
583
678
|
# Skip all jdtls forwarding — custom diagnostics only.
|
|
@@ -68,6 +68,10 @@ pytestmark = [
|
|
|
68
68
|
# so a hung jdtls doesn't wedge the suite.
|
|
69
69
|
_E2E_TEST_TIMEOUT_SEC = 120
|
|
70
70
|
_JDTLS_PARSE_WAIT_SEC = 2.5
|
|
71
|
+
# Max wall-clock seconds to poll for a non-empty documentSymbol result.
|
|
72
|
+
# macOS CI runners are slower than Linux; 60s accommodates cold-start variance.
|
|
73
|
+
_JDTLS_SYMBOL_POLL_TIMEOUT_SEC = 60
|
|
74
|
+
_JDTLS_SYMBOL_POLL_INTERVAL_SEC = 1.0
|
|
71
75
|
|
|
72
76
|
_HELLO_JAVA = """\
|
|
73
77
|
public class Hello {
|
|
@@ -260,7 +264,16 @@ class TestJdtlsEndToEnd:
|
|
|
260
264
|
assert "textDocument" in serialized, f"_serialize_params emitted wrong field names: {serialized.keys()}"
|
|
261
265
|
assert "text_document" not in serialized
|
|
262
266
|
|
|
263
|
-
|
|
267
|
+
# Poll until jdtls returns a non-empty symbol list or we time out.
|
|
268
|
+
# A fixed sleep is unreliable on macOS CI where jdtls cold-start
|
|
269
|
+
# can reach 20s; polling adapts to the actual indexing speed.
|
|
270
|
+
deadline = asyncio.get_event_loop().time() + _JDTLS_SYMBOL_POLL_TIMEOUT_SEC
|
|
271
|
+
result = None
|
|
272
|
+
while asyncio.get_event_loop().time() < deadline:
|
|
273
|
+
result = await proxy.send_request("textDocument/documentSymbol", serialized)
|
|
274
|
+
if result:
|
|
275
|
+
break
|
|
276
|
+
await asyncio.sleep(_JDTLS_SYMBOL_POLL_INTERVAL_SEC)
|
|
264
277
|
|
|
265
278
|
# Primary assertion: jdtls returned something. With a correctly-shaped
|
|
266
279
|
# request, jdtls always returns a list (possibly empty) for a parsable
|
|
@@ -274,7 +287,8 @@ class TestJdtlsEndToEnd:
|
|
|
274
287
|
assert isinstance(result, list)
|
|
275
288
|
# Hello.java declares a top-level class, so we expect at least one symbol.
|
|
276
289
|
assert len(result) > 0, (
|
|
277
|
-
"jdtls returned an empty symbol list for a file with a top-level class
|
|
290
|
+
"jdtls returned an empty symbol list for a file with a top-level class "
|
|
291
|
+
f"after polling for {_JDTLS_SYMBOL_POLL_TIMEOUT_SEC}s. "
|
|
278
292
|
"Either jdtls didn't finish parsing or the file wasn't opened correctly."
|
|
279
293
|
)
|
|
280
294
|
|
|
@@ -4,7 +4,10 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import json
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
7
9
|
import subprocess
|
|
10
|
+
import time
|
|
8
11
|
from pathlib import Path
|
|
9
12
|
from typing import Any
|
|
10
13
|
from unittest.mock import MagicMock, patch
|
|
@@ -1517,3 +1520,235 @@ class TestWipeDataDir:
|
|
|
1517
1520
|
from java_functional_lsp.proxy import _wipe_data_dir
|
|
1518
1521
|
|
|
1519
1522
|
_wipe_data_dir(tmp_path / "ghost") # should not raise
|
|
1523
|
+
|
|
1524
|
+
|
|
1525
|
+
class TestEvictLruWorkspaces:
|
|
1526
|
+
"""Tests for _evict_lru_workspaces — count-based LRU eviction of jdtls workspace dirs."""
|
|
1527
|
+
|
|
1528
|
+
@staticmethod
|
|
1529
|
+
def _make_workspace(cache_root: Path, name: str, mtime_age_days: float) -> Path:
|
|
1530
|
+
"""Create a fake workspace dir and backdate its mtime."""
|
|
1531
|
+
d = cache_root / name
|
|
1532
|
+
d.mkdir()
|
|
1533
|
+
ts = time.time() - mtime_age_days * 86400.0
|
|
1534
|
+
os.utime(d, (ts, ts))
|
|
1535
|
+
return d
|
|
1536
|
+
|
|
1537
|
+
def test_noop_on_missing_cache_root(self, tmp_path: Path) -> None:
|
|
1538
|
+
"""No error when cache_root does not exist."""
|
|
1539
|
+
from java_functional_lsp.proxy import _evict_lru_workspaces
|
|
1540
|
+
|
|
1541
|
+
_evict_lru_workspaces(tmp_path / "nonexistent", max_size=10)
|
|
1542
|
+
# No exception raised — that is the assertion
|
|
1543
|
+
|
|
1544
|
+
def test_noop_when_at_or_below_max_size(self, tmp_path: Path) -> None:
|
|
1545
|
+
"""No eviction when count <= max_size."""
|
|
1546
|
+
from java_functional_lsp.proxy import _evict_lru_workspaces
|
|
1547
|
+
|
|
1548
|
+
d1 = self._make_workspace(tmp_path, "aabbccdd1234", mtime_age_days=5)
|
|
1549
|
+
d2 = self._make_workspace(tmp_path, "1122334455ab", mtime_age_days=3)
|
|
1550
|
+
_evict_lru_workspaces(tmp_path, max_size=2)
|
|
1551
|
+
assert d1.exists()
|
|
1552
|
+
assert d2.exists()
|
|
1553
|
+
|
|
1554
|
+
def test_evicts_oldest_when_over_max_size(self, tmp_path: Path) -> None:
|
|
1555
|
+
"""Oldest dirs (lowest mtime) are removed; newest are kept."""
|
|
1556
|
+
from java_functional_lsp.proxy import _evict_lru_workspaces
|
|
1557
|
+
|
|
1558
|
+
oldest = self._make_workspace(tmp_path, "aabbccdd1111", mtime_age_days=10)
|
|
1559
|
+
middle = self._make_workspace(tmp_path, "aabbccdd2222", mtime_age_days=5)
|
|
1560
|
+
newest = self._make_workspace(tmp_path, "aabbccdd3333", mtime_age_days=1)
|
|
1561
|
+
_evict_lru_workspaces(tmp_path, max_size=2)
|
|
1562
|
+
assert not oldest.exists()
|
|
1563
|
+
assert middle.exists()
|
|
1564
|
+
assert newest.exists()
|
|
1565
|
+
|
|
1566
|
+
def test_dotfiles_never_evicted(self, tmp_path: Path) -> None:
|
|
1567
|
+
""".version and other dotfiles are excluded from eviction."""
|
|
1568
|
+
from java_functional_lsp.proxy import _evict_lru_workspaces
|
|
1569
|
+
|
|
1570
|
+
marker = tmp_path / ".version"
|
|
1571
|
+
marker.write_text("abc|21")
|
|
1572
|
+
old_ts = time.time() - 999 * 86400.0
|
|
1573
|
+
os.utime(marker, (old_ts, old_ts))
|
|
1574
|
+
# max_size=0 would evict everything — dotfiles must still survive
|
|
1575
|
+
_evict_lru_workspaces(tmp_path, max_size=0)
|
|
1576
|
+
assert marker.exists()
|
|
1577
|
+
|
|
1578
|
+
def test_dotdirs_never_evicted(self, tmp_path: Path) -> None:
|
|
1579
|
+
"""Dotfile directories (e.g. .hidden/) are excluded from eviction."""
|
|
1580
|
+
from java_functional_lsp.proxy import _evict_lru_workspaces
|
|
1581
|
+
|
|
1582
|
+
hidden_dir = tmp_path / ".hidden"
|
|
1583
|
+
hidden_dir.mkdir()
|
|
1584
|
+
old_ts = time.time() - 999 * 86400.0
|
|
1585
|
+
os.utime(hidden_dir, (old_ts, old_ts))
|
|
1586
|
+
_evict_lru_workspaces(tmp_path, max_size=0)
|
|
1587
|
+
assert hidden_dir.exists()
|
|
1588
|
+
|
|
1589
|
+
def test_non_directory_entries_ignored(self, tmp_path: Path) -> None:
|
|
1590
|
+
"""Plain files in cache_root are never removed."""
|
|
1591
|
+
from java_functional_lsp.proxy import _evict_lru_workspaces
|
|
1592
|
+
|
|
1593
|
+
stray = tmp_path / "stray.txt"
|
|
1594
|
+
stray.write_text("irrelevant")
|
|
1595
|
+
old_ts = time.time() - 999 * 86400.0
|
|
1596
|
+
os.utime(stray, (old_ts, old_ts))
|
|
1597
|
+
_evict_lru_workspaces(tmp_path, max_size=0)
|
|
1598
|
+
assert stray.exists()
|
|
1599
|
+
|
|
1600
|
+
def test_symlinks_never_evicted(self, tmp_path: Path) -> None:
|
|
1601
|
+
"""Symlinks in cache_root are excluded — shutil.rmtree must not follow them."""
|
|
1602
|
+
from java_functional_lsp.proxy import _evict_lru_workspaces
|
|
1603
|
+
|
|
1604
|
+
# Target is a dotdir (excluded from candidates) so it is never evicted.
|
|
1605
|
+
# This prevents the symlink from going dangling during the test.
|
|
1606
|
+
target = tmp_path / ".hidden_target"
|
|
1607
|
+
target.mkdir()
|
|
1608
|
+
link = tmp_path / "aabbccdd1111"
|
|
1609
|
+
link.symlink_to(target)
|
|
1610
|
+
old_ts = time.time() - 999 * 86400.0
|
|
1611
|
+
os.utime(link, (old_ts, old_ts))
|
|
1612
|
+
_evict_lru_workspaces(tmp_path, max_size=0)
|
|
1613
|
+
assert link.exists() # symlink itself must survive
|
|
1614
|
+
assert target.exists() # target must not be deleted
|
|
1615
|
+
|
|
1616
|
+
def test_dir_mtime_returns_inf_on_oserror(self, tmp_path: Path) -> None:
|
|
1617
|
+
"""_dir_mtime returns float('inf') when stat() fails — non-zero sentinel."""
|
|
1618
|
+
from java_functional_lsp.proxy import _dir_mtime
|
|
1619
|
+
|
|
1620
|
+
assert _dir_mtime(tmp_path / "nonexistent") == float("inf")
|
|
1621
|
+
|
|
1622
|
+
def test_stat_error_conservatively_keeps_dir(self, tmp_path: Path) -> None:
|
|
1623
|
+
"""Dir whose _dir_mtime returns inf is sorted as newest — conservatively kept.
|
|
1624
|
+
|
|
1625
|
+
Patching _dir_mtime directly (not Path.stat) avoids interfering with
|
|
1626
|
+
is_dir() which also calls stat() internally.
|
|
1627
|
+
"""
|
|
1628
|
+
from java_functional_lsp.proxy import _evict_lru_workspaces
|
|
1629
|
+
|
|
1630
|
+
older = self._make_workspace(tmp_path, "aabbccdd1111", mtime_age_days=10)
|
|
1631
|
+
kept_dir = self._make_workspace(tmp_path, "aabbccdd2222", mtime_age_days=5)
|
|
1632
|
+
|
|
1633
|
+
original_dir_mtime = proxy_mod._dir_mtime
|
|
1634
|
+
|
|
1635
|
+
def patched_dir_mtime(path: Path) -> float:
|
|
1636
|
+
if path.name == kept_dir.name:
|
|
1637
|
+
return float("inf")
|
|
1638
|
+
return original_dir_mtime(path)
|
|
1639
|
+
|
|
1640
|
+
with patch("java_functional_lsp.proxy._dir_mtime", patched_dir_mtime):
|
|
1641
|
+
# kept_dir: _dir_mtime returns inf → sorted as newest → kept
|
|
1642
|
+
# older: mtime=10 days ago → sorted as oldest → evicted
|
|
1643
|
+
_evict_lru_workspaces(tmp_path, max_size=1)
|
|
1644
|
+
|
|
1645
|
+
assert kept_dir.exists() # conservatively kept (inf mtime)
|
|
1646
|
+
assert not older.exists() # evicted as the oldest accessible dir
|
|
1647
|
+
|
|
1648
|
+
def test_rmtree_error_logged_and_continued(self, tmp_path: Path) -> None:
|
|
1649
|
+
"""OSError on rmtree emits a warning and continues evicting remaining dirs."""
|
|
1650
|
+
from java_functional_lsp.proxy import _evict_lru_workspaces
|
|
1651
|
+
|
|
1652
|
+
d1 = self._make_workspace(tmp_path, "aabbccdd1111", mtime_age_days=10)
|
|
1653
|
+
self._make_workspace(tmp_path, "aabbccdd2222", mtime_age_days=5)
|
|
1654
|
+
d3 = self._make_workspace(tmp_path, "aabbccdd3333", mtime_age_days=1)
|
|
1655
|
+
|
|
1656
|
+
call_count = 0
|
|
1657
|
+
original_rmtree = shutil.rmtree
|
|
1658
|
+
|
|
1659
|
+
def patched_rmtree(path: Any, **kw: Any) -> None:
|
|
1660
|
+
nonlocal call_count
|
|
1661
|
+
call_count += 1
|
|
1662
|
+
if Path(path).name == d1.name:
|
|
1663
|
+
raise OSError("busy")
|
|
1664
|
+
original_rmtree(path, **kw)
|
|
1665
|
+
|
|
1666
|
+
with patch("java_functional_lsp.proxy.shutil.rmtree", side_effect=patched_rmtree):
|
|
1667
|
+
with patch("java_functional_lsp.proxy.logger") as mock_logger:
|
|
1668
|
+
_evict_lru_workspaces(tmp_path, max_size=1)
|
|
1669
|
+
# Warning for d1 (rmtree failed); info for d2 (evicted successfully)
|
|
1670
|
+
assert mock_logger.warning.call_count >= 1
|
|
1671
|
+
assert mock_logger.info.call_count >= 1
|
|
1672
|
+
|
|
1673
|
+
assert d3.exists() # newest kept
|
|
1674
|
+
|
|
1675
|
+
def test_logs_info_on_eviction(self, tmp_path: Path) -> None:
|
|
1676
|
+
"""Info log is emitted for each evicted workspace dir, including 'LRU'."""
|
|
1677
|
+
from java_functional_lsp.proxy import _evict_lru_workspaces
|
|
1678
|
+
|
|
1679
|
+
self._make_workspace(tmp_path, "aabbccdd1111", mtime_age_days=10)
|
|
1680
|
+
self._make_workspace(tmp_path, "aabbccdd2222", mtime_age_days=1)
|
|
1681
|
+
|
|
1682
|
+
with patch("java_functional_lsp.proxy.logger") as mock_logger:
|
|
1683
|
+
_evict_lru_workspaces(tmp_path, max_size=1)
|
|
1684
|
+
|
|
1685
|
+
info_calls = [str(c) for c in mock_logger.info.call_args_list]
|
|
1686
|
+
assert any("aabbccdd1111" in m and "LRU" in m for m in info_calls)
|
|
1687
|
+
|
|
1688
|
+
def test_max_size_param_respected(self, tmp_path: Path) -> None:
|
|
1689
|
+
"""Passing max_size=3 keeps exactly 3 dirs (the 3 newest)."""
|
|
1690
|
+
from java_functional_lsp.proxy import _evict_lru_workspaces
|
|
1691
|
+
|
|
1692
|
+
dirs = [self._make_workspace(tmp_path, f"workspace{i:04d}", mtime_age_days=float(10 - i)) for i in range(6)]
|
|
1693
|
+
_evict_lru_workspaces(tmp_path, max_size=3)
|
|
1694
|
+
surviving = [d for d in dirs if d.exists()]
|
|
1695
|
+
assert len(surviving) == 3
|
|
1696
|
+
# The 3 newest (smallest age = largest mtime) must survive
|
|
1697
|
+
for d in dirs[-3:]:
|
|
1698
|
+
assert d.exists()
|
|
1699
|
+
|
|
1700
|
+
|
|
1701
|
+
class TestParseMaxWorkspaces:
|
|
1702
|
+
"""Tests for _parse_max_workspaces — config validation for the LRU cap."""
|
|
1703
|
+
|
|
1704
|
+
def test_none_config_returns_default(self) -> None:
|
|
1705
|
+
from java_functional_lsp.proxy import _WORKSPACE_CACHE_MAX_SIZE, _parse_max_workspaces
|
|
1706
|
+
|
|
1707
|
+
assert _parse_max_workspaces(None) == _WORKSPACE_CACHE_MAX_SIZE
|
|
1708
|
+
|
|
1709
|
+
def test_empty_config_returns_default(self) -> None:
|
|
1710
|
+
from java_functional_lsp.proxy import _WORKSPACE_CACHE_MAX_SIZE, _parse_max_workspaces
|
|
1711
|
+
|
|
1712
|
+
assert _parse_max_workspaces({}) == _WORKSPACE_CACHE_MAX_SIZE
|
|
1713
|
+
|
|
1714
|
+
def test_missing_cache_key_returns_default(self) -> None:
|
|
1715
|
+
from java_functional_lsp.proxy import _WORKSPACE_CACHE_MAX_SIZE, _parse_max_workspaces
|
|
1716
|
+
|
|
1717
|
+
assert _parse_max_workspaces({"jdtls": {}}) == _WORKSPACE_CACHE_MAX_SIZE
|
|
1718
|
+
|
|
1719
|
+
def test_valid_value_is_returned(self) -> None:
|
|
1720
|
+
from java_functional_lsp.proxy import _parse_max_workspaces
|
|
1721
|
+
|
|
1722
|
+
assert _parse_max_workspaces({"cache": {"maxWorkspaces": 5}}) == 5
|
|
1723
|
+
assert _parse_max_workspaces({"cache": {"maxWorkspaces": 1}}) == 1
|
|
1724
|
+
assert _parse_max_workspaces({"cache": {"maxWorkspaces": 100}}) == 100
|
|
1725
|
+
|
|
1726
|
+
def test_string_integer_is_accepted(self) -> None:
|
|
1727
|
+
"""A string that represents a valid integer (e.g. from JSON) is parsed."""
|
|
1728
|
+
from java_functional_lsp.proxy import _parse_max_workspaces
|
|
1729
|
+
|
|
1730
|
+
assert _parse_max_workspaces({"cache": {"maxWorkspaces": "7"}}) == 7
|
|
1731
|
+
|
|
1732
|
+
def test_invalid_string_returns_default_with_warning(self) -> None:
|
|
1733
|
+
from java_functional_lsp.proxy import _WORKSPACE_CACHE_MAX_SIZE, _parse_max_workspaces
|
|
1734
|
+
|
|
1735
|
+
with patch("java_functional_lsp.proxy.logger") as mock_logger:
|
|
1736
|
+
result = _parse_max_workspaces({"cache": {"maxWorkspaces": "bad"}})
|
|
1737
|
+
assert result == _WORKSPACE_CACHE_MAX_SIZE
|
|
1738
|
+
mock_logger.warning.assert_called_once()
|
|
1739
|
+
|
|
1740
|
+
def test_zero_returns_default_with_warning(self) -> None:
|
|
1741
|
+
from java_functional_lsp.proxy import _WORKSPACE_CACHE_MAX_SIZE, _parse_max_workspaces
|
|
1742
|
+
|
|
1743
|
+
with patch("java_functional_lsp.proxy.logger") as mock_logger:
|
|
1744
|
+
result = _parse_max_workspaces({"cache": {"maxWorkspaces": 0}})
|
|
1745
|
+
assert result == _WORKSPACE_CACHE_MAX_SIZE
|
|
1746
|
+
mock_logger.warning.assert_called_once()
|
|
1747
|
+
|
|
1748
|
+
def test_negative_returns_default_with_warning(self) -> None:
|
|
1749
|
+
from java_functional_lsp.proxy import _WORKSPACE_CACHE_MAX_SIZE, _parse_max_workspaces
|
|
1750
|
+
|
|
1751
|
+
with patch("java_functional_lsp.proxy.logger") as mock_logger:
|
|
1752
|
+
result = _parse_max_workspaces({"cache": {"maxWorkspaces": -1}})
|
|
1753
|
+
assert result == _WORKSPACE_CACHE_MAX_SIZE
|
|
1754
|
+
mock_logger.warning.assert_called_once()
|