java-functional-lsp 0.8.1__tar.gz → 0.9.1__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.8.1 → java_functional_lsp-0.9.1}/PKG-INFO +1 -1
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/pyproject.toml +1 -1
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/src/java_functional_lsp/__init__.py +1 -1
- java_functional_lsp-0.9.1/src/java_functional_lsp/merkle.py +222 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/src/java_functional_lsp/proxy.py +124 -7
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/src/java_functional_lsp/server.py +92 -26
- java_functional_lsp-0.9.1/tests/test_merkle.py +314 -0
- java_functional_lsp-0.9.1/tests/test_merkle_proxy.py +366 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/tests/test_proxy.py +7 -3
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/uv.lock +1 -1
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/.claude-plugin/plugin.json +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/.githooks/pre-commit +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/.githooks/pre-push +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/.github/CODEOWNERS +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/.github/ISSUE_TEMPLATE/bug-report.md +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/.github/ISSUE_TEMPLATE/feature-request.md +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/.github/SECURITY.md +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/.github/dependabot.yml +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/.github/release-drafter.yml +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/.github/workflows/publish.yml +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/.github/workflows/release-drafter.yml +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/.github/workflows/stale.yml +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/.github/workflows/test.yml +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/.github/workflows/update-homebrew.yml +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/.github/workflows/vscode-ext.yml +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/.gitignore +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/CONTRIBUTING.md +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/LICENSE +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/README.md +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/SKILL.md +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/commands/lint-java.md +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/editors/intellij/README.md +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/editors/intellij/lsp4ij-template.json +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/editors/vscode/.vscodeignore +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/editors/vscode/README.md +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/editors/vscode/package-lock.json +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/editors/vscode/package.json +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/editors/vscode/src/extension.ts +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/editors/vscode/tsconfig.json +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/hooks/hooks.json +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/hooks/java_linter_reminder.py +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/scripts/ensure-lsp.sh +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/scripts/generate-formula.py +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/src/java_functional_lsp/__main__.py +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/src/java_functional_lsp/analyzers/__init__.py +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/src/java_functional_lsp/analyzers/base.py +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/src/java_functional_lsp/analyzers/exception_checker.py +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/src/java_functional_lsp/analyzers/functional_checker.py +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/src/java_functional_lsp/analyzers/mutation_checker.py +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/src/java_functional_lsp/analyzers/null_checker.py +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/src/java_functional_lsp/analyzers/spring_checker.py +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/src/java_functional_lsp/cli.py +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/src/java_functional_lsp/fixes.py +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/tests/__init__.py +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/tests/conftest.py +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/tests/test_base.py +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/tests/test_cli.py +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/tests/test_config.py +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/tests/test_e2e.py +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/tests/test_e2e_jdtls.py +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/tests/test_exception_checker.py +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/tests/test_fixes.py +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/tests/test_functional_checker.py +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/tests/test_mutation_checker.py +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/tests/test_null_checker.py +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/tests/test_server.py +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/tests/test_spring_checker.py +0 -0
- {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/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.
|
|
3
|
+
Version: 0.9.1
|
|
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.
|
|
7
|
+
version = "0.9.1"
|
|
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" }
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Module snapshot tracking for incremental file-change detection between sessions.
|
|
2
|
+
|
|
3
|
+
Each Java module's source files are hashed into a ``ModuleSnapshot``.
|
|
4
|
+
On the next plugin session the stored snapshot is compared against the
|
|
5
|
+
current one; only files that actually changed are forwarded to jdtls via
|
|
6
|
+
``workspace/didChangeWatchedFiles``, letting it do an incremental rebuild
|
|
7
|
+
instead of a full cold-start re-index.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import contextlib
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import tempfile
|
|
18
|
+
from collections.abc import Iterator
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
# Files and directories tracked / skipped during snapshot.
|
|
25
|
+
_BUILD_FILES: frozenset[str] = frozenset({"pom.xml", "build.gradle", "build.gradle.kts"})
|
|
26
|
+
_SKIP_DIRS: frozenset[str] = frozenset({"target", ".git", "node_modules", ".gradle", "build", ".idea", ".mvn"})
|
|
27
|
+
|
|
28
|
+
# Safety bound: modules larger than this are skipped to prevent multi-second
|
|
29
|
+
# executor stalls (e.g., when jdtls is scoped to the monorepo root).
|
|
30
|
+
_MAX_FILES = 20_000
|
|
31
|
+
|
|
32
|
+
# Upper bound on entries in a persisted snapshot (sanity / DoS guard).
|
|
33
|
+
_MAX_SNAPSHOT_FILES = 100_000
|
|
34
|
+
|
|
35
|
+
_READ_CHUNK = 65536 # 64 KiB per read — bounds per-file memory usage during hashing
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _blake2b_file(path: Path) -> str:
|
|
39
|
+
"""Return a 64-char BLAKE2b hex digest of *path*, reading in 64 KiB chunks.
|
|
40
|
+
|
|
41
|
+
Streams the file instead of loading it entirely into memory, bounding
|
|
42
|
+
per-file heap usage to ``_READ_CHUNK`` bytes regardless of file size.
|
|
43
|
+
"""
|
|
44
|
+
h = hashlib.blake2b(digest_size=32)
|
|
45
|
+
with path.open("rb") as fh:
|
|
46
|
+
while chunk := fh.read(_READ_CHUNK):
|
|
47
|
+
h.update(chunk)
|
|
48
|
+
return h.hexdigest()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class TreeDiff:
|
|
53
|
+
"""Set-based diff between two ``ModuleSnapshot`` instances."""
|
|
54
|
+
|
|
55
|
+
added: frozenset[str]
|
|
56
|
+
modified: frozenset[str]
|
|
57
|
+
removed: frozenset[str]
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def is_empty(self) -> bool:
|
|
61
|
+
return not (self.added or self.modified or self.removed)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def has_build_file_changes(self) -> bool:
|
|
65
|
+
"""True if any build file (pom.xml, build.gradle, …) changed."""
|
|
66
|
+
return any(Path(p).name in _BUILD_FILES for bucket in (self.added, self.modified, self.removed) for p in bucket)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def all_changed(self) -> frozenset[str]:
|
|
70
|
+
"""Union of added and modified relative paths (files present on disk)."""
|
|
71
|
+
return self.added | self.modified
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class ModuleSnapshot:
|
|
76
|
+
"""Snapshot of a Java module's tracked source files.
|
|
77
|
+
|
|
78
|
+
``root_hash`` is a BLAKE2b hash of all ``(relative_path, content_hash)``
|
|
79
|
+
pairs sorted by path. Two snapshots with the same ``root_hash`` are
|
|
80
|
+
identical; this enables an O(1) equality check before the O(N) per-file
|
|
81
|
+
diff.
|
|
82
|
+
|
|
83
|
+
``files`` maps each tracked relative path to the BLAKE2b hash of its
|
|
84
|
+
content at snapshot time.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
root_hash: str
|
|
88
|
+
files: dict[str, str] # relative path → content hash
|
|
89
|
+
|
|
90
|
+
# ------------------------------------------------------------------
|
|
91
|
+
# Construction
|
|
92
|
+
# ------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def build(cls, module_root: Path) -> ModuleSnapshot | None:
|
|
96
|
+
"""Scan *module_root* and return a snapshot, or ``None`` if too large.
|
|
97
|
+
|
|
98
|
+
Tracked files: ``*.java`` + ``pom.xml`` / ``build.gradle`` /
|
|
99
|
+
``build.gradle.kts``. Directories in ``_SKIP_DIRS`` (``target``,
|
|
100
|
+
``.git``, ``node_modules``, …) are pruned from the walk entirely.
|
|
101
|
+
Symlinks are skipped. Paths that resolve outside *module_root* are
|
|
102
|
+
rejected (defense-in-depth against directory traversal).
|
|
103
|
+
"""
|
|
104
|
+
files: dict[str, str] = {}
|
|
105
|
+
|
|
106
|
+
for file_path in _iter_tracked_files(module_root):
|
|
107
|
+
if len(files) >= _MAX_FILES:
|
|
108
|
+
logger.warning(
|
|
109
|
+
"merkle: module %s has ≥%d tracked files — skipping snapshot",
|
|
110
|
+
module_root,
|
|
111
|
+
_MAX_FILES,
|
|
112
|
+
)
|
|
113
|
+
return None
|
|
114
|
+
rel = str(file_path.relative_to(module_root))
|
|
115
|
+
try:
|
|
116
|
+
files[rel] = _blake2b_file(file_path)
|
|
117
|
+
except OSError as exc:
|
|
118
|
+
logger.debug("merkle: could not read %s: %s", file_path, exc)
|
|
119
|
+
|
|
120
|
+
# Build root hash incrementally — avoids allocating an O(N) intermediate string.
|
|
121
|
+
h = hashlib.blake2b(digest_size=32)
|
|
122
|
+
for p, fh in sorted(files.items()):
|
|
123
|
+
h.update(f"{p}:{fh}\n".encode())
|
|
124
|
+
return cls(root_hash=h.hexdigest(), files=files)
|
|
125
|
+
|
|
126
|
+
# ------------------------------------------------------------------
|
|
127
|
+
# Comparison
|
|
128
|
+
# ------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
def diff(self, newer: ModuleSnapshot) -> TreeDiff:
|
|
131
|
+
"""Return what changed between *self* (old) and *newer* (current)."""
|
|
132
|
+
old = self.files
|
|
133
|
+
new = newer.files
|
|
134
|
+
added = frozenset(new.keys() - old.keys())
|
|
135
|
+
removed = frozenset(old.keys() - new.keys())
|
|
136
|
+
modified = frozenset(p for p in old.keys() & new.keys() if old[p] != new[p])
|
|
137
|
+
return TreeDiff(added=added, modified=modified, removed=removed)
|
|
138
|
+
|
|
139
|
+
# ------------------------------------------------------------------
|
|
140
|
+
# Persistence
|
|
141
|
+
# ------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
def save(self, path: Path) -> None:
|
|
144
|
+
"""Atomically write snapshot to *path* (write to temp then rename).
|
|
145
|
+
|
|
146
|
+
The parent directory is created with mode ``0o700`` (owner-only) on
|
|
147
|
+
first use to prevent other local users from tampering with the cache.
|
|
148
|
+
"""
|
|
149
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
150
|
+
payload = json.dumps({"root": self.root_hash, "files": self.files}, separators=(",", ":"))
|
|
151
|
+
fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
|
|
152
|
+
try:
|
|
153
|
+
with os.fdopen(fd, "wb") as fh:
|
|
154
|
+
fh.write(payload.encode())
|
|
155
|
+
os.replace(tmp, path)
|
|
156
|
+
except Exception:
|
|
157
|
+
with contextlib.suppress(OSError):
|
|
158
|
+
os.unlink(tmp)
|
|
159
|
+
raise
|
|
160
|
+
|
|
161
|
+
@classmethod
|
|
162
|
+
def load(cls, path: Path) -> ModuleSnapshot | None:
|
|
163
|
+
"""Load a snapshot from *path*.
|
|
164
|
+
|
|
165
|
+
Returns ``None`` on missing file, JSON parse error, or invalid
|
|
166
|
+
content (path traversal attempts, oversized entries, wrong types).
|
|
167
|
+
Never raises.
|
|
168
|
+
"""
|
|
169
|
+
try:
|
|
170
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
171
|
+
root_hash = data["root"]
|
|
172
|
+
files = data["files"]
|
|
173
|
+
if not isinstance(root_hash, str) or not isinstance(files, dict):
|
|
174
|
+
return None
|
|
175
|
+
if len(files) > _MAX_SNAPSHOT_FILES:
|
|
176
|
+
return None
|
|
177
|
+
# Validate each entry: relative, no traversal, no nulls, str values.
|
|
178
|
+
valid = all(
|
|
179
|
+
isinstance(k, str)
|
|
180
|
+
and isinstance(v, str)
|
|
181
|
+
and "\x00" not in k
|
|
182
|
+
and not (p := Path(k)).is_absolute()
|
|
183
|
+
and ".." not in p.parts
|
|
184
|
+
for k, v in files.items()
|
|
185
|
+
)
|
|
186
|
+
return cls(root_hash=root_hash, files=files) if valid else None
|
|
187
|
+
except (OSError, KeyError, ValueError):
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ---------------------------------------------------------------------------
|
|
192
|
+
# Internal helpers
|
|
193
|
+
# ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _iter_tracked_files(module_root: Path) -> Iterator[Path]:
|
|
197
|
+
"""Yield ``.java`` and build files under *module_root*.
|
|
198
|
+
|
|
199
|
+
Uses ``os.walk`` with ``followlinks=False`` and prunes ``_SKIP_DIRS``
|
|
200
|
+
in-place so entire subtrees (``target/``, ``.git/``, …) are never
|
|
201
|
+
entered. Symlink files are skipped individually. Any path that resolves
|
|
202
|
+
outside *module_root* after symlink expansion is rejected.
|
|
203
|
+
"""
|
|
204
|
+
module_resolved = module_root.resolve()
|
|
205
|
+
|
|
206
|
+
for dirpath_str, dirnames, filenames in os.walk(module_root, followlinks=False):
|
|
207
|
+
# Prune skip dirs in-place to stop os.walk from descending into them.
|
|
208
|
+
dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS]
|
|
209
|
+
|
|
210
|
+
dir_path = Path(dirpath_str)
|
|
211
|
+
for fname in filenames:
|
|
212
|
+
if not (fname.endswith(".java") or fname in _BUILD_FILES):
|
|
213
|
+
continue
|
|
214
|
+
file_path = dir_path / fname
|
|
215
|
+
if file_path.is_symlink():
|
|
216
|
+
continue
|
|
217
|
+
# Reject any path whose resolved form escapes the module root.
|
|
218
|
+
try:
|
|
219
|
+
file_path.resolve().relative_to(module_resolved)
|
|
220
|
+
except ValueError:
|
|
221
|
+
continue
|
|
222
|
+
yield file_path
|
|
@@ -19,6 +19,8 @@ from functools import lru_cache
|
|
|
19
19
|
from pathlib import Path
|
|
20
20
|
from typing import Any
|
|
21
21
|
|
|
22
|
+
from .merkle import _BUILD_FILES, ModuleSnapshot, TreeDiff
|
|
23
|
+
|
|
22
24
|
logger = logging.getLogger(__name__)
|
|
23
25
|
|
|
24
26
|
REQUEST_TIMEOUT = 30.0 # seconds — per-request timeout for normal operations
|
|
@@ -317,7 +319,6 @@ async def read_message(reader: asyncio.StreamReader) -> dict[str, Any] | None:
|
|
|
317
319
|
return None
|
|
318
320
|
|
|
319
321
|
|
|
320
|
-
_BUILD_FILES = ("pom.xml", "build.gradle", "build.gradle.kts")
|
|
321
322
|
_WORKSPACE_DID_CHANGE_FOLDERS = "workspace/didChangeWorkspaceFolders"
|
|
322
323
|
_MAX_QUEUED_NOTIFICATIONS = 200
|
|
323
324
|
_MODULE_READY_TIMEOUT = 30.0
|
|
@@ -430,7 +431,7 @@ def _resolve_module_uri(file_uri: str) -> str | None:
|
|
|
430
431
|
if module_root is None:
|
|
431
432
|
return None
|
|
432
433
|
module_uri = from_fs_path(module_root)
|
|
433
|
-
return module_uri
|
|
434
|
+
return module_uri if module_uri else None
|
|
434
435
|
|
|
435
436
|
|
|
436
437
|
def _version_key(name: str) -> tuple[int, ...]:
|
|
@@ -444,6 +445,55 @@ def _version_key(name: str) -> tuple[int, ...]:
|
|
|
444
445
|
return tuple(parts)
|
|
445
446
|
|
|
446
447
|
|
|
448
|
+
@lru_cache(maxsize=256)
|
|
449
|
+
def _module_snapshot_path(module_uri: str) -> Path:
|
|
450
|
+
"""Return the path for the persistent snapshot file for *module_uri*.
|
|
451
|
+
|
|
452
|
+
Snapshots live under ``~/.cache/jdtls-snapshots/`` — separate from the
|
|
453
|
+
jdtls data-dir tree so they survive version-bump cache wipes.
|
|
454
|
+
Each module gets a 12-char BLAKE2b subdirectory (6-byte digest).
|
|
455
|
+
"""
|
|
456
|
+
h = hashlib.blake2b(module_uri.encode(), digest_size=6).hexdigest()
|
|
457
|
+
return Path.home() / ".cache" / "jdtls-snapshots" / h / ".snapshot.json"
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _compute_module_diff(
|
|
461
|
+
module_uri: str,
|
|
462
|
+
) -> tuple[TreeDiff | None, ModuleSnapshot] | None:
|
|
463
|
+
"""Blocking: build current snapshot and diff against stored one.
|
|
464
|
+
|
|
465
|
+
Returns ``(diff, current_snapshot)`` or ``None`` on error / size limit.
|
|
466
|
+
``diff`` is ``None`` when there is no stored snapshot (first session) or
|
|
467
|
+
the snapshots are identical — the caller should still save the snapshot in
|
|
468
|
+
both cases.
|
|
469
|
+
|
|
470
|
+
Intended to run inside a thread-pool executor.
|
|
471
|
+
"""
|
|
472
|
+
from pygls.uris import to_fs_path
|
|
473
|
+
|
|
474
|
+
module_fs = to_fs_path(module_uri)
|
|
475
|
+
if not module_fs:
|
|
476
|
+
return None
|
|
477
|
+
module_root = Path(module_fs)
|
|
478
|
+
if not module_root.is_dir():
|
|
479
|
+
return None
|
|
480
|
+
|
|
481
|
+
current = ModuleSnapshot.build(module_root)
|
|
482
|
+
if current is None:
|
|
483
|
+
return None # file count exceeded limit
|
|
484
|
+
|
|
485
|
+
snapshot_path = _module_snapshot_path(module_uri)
|
|
486
|
+
stored = ModuleSnapshot.load(snapshot_path)
|
|
487
|
+
|
|
488
|
+
if stored is None:
|
|
489
|
+
# First session: no diff to compute — caller (_apply_module_diff) will
|
|
490
|
+
# save the baseline snapshot after the module reaches READY.
|
|
491
|
+
return (None, current)
|
|
492
|
+
|
|
493
|
+
diff = stored.diff(current)
|
|
494
|
+
return (diff, current)
|
|
495
|
+
|
|
496
|
+
|
|
447
497
|
def _clear_cache_on_version_change(cache_root: Path) -> None:
|
|
448
498
|
"""Clear jdtls data cache when the server version changes.
|
|
449
499
|
|
|
@@ -534,6 +584,16 @@ class JdtlsProxy:
|
|
|
534
584
|
self.modules = ModuleRegistry()
|
|
535
585
|
self.has_lombok = False
|
|
536
586
|
self._workspace_expanded = False
|
|
587
|
+
# Merkle snapshot diff: module_uri → (diff | None, current_snapshot)
|
|
588
|
+
# Populated by _kick_module_diff (background task on add_module_if_new).
|
|
589
|
+
# Consumed by server._apply_module_diff after module reaches READY.
|
|
590
|
+
self._module_diff_results: dict[str, tuple[TreeDiff | None, ModuleSnapshot]] = {}
|
|
591
|
+
# Task references for in-flight diff computations, keyed by module_uri.
|
|
592
|
+
# Allows _apply_module_diff to await the task directly if READY fires
|
|
593
|
+
# before _kick_module_diff stores its result.
|
|
594
|
+
self._pending_diff_tasks: dict[str, asyncio.Task[None]] = {}
|
|
595
|
+
# Background tasks created by this proxy (prevents GC of fire-and-forget tasks).
|
|
596
|
+
self._proxy_bg_tasks: set[asyncio.Task[Any]] = set()
|
|
537
597
|
|
|
538
598
|
@property
|
|
539
599
|
def is_available(self) -> bool:
|
|
@@ -717,6 +777,11 @@ class JdtlsProxy:
|
|
|
717
777
|
Uses a ``deque(maxlen=200)`` so oldest entries are dropped in O(1)
|
|
718
778
|
when the queue overflows during long jdtls startup.
|
|
719
779
|
"""
|
|
780
|
+
if len(self._queued_notifications) >= _MAX_QUEUED_NOTIFICATIONS:
|
|
781
|
+
logger.warning(
|
|
782
|
+
"jdtls: notification queue full (%d cap) — oldest entry dropped; some didOpen events may be missed",
|
|
783
|
+
_MAX_QUEUED_NOTIFICATIONS,
|
|
784
|
+
)
|
|
720
785
|
self._queued_notifications.append((method, params))
|
|
721
786
|
|
|
722
787
|
async def flush_queued_notifications(self) -> None:
|
|
@@ -726,6 +791,42 @@ class JdtlsProxy:
|
|
|
726
791
|
for method, params in queue:
|
|
727
792
|
await self.send_notification(method, params)
|
|
728
793
|
|
|
794
|
+
async def _kick_module_diff(self, module_uri: str) -> None:
|
|
795
|
+
"""Background task: compute snapshot diff and stash it for later use.
|
|
796
|
+
|
|
797
|
+
Result is stored in ``_module_diff_results`` BEFORE the task is removed
|
|
798
|
+
from ``_pending_diff_tasks`` to eliminate the TOCTOU window where
|
|
799
|
+
``await_module_diff`` could find neither a running task nor a result.
|
|
800
|
+
"""
|
|
801
|
+
loop = asyncio.get_running_loop()
|
|
802
|
+
result = await loop.run_in_executor(None, _compute_module_diff, module_uri)
|
|
803
|
+
# Store result first, then remove from pending — preserves invariant that
|
|
804
|
+
# once the task is gone from _pending_diff_tasks the result is already readable.
|
|
805
|
+
if result is not None:
|
|
806
|
+
self._module_diff_results[module_uri] = result
|
|
807
|
+
self._pending_diff_tasks.pop(module_uri, None)
|
|
808
|
+
|
|
809
|
+
def pop_module_data(self, module_uri: str) -> tuple[TreeDiff | None, ModuleSnapshot] | None:
|
|
810
|
+
"""Pop and return stashed ``(diff, snapshot)`` for *module_uri*, or ``None``."""
|
|
811
|
+
return self._module_diff_results.pop(module_uri, None)
|
|
812
|
+
|
|
813
|
+
async def await_module_diff(self, module_uri: str) -> None:
|
|
814
|
+
"""Wait for the in-flight diff task for *module_uri* to complete, if any.
|
|
815
|
+
|
|
816
|
+
Called when the READY signal fires before ``_kick_module_diff`` has
|
|
817
|
+
stored its result — awaiting the task directly avoids a time-based
|
|
818
|
+
sleep and ensures the diff is never silently dropped.
|
|
819
|
+
|
|
820
|
+
Swallows ``CancelledError`` so that a ``stop()`` call during the wait
|
|
821
|
+
does not propagate and crash the caller.
|
|
822
|
+
"""
|
|
823
|
+
task = self._pending_diff_tasks.pop(module_uri, None)
|
|
824
|
+
if task is not None and not task.done():
|
|
825
|
+
try:
|
|
826
|
+
await task
|
|
827
|
+
except (asyncio.CancelledError, Exception):
|
|
828
|
+
pass # result storage is _kick_module_diff's responsibility
|
|
829
|
+
|
|
729
830
|
async def add_module_if_new(self, file_uri: str) -> str | None:
|
|
730
831
|
"""Add the module containing *file_uri* to jdtls if not already added.
|
|
731
832
|
|
|
@@ -736,7 +837,7 @@ class JdtlsProxy:
|
|
|
736
837
|
Calls ``modules.mark_added()`` before any ``await`` to prevent
|
|
737
838
|
duplicate add calls from concurrent coroutines.
|
|
738
839
|
"""
|
|
739
|
-
if not self._available:
|
|
840
|
+
if not self._available or self._workspace_expanded:
|
|
740
841
|
return None
|
|
741
842
|
module_uri = _resolve_module_uri(file_uri)
|
|
742
843
|
if module_uri is None or self.modules.was_added(module_uri):
|
|
@@ -751,6 +852,13 @@ class JdtlsProxy:
|
|
|
751
852
|
_WORKSPACE_DID_CHANGE_FOLDERS,
|
|
752
853
|
{"event": {"added": [{"uri": module_uri, "name": mod_name}], "removed": []}},
|
|
753
854
|
)
|
|
855
|
+
# Fire-and-forget: compute snapshot diff in background so it is ready
|
|
856
|
+
# when the module reaches READY state. Does NOT block the jdtls
|
|
857
|
+
# readiness timeout (which starts ticking at mark_added above).
|
|
858
|
+
diff_task = asyncio.create_task(self._kick_module_diff(module_uri))
|
|
859
|
+
self._proxy_bg_tasks.add(diff_task)
|
|
860
|
+
diff_task.add_done_callback(self._proxy_bg_tasks.discard)
|
|
861
|
+
self._pending_diff_tasks[module_uri] = diff_task
|
|
754
862
|
return module_uri
|
|
755
863
|
|
|
756
864
|
async def expand_full_workspace(self) -> None:
|
|
@@ -770,11 +878,14 @@ class JdtlsProxy:
|
|
|
770
878
|
return
|
|
771
879
|
self.modules.mark_added(root_uri)
|
|
772
880
|
|
|
773
|
-
# Remove
|
|
881
|
+
# Remove ALL individually-added modules to prevent double-indexing with
|
|
882
|
+
# the full root. This covers both the initial scoped module and any
|
|
883
|
+
# modules added by add_module_if_new during the ensure_started window.
|
|
774
884
|
removed: list[dict[str, str]] = []
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
885
|
+
for uri in list(self.modules._states):
|
|
886
|
+
if uri != root_uri:
|
|
887
|
+
p = to_fs_path(uri) or uri
|
|
888
|
+
removed.append({"uri": uri, "name": Path(p).name})
|
|
778
889
|
|
|
779
890
|
logger.info("jdtls: expanding to full workspace %s", _redact_path(root_path))
|
|
780
891
|
await self.send_notification(
|
|
@@ -792,6 +903,12 @@ class JdtlsProxy:
|
|
|
792
903
|
if self._stderr_task and not self._stderr_task.done():
|
|
793
904
|
self._stderr_task.cancel()
|
|
794
905
|
|
|
906
|
+
for task in list(self._proxy_bg_tasks):
|
|
907
|
+
task.cancel()
|
|
908
|
+
self._proxy_bg_tasks.clear()
|
|
909
|
+
self._module_diff_results.clear()
|
|
910
|
+
self._pending_diff_tasks.clear()
|
|
911
|
+
|
|
795
912
|
if self._process and self._process.returncode is None:
|
|
796
913
|
try:
|
|
797
914
|
await self.send_request("shutdown", None, timeout=5.0)
|
|
@@ -12,6 +12,7 @@ import logging
|
|
|
12
12
|
import os
|
|
13
13
|
import re
|
|
14
14
|
import sys
|
|
15
|
+
from collections.abc import Coroutine
|
|
15
16
|
from pathlib import Path
|
|
16
17
|
from typing import Any
|
|
17
18
|
|
|
@@ -28,7 +29,7 @@ from .analyzers.mutation_checker import MutationChecker
|
|
|
28
29
|
from .analyzers.null_checker import NullChecker
|
|
29
30
|
from .analyzers.spring_checker import SpringChecker
|
|
30
31
|
from .fixes import get_fix, get_fix_registry_keys
|
|
31
|
-
from .proxy import JdtlsProxy, _resolve_module_uri
|
|
32
|
+
from .proxy import JdtlsProxy, _module_snapshot_path, _resolve_module_uri
|
|
32
33
|
|
|
33
34
|
logger = logging.getLogger(__name__)
|
|
34
35
|
|
|
@@ -75,20 +76,30 @@ class JavaFunctionalLspServer(LanguageServer):
|
|
|
75
76
|
self._skip_jdtls_registration: bool = False
|
|
76
77
|
self._init_generation: int = 0
|
|
77
78
|
|
|
78
|
-
def _on_jdtls_diagnostics(self, uri: str,
|
|
79
|
+
def _on_jdtls_diagnostics(self, uri: str, _diagnostics: list[Any]) -> None:
|
|
79
80
|
"""Called when jdtls publishes diagnostics — merge with custom and re-publish.
|
|
80
81
|
|
|
81
82
|
Also marks the file's module as READY, since receiving diagnostics from
|
|
82
83
|
jdtls is a reliable signal that the module has been indexed (more reliable
|
|
83
84
|
than a first non-None response which may be semantically empty).
|
|
85
|
+
|
|
86
|
+
When a module transitions to READY, fires ``_apply_module_diff`` to
|
|
87
|
+
notify jdtls about any externally changed files (e.g., after git pull).
|
|
84
88
|
"""
|
|
85
89
|
if not uri.endswith(".java"):
|
|
86
90
|
return
|
|
87
91
|
module_uri = _resolve_module_uri(uri)
|
|
88
92
|
if module_uri:
|
|
93
|
+
already_ready = self._proxy.modules.is_ready(module_uri)
|
|
89
94
|
self._proxy.modules.mark_ready(module_uri)
|
|
95
|
+
if not already_ready:
|
|
96
|
+
# First READY transition only — subsequent diagnostics for the same
|
|
97
|
+
# module are no-ops to avoid spawning O(files) redundant tasks.
|
|
98
|
+
_fire_and_forget(_apply_module_diff(self._proxy, module_uri))
|
|
90
99
|
try:
|
|
91
100
|
_analyze_and_publish(uri)
|
|
101
|
+
except FileNotFoundError:
|
|
102
|
+
pass # Virtual jdtls file (e.g., decompiled source in jdtls-data); skip silently.
|
|
92
103
|
except Exception as e:
|
|
93
104
|
logger.error("Error re-publishing diagnostics for %s: %s", uri, e)
|
|
94
105
|
|
|
@@ -102,6 +113,72 @@ _bg_tasks: set[asyncio.Task[None]] = set()
|
|
|
102
113
|
_DEBOUNCE_SECONDS = 0.15
|
|
103
114
|
|
|
104
115
|
|
|
116
|
+
def _fire_and_forget(coro: Coroutine[Any, Any, Any]) -> None:
|
|
117
|
+
"""Schedule *coro* as a background task, preventing GC until it finishes."""
|
|
118
|
+
task = asyncio.create_task(coro)
|
|
119
|
+
_bg_tasks.add(task)
|
|
120
|
+
task.add_done_callback(_bg_tasks.discard)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def _apply_module_diff(proxy: JdtlsProxy, module_uri: str) -> None:
|
|
124
|
+
"""Send ``workspace/didChangeWatchedFiles`` for externally changed files.
|
|
125
|
+
|
|
126
|
+
Called (fire-and-forget) when a module reaches READY state. Pops the
|
|
127
|
+
``(diff, snapshot)`` pair computed by ``_kick_module_diff`` during module
|
|
128
|
+
registration, notifies jdtls about changed files so it can do an
|
|
129
|
+
incremental rebuild, then persists the updated snapshot to disk.
|
|
130
|
+
|
|
131
|
+
No-ops if the diff computation is not yet finished or found no changes.
|
|
132
|
+
"""
|
|
133
|
+
data = proxy.pop_module_data(module_uri)
|
|
134
|
+
if data is None:
|
|
135
|
+
# Race guard: READY may fire before _kick_module_diff finishes.
|
|
136
|
+
# Await the in-flight task directly (no arbitrary sleep).
|
|
137
|
+
await proxy.await_module_diff(module_uri)
|
|
138
|
+
data = proxy.pop_module_data(module_uri)
|
|
139
|
+
if data is None:
|
|
140
|
+
return
|
|
141
|
+
diff, current_snapshot = data
|
|
142
|
+
|
|
143
|
+
if diff is not None and not diff.is_empty:
|
|
144
|
+
from pygls.uris import from_fs_path, to_fs_path
|
|
145
|
+
|
|
146
|
+
module_fs = to_fs_path(module_uri) or ""
|
|
147
|
+
if module_fs:
|
|
148
|
+
module_path = Path(module_fs)
|
|
149
|
+
changes = (
|
|
150
|
+
[
|
|
151
|
+
{"uri": u, "type": lsp.FileChangeType.Created}
|
|
152
|
+
for rel in diff.added
|
|
153
|
+
if (u := from_fs_path(str(module_path / rel)))
|
|
154
|
+
]
|
|
155
|
+
+ [
|
|
156
|
+
{"uri": u, "type": lsp.FileChangeType.Changed}
|
|
157
|
+
for rel in diff.modified
|
|
158
|
+
if (u := from_fs_path(str(module_path / rel)))
|
|
159
|
+
]
|
|
160
|
+
+ [
|
|
161
|
+
{"uri": u, "type": lsp.FileChangeType.Deleted}
|
|
162
|
+
for rel in diff.removed
|
|
163
|
+
if (u := from_fs_path(str(module_path / rel)))
|
|
164
|
+
]
|
|
165
|
+
)
|
|
166
|
+
if changes:
|
|
167
|
+
await proxy.send_notification("workspace/didChangeWatchedFiles", {"changes": changes})
|
|
168
|
+
logger.info(
|
|
169
|
+
"merkle: notified jdtls of %d externally changed file(s) in module",
|
|
170
|
+
len(changes),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# Always persist the current snapshot so next session has an up-to-date baseline.
|
|
174
|
+
snapshot_path = _module_snapshot_path(module_uri)
|
|
175
|
+
loop = asyncio.get_running_loop()
|
|
176
|
+
try:
|
|
177
|
+
await loop.run_in_executor(None, current_snapshot.save, snapshot_path)
|
|
178
|
+
except OSError as exc:
|
|
179
|
+
logger.warning("merkle: could not save updated snapshot: %s", exc)
|
|
180
|
+
|
|
181
|
+
|
|
105
182
|
def _handle_exception(exc_type: type[BaseException], exc_value: BaseException, exc_tb: Any) -> None:
|
|
106
183
|
"""Log uncaught exceptions for crash debugging."""
|
|
107
184
|
logger.error("Uncaught exception", exc_info=(exc_type, exc_value, exc_tb))
|
|
@@ -342,7 +419,7 @@ def on_initialize(params: lsp.InitializeParams) -> lsp.InitializeResult:
|
|
|
342
419
|
|
|
343
420
|
|
|
344
421
|
@server.feature(lsp.INITIALIZED)
|
|
345
|
-
async def on_initialized(
|
|
422
|
+
async def on_initialized(_params: lsp.InitializedParams) -> None:
|
|
346
423
|
"""Check jdtls availability; actual start deferred to first didOpen."""
|
|
347
424
|
logger.info(
|
|
348
425
|
"java-functional-lsp initialized (rules: %s)",
|
|
@@ -451,9 +528,7 @@ def _forward_or_queue(method: str, serialized: Any) -> None:
|
|
|
451
528
|
if server._skip_jdtls:
|
|
452
529
|
return
|
|
453
530
|
if server._proxy.is_available:
|
|
454
|
-
|
|
455
|
-
_bg_tasks.add(task)
|
|
456
|
-
task.add_done_callback(_bg_tasks.discard)
|
|
531
|
+
_fire_and_forget(server._proxy.send_notification(method, serialized))
|
|
457
532
|
elif server._proxy._lazy_start_fired and not server._proxy._start_failed:
|
|
458
533
|
server._proxy.queue_notification(method, serialized)
|
|
459
534
|
|
|
@@ -483,9 +558,7 @@ async def on_did_open(params: lsp.DidOpenTextDocumentParams) -> None:
|
|
|
483
558
|
if not server._proxy._lazy_start_fired:
|
|
484
559
|
# First file: kick off lazy start in background.
|
|
485
560
|
server._proxy._lazy_start_fired = True
|
|
486
|
-
|
|
487
|
-
_bg_tasks.add(task)
|
|
488
|
-
task.add_done_callback(_bg_tasks.discard)
|
|
561
|
+
_fire_and_forget(_lazy_start_jdtls(uri))
|
|
489
562
|
|
|
490
563
|
# Custom diagnostics always publish immediately — never blocked by jdtls.
|
|
491
564
|
try:
|
|
@@ -531,14 +604,21 @@ async def _lazy_start_jdtls(file_uri: str) -> None:
|
|
|
531
604
|
"""Background task: start jdtls scoped to the module containing *file_uri*.
|
|
532
605
|
|
|
533
606
|
Runs in the background so ``on_did_open`` returns immediately with custom
|
|
534
|
-
diagnostics.
|
|
535
|
-
|
|
536
|
-
|
|
607
|
+
diagnostics. After jdtls initializes, the workspace is immediately expanded
|
|
608
|
+
to the full IDE workspace root (inside-out) so jdtls discovers all sibling
|
|
609
|
+
modules before any queued ``didOpen`` notifications are flushed. This means
|
|
610
|
+
``add_module_if_new`` is a no-op for all subsequent file opens — jdtls
|
|
611
|
+
already covers the whole project.
|
|
537
612
|
"""
|
|
538
613
|
try:
|
|
539
614
|
started = await server._proxy.ensure_started(server._init_params, file_uri, config=server._config)
|
|
540
615
|
if started:
|
|
541
616
|
logger.info("jdtls proxy active — full Java language support enabled")
|
|
617
|
+
# Expand to full workspace BEFORE flushing queued notifications so
|
|
618
|
+
# all files are processed in the full-workspace context (inside-out).
|
|
619
|
+
# expand_full_workspace is a no-op for standalone projects where
|
|
620
|
+
# the workspace root equals the initial module.
|
|
621
|
+
await server._proxy.expand_full_workspace()
|
|
542
622
|
if server._skip_jdtls_registration:
|
|
543
623
|
logger.info("Skipping dynamic capability registration (no-register mode)")
|
|
544
624
|
else:
|
|
@@ -548,20 +628,6 @@ async def _lazy_start_jdtls(file_uri: str) -> None:
|
|
|
548
628
|
logger.warning("jdtls lazy start failed", exc_info=True)
|
|
549
629
|
|
|
550
630
|
|
|
551
|
-
async def _expand_workspace_background() -> None:
|
|
552
|
-
"""Background task: expand jdtls workspace to full monorepo root.
|
|
553
|
-
|
|
554
|
-
Runs after jdtls finishes initializing with the first module scope.
|
|
555
|
-
The user's actively-opened modules are loaded immediately via
|
|
556
|
-
``add_module_if_new()`` in ``on_did_open``; this adds the full root
|
|
557
|
-
so cross-module references for unopened files also work.
|
|
558
|
-
"""
|
|
559
|
-
try:
|
|
560
|
-
await server._proxy.expand_full_workspace()
|
|
561
|
-
except Exception:
|
|
562
|
-
logger.warning("Failed to expand jdtls workspace", exc_info=True)
|
|
563
|
-
|
|
564
|
-
|
|
565
631
|
# --- jdtls passthrough handlers (registered dynamically, NOT at module level) ---
|
|
566
632
|
#
|
|
567
633
|
# These are NOT decorated with @server.feature because pygls auto-advertises
|