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.
Files changed (69) hide show
  1. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/PKG-INFO +3 -1
  2. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/README.md +2 -0
  3. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/editors/intellij/README.md +2 -1
  4. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/pyproject.toml +1 -1
  5. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/src/java_functional_lsp/__init__.py +1 -1
  6. java_functional_lsp-0.9.0/src/java_functional_lsp/merkle.py +222 -0
  7. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/src/java_functional_lsp/proxy.py +111 -2
  8. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/src/java_functional_lsp/server.py +151 -26
  9. java_functional_lsp-0.9.0/tests/test_merkle.py +314 -0
  10. java_functional_lsp-0.9.0/tests/test_merkle_proxy.py +366 -0
  11. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/tests/test_server.py +94 -0
  12. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/uv.lock +4 -4
  13. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/.claude-plugin/plugin.json +0 -0
  14. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/.githooks/pre-commit +0 -0
  15. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/.githooks/pre-push +0 -0
  16. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/.github/CODEOWNERS +0 -0
  17. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/.github/ISSUE_TEMPLATE/bug-report.md +0 -0
  18. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/.github/ISSUE_TEMPLATE/feature-request.md +0 -0
  19. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  20. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/.github/SECURITY.md +0 -0
  21. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/.github/dependabot.yml +0 -0
  22. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/.github/release-drafter.yml +0 -0
  23. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/.github/workflows/publish.yml +0 -0
  24. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/.github/workflows/release-drafter.yml +0 -0
  25. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/.github/workflows/stale.yml +0 -0
  26. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/.github/workflows/test.yml +0 -0
  27. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/.github/workflows/update-homebrew.yml +0 -0
  28. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/.github/workflows/vscode-ext.yml +0 -0
  29. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/.gitignore +0 -0
  30. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/CONTRIBUTING.md +0 -0
  31. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/LICENSE +0 -0
  32. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/SKILL.md +0 -0
  33. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/commands/lint-java.md +0 -0
  34. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/editors/intellij/lsp4ij-template.json +0 -0
  35. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/editors/vscode/.vscodeignore +0 -0
  36. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/editors/vscode/README.md +0 -0
  37. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/editors/vscode/package-lock.json +0 -0
  38. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/editors/vscode/package.json +0 -0
  39. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/editors/vscode/src/extension.ts +0 -0
  40. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/editors/vscode/tsconfig.json +0 -0
  41. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/hooks/hooks.json +0 -0
  42. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/hooks/java_linter_reminder.py +0 -0
  43. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/scripts/ensure-lsp.sh +0 -0
  44. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/scripts/generate-formula.py +0 -0
  45. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/src/java_functional_lsp/__main__.py +0 -0
  46. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/src/java_functional_lsp/analyzers/__init__.py +0 -0
  47. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/src/java_functional_lsp/analyzers/base.py +0 -0
  48. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/src/java_functional_lsp/analyzers/exception_checker.py +0 -0
  49. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/src/java_functional_lsp/analyzers/functional_checker.py +0 -0
  50. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/src/java_functional_lsp/analyzers/mutation_checker.py +0 -0
  51. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/src/java_functional_lsp/analyzers/null_checker.py +0 -0
  52. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/src/java_functional_lsp/analyzers/spring_checker.py +0 -0
  53. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/src/java_functional_lsp/cli.py +0 -0
  54. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/src/java_functional_lsp/fixes.py +0 -0
  55. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/tests/__init__.py +0 -0
  56. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/tests/conftest.py +0 -0
  57. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/tests/test_base.py +0 -0
  58. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/tests/test_cli.py +0 -0
  59. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/tests/test_config.py +0 -0
  60. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/tests/test_e2e.py +0 -0
  61. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/tests/test_e2e_jdtls.py +0 -0
  62. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/tests/test_exception_checker.py +0 -0
  63. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/tests/test_fixes.py +0 -0
  64. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/tests/test_functional_checker.py +0 -0
  65. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/tests/test_mutation_checker.py +0 -0
  66. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/tests/test_null_checker.py +0 -0
  67. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/tests/test_proxy.py +0 -0
  68. {java_functional_lsp-0.8.0 → java_functional_lsp-0.9.0}/tests/test_spring_checker.py +0 -0
  69. {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.8.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
- LSP4IJ is designed to **supplement** IntelliJ's native Java support, not replace it. Your custom functional programming diagnostics appear alongside IntelliJ's built-in inspections. No conflicts.
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.8.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" }
@@ -1,3 +1,3 @@
1
1
  """java-functional-lsp: A Java LSP server enforcing functional programming best practices."""
2
2
 
3
- __version__ = "0.8.0"
3
+ __version__ = "0.9.0"
@@ -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 or None
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)