java-functional-lsp 0.8.0__tar.gz → 0.9.0__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.0 → java_functional_lsp-0.9.0}/PKG-INFO +3 -1
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/README.md +2 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/editors/intellij/README.md +2 -1
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/pyproject.toml +1 -1
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/src/java_functional_lsp/__init__.py +1 -1
- java_functional_lsp-0.9.0/src/java_functional_lsp/merkle.py +222 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/src/java_functional_lsp/proxy.py +111 -2
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/src/java_functional_lsp/server.py +151 -26
- java_functional_lsp-0.9.0/tests/test_merkle.py +314 -0
- java_functional_lsp-0.9.0/tests/test_merkle_proxy.py +366 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/tests/test_server.py +94 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/uv.lock +4 -4
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/.claude-plugin/plugin.json +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/.githooks/pre-commit +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/.githooks/pre-push +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/.github/CODEOWNERS +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/.github/ISSUE_TEMPLATE/bug-report.md +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/.github/ISSUE_TEMPLATE/feature-request.md +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/.github/SECURITY.md +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/.github/dependabot.yml +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/.github/release-drafter.yml +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/.github/workflows/publish.yml +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/.github/workflows/release-drafter.yml +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/.github/workflows/stale.yml +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/.github/workflows/test.yml +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/.github/workflows/update-homebrew.yml +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/.github/workflows/vscode-ext.yml +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/.gitignore +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/CONTRIBUTING.md +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/LICENSE +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/SKILL.md +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/commands/lint-java.md +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/editors/intellij/lsp4ij-template.json +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/editors/vscode/.vscodeignore +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/editors/vscode/README.md +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/editors/vscode/package-lock.json +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/editors/vscode/package.json +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/editors/vscode/src/extension.ts +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/editors/vscode/tsconfig.json +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/hooks/hooks.json +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/hooks/java_linter_reminder.py +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/scripts/ensure-lsp.sh +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/scripts/generate-formula.py +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/src/java_functional_lsp/__main__.py +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/src/java_functional_lsp/analyzers/__init__.py +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/src/java_functional_lsp/analyzers/base.py +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/src/java_functional_lsp/analyzers/exception_checker.py +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/src/java_functional_lsp/analyzers/functional_checker.py +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/src/java_functional_lsp/analyzers/mutation_checker.py +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/src/java_functional_lsp/analyzers/null_checker.py +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/src/java_functional_lsp/analyzers/spring_checker.py +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/src/java_functional_lsp/cli.py +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/src/java_functional_lsp/fixes.py +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/tests/__init__.py +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/tests/conftest.py +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/tests/test_base.py +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/tests/test_cli.py +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/tests/test_config.py +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/tests/test_e2e.py +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/tests/test_e2e_jdtls.py +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/tests/test_exception_checker.py +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/tests/test_fixes.py +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/tests/test_functional_checker.py +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/tests/test_mutation_checker.py +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/tests/test_null_checker.py +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/tests/test_proxy.py +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/tests/test_spring_checker.py +0 -0
- {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/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.0
|
|
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
|
|
@@ -128,6 +128,8 @@ Use the [LSP4IJ](https://github.com/redhat-developer/lsp4ij) plugin (works on Co
|
|
|
128
128
|
2. **Settings** → **Languages & Frameworks** → **Language Servers** → **`+`**
|
|
129
129
|
3. Set **Command**: `java-functional-lsp`, then in **Mappings** → **File name patterns** add `*.java` with Language Id `java`
|
|
130
130
|
|
|
131
|
+
The server automatically detects JetBrains IDEs and disables the jdtls proxy (IntelliJ provides native Java support). To force-enable jdtls, set `JAVA_FUNCTIONAL_LSP_JDTLS=on` in the server command environment.
|
|
132
|
+
|
|
131
133
|
See [editors/intellij/README.md](editors/intellij/README.md) for detailed instructions.
|
|
132
134
|
|
|
133
135
|
### Claude Code
|
|
@@ -100,6 +100,8 @@ Use the [LSP4IJ](https://github.com/redhat-developer/lsp4ij) plugin (works on Co
|
|
|
100
100
|
2. **Settings** → **Languages & Frameworks** → **Language Servers** → **`+`**
|
|
101
101
|
3. Set **Command**: `java-functional-lsp`, then in **Mappings** → **File name patterns** add `*.java` with Language Id `java`
|
|
102
102
|
|
|
103
|
+
The server automatically detects JetBrains IDEs and disables the jdtls proxy (IntelliJ provides native Java support). To force-enable jdtls, set `JAVA_FUNCTIONAL_LSP_JDTLS=on` in the server command environment.
|
|
104
|
+
|
|
103
105
|
See [editors/intellij/README.md](editors/intellij/README.md) for detailed instructions.
|
|
104
106
|
|
|
105
107
|
### Claude Code
|
|
@@ -73,7 +73,7 @@ Project-level rules are configured via `.java-functional-lsp.json` in your proje
|
|
|
73
73
|
|
|
74
74
|
## Coexistence with IntelliJ's Java Support
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
The server automatically detects JetBrains IDEs and disables the jdtls proxy — IntelliJ provides its own Java language features (completions, hover, go-to-definition, compile errors). Only the 16 custom functional programming rules run, and they appear alongside IntelliJ's built-in inspections.
|
|
77
77
|
|
|
78
78
|
## Troubleshooting
|
|
79
79
|
|
|
@@ -85,6 +85,7 @@ LSP4IJ is designed to **supplement** IntelliJ's native Java support, not replace
|
|
|
85
85
|
- Ensure the file mapping is set to Language: `Java`, Language ID: `java`
|
|
86
86
|
- Check that `.java-functional-lsp.json` doesn't have rules set to `"off"`
|
|
87
87
|
- Try restarting the language server: **Tools** → **Language Servers** → **Restart**
|
|
88
|
+
- If diagnostics worked before but stopped, the jdtls proxy may be interfering. Set `JAVA_FUNCTIONAL_LSP_JDTLS=off` in the server command environment to force-disable jdtls. The server auto-detects JetBrains IDEs and disables jdtls by default (since IntelliJ provides native Java support), but this can be overridden with `JAVA_FUNCTIONAL_LSP_JDTLS=on`.
|
|
88
89
|
|
|
89
90
|
### PATH not found
|
|
90
91
|
- IntelliJ may not inherit your shell's PATH. Use the full absolute path to the binary in the server command.
|
|
@@ -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.0"
|
|
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:
|
|
@@ -726,6 +786,42 @@ class JdtlsProxy:
|
|
|
726
786
|
for method, params in queue:
|
|
727
787
|
await self.send_notification(method, params)
|
|
728
788
|
|
|
789
|
+
async def _kick_module_diff(self, module_uri: str) -> None:
|
|
790
|
+
"""Background task: compute snapshot diff and stash it for later use.
|
|
791
|
+
|
|
792
|
+
Result is stored in ``_module_diff_results`` BEFORE the task is removed
|
|
793
|
+
from ``_pending_diff_tasks`` to eliminate the TOCTOU window where
|
|
794
|
+
``await_module_diff`` could find neither a running task nor a result.
|
|
795
|
+
"""
|
|
796
|
+
loop = asyncio.get_running_loop()
|
|
797
|
+
result = await loop.run_in_executor(None, _compute_module_diff, module_uri)
|
|
798
|
+
# Store result first, then remove from pending — preserves invariant that
|
|
799
|
+
# once the task is gone from _pending_diff_tasks the result is already readable.
|
|
800
|
+
if result is not None:
|
|
801
|
+
self._module_diff_results[module_uri] = result
|
|
802
|
+
self._pending_diff_tasks.pop(module_uri, None)
|
|
803
|
+
|
|
804
|
+
def pop_module_data(self, module_uri: str) -> tuple[TreeDiff | None, ModuleSnapshot] | None:
|
|
805
|
+
"""Pop and return stashed ``(diff, snapshot)`` for *module_uri*, or ``None``."""
|
|
806
|
+
return self._module_diff_results.pop(module_uri, None)
|
|
807
|
+
|
|
808
|
+
async def await_module_diff(self, module_uri: str) -> None:
|
|
809
|
+
"""Wait for the in-flight diff task for *module_uri* to complete, if any.
|
|
810
|
+
|
|
811
|
+
Called when the READY signal fires before ``_kick_module_diff`` has
|
|
812
|
+
stored its result — awaiting the task directly avoids a time-based
|
|
813
|
+
sleep and ensures the diff is never silently dropped.
|
|
814
|
+
|
|
815
|
+
Swallows ``CancelledError`` so that a ``stop()`` call during the wait
|
|
816
|
+
does not propagate and crash the caller.
|
|
817
|
+
"""
|
|
818
|
+
task = self._pending_diff_tasks.pop(module_uri, None)
|
|
819
|
+
if task is not None and not task.done():
|
|
820
|
+
try:
|
|
821
|
+
await task
|
|
822
|
+
except (asyncio.CancelledError, Exception):
|
|
823
|
+
pass # result storage is _kick_module_diff's responsibility
|
|
824
|
+
|
|
729
825
|
async def add_module_if_new(self, file_uri: str) -> str | None:
|
|
730
826
|
"""Add the module containing *file_uri* to jdtls if not already added.
|
|
731
827
|
|
|
@@ -751,6 +847,13 @@ class JdtlsProxy:
|
|
|
751
847
|
_WORKSPACE_DID_CHANGE_FOLDERS,
|
|
752
848
|
{"event": {"added": [{"uri": module_uri, "name": mod_name}], "removed": []}},
|
|
753
849
|
)
|
|
850
|
+
# Fire-and-forget: compute snapshot diff in background so it is ready
|
|
851
|
+
# when the module reaches READY state. Does NOT block the jdtls
|
|
852
|
+
# readiness timeout (which starts ticking at mark_added above).
|
|
853
|
+
diff_task = asyncio.create_task(self._kick_module_diff(module_uri))
|
|
854
|
+
self._proxy_bg_tasks.add(diff_task)
|
|
855
|
+
diff_task.add_done_callback(self._proxy_bg_tasks.discard)
|
|
856
|
+
self._pending_diff_tasks[module_uri] = diff_task
|
|
754
857
|
return module_uri
|
|
755
858
|
|
|
756
859
|
async def expand_full_workspace(self) -> None:
|
|
@@ -792,6 +895,12 @@ class JdtlsProxy:
|
|
|
792
895
|
if self._stderr_task and not self._stderr_task.done():
|
|
793
896
|
self._stderr_task.cancel()
|
|
794
897
|
|
|
898
|
+
for task in list(self._proxy_bg_tasks):
|
|
899
|
+
task.cancel()
|
|
900
|
+
self._proxy_bg_tasks.clear()
|
|
901
|
+
self._module_diff_results.clear()
|
|
902
|
+
self._pending_diff_tasks.clear()
|
|
903
|
+
|
|
795
904
|
if self._process and self._process.returncode is None:
|
|
796
905
|
try:
|
|
797
906
|
await self.send_request("shutdown", None, timeout=5.0)
|