java-functional-lsp 0.8.1__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.1 → java_functional_lsp-0.9.0}/PKG-INFO +1 -1
  2. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/pyproject.toml +1 -1
  3. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/src/java_functional_lsp/__init__.py +1 -1
  4. java_functional_lsp-0.9.0/src/java_functional_lsp/merkle.py +222 -0
  5. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/src/java_functional_lsp/proxy.py +111 -2
  6. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/src/java_functional_lsp/server.py +80 -23
  7. java_functional_lsp-0.9.0/tests/test_merkle.py +314 -0
  8. java_functional_lsp-0.9.0/tests/test_merkle_proxy.py +366 -0
  9. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/uv.lock +1 -1
  10. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/.claude-plugin/plugin.json +0 -0
  11. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/.githooks/pre-commit +0 -0
  12. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/.githooks/pre-push +0 -0
  13. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/.github/CODEOWNERS +0 -0
  14. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/.github/ISSUE_TEMPLATE/bug-report.md +0 -0
  15. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/.github/ISSUE_TEMPLATE/feature-request.md +0 -0
  16. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  17. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/.github/SECURITY.md +0 -0
  18. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/.github/dependabot.yml +0 -0
  19. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/.github/release-drafter.yml +0 -0
  20. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/.github/workflows/publish.yml +0 -0
  21. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/.github/workflows/release-drafter.yml +0 -0
  22. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/.github/workflows/stale.yml +0 -0
  23. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/.github/workflows/test.yml +0 -0
  24. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/.github/workflows/update-homebrew.yml +0 -0
  25. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/.github/workflows/vscode-ext.yml +0 -0
  26. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/.gitignore +0 -0
  27. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/CONTRIBUTING.md +0 -0
  28. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/LICENSE +0 -0
  29. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/README.md +0 -0
  30. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/SKILL.md +0 -0
  31. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/commands/lint-java.md +0 -0
  32. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/editors/intellij/README.md +0 -0
  33. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/editors/intellij/lsp4ij-template.json +0 -0
  34. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/editors/vscode/.vscodeignore +0 -0
  35. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/editors/vscode/README.md +0 -0
  36. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/editors/vscode/package-lock.json +0 -0
  37. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/editors/vscode/package.json +0 -0
  38. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/editors/vscode/src/extension.ts +0 -0
  39. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/editors/vscode/tsconfig.json +0 -0
  40. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/hooks/hooks.json +0 -0
  41. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/hooks/java_linter_reminder.py +0 -0
  42. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/scripts/ensure-lsp.sh +0 -0
  43. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/scripts/generate-formula.py +0 -0
  44. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/src/java_functional_lsp/__main__.py +0 -0
  45. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/src/java_functional_lsp/analyzers/__init__.py +0 -0
  46. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/src/java_functional_lsp/analyzers/base.py +0 -0
  47. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/src/java_functional_lsp/analyzers/exception_checker.py +0 -0
  48. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/src/java_functional_lsp/analyzers/functional_checker.py +0 -0
  49. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/src/java_functional_lsp/analyzers/mutation_checker.py +0 -0
  50. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/src/java_functional_lsp/analyzers/null_checker.py +0 -0
  51. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/src/java_functional_lsp/analyzers/spring_checker.py +0 -0
  52. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/src/java_functional_lsp/cli.py +0 -0
  53. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/src/java_functional_lsp/fixes.py +0 -0
  54. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/tests/__init__.py +0 -0
  55. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/tests/conftest.py +0 -0
  56. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/tests/test_base.py +0 -0
  57. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/tests/test_cli.py +0 -0
  58. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/tests/test_config.py +0 -0
  59. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/tests/test_e2e.py +0 -0
  60. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/tests/test_e2e_jdtls.py +0 -0
  61. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/tests/test_exception_checker.py +0 -0
  62. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/tests/test_fixes.py +0 -0
  63. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/tests/test_functional_checker.py +0 -0
  64. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/tests/test_mutation_checker.py +0 -0
  65. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/tests/test_null_checker.py +0 -0
  66. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/tests/test_proxy.py +0 -0
  67. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/tests/test_server.py +0 -0
  68. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.0}/tests/test_spring_checker.py +0 -0
  69. {java_functional_lsp-0.8.1 → 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.1
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "java-functional-lsp"
7
- version = "0.8.1"
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.1"
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)
@@ -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,18 +76,26 @@ 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, diagnostics: list[Any]) -> None:
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)
92
101
  except Exception as e:
@@ -102,6 +111,72 @@ _bg_tasks: set[asyncio.Task[None]] = set()
102
111
  _DEBOUNCE_SECONDS = 0.15
103
112
 
104
113
 
114
+ def _fire_and_forget(coro: Coroutine[Any, Any, Any]) -> None:
115
+ """Schedule *coro* as a background task, preventing GC until it finishes."""
116
+ task = asyncio.create_task(coro)
117
+ _bg_tasks.add(task)
118
+ task.add_done_callback(_bg_tasks.discard)
119
+
120
+
121
+ async def _apply_module_diff(proxy: JdtlsProxy, module_uri: str) -> None:
122
+ """Send ``workspace/didChangeWatchedFiles`` for externally changed files.
123
+
124
+ Called (fire-and-forget) when a module reaches READY state. Pops the
125
+ ``(diff, snapshot)`` pair computed by ``_kick_module_diff`` during module
126
+ registration, notifies jdtls about changed files so it can do an
127
+ incremental rebuild, then persists the updated snapshot to disk.
128
+
129
+ No-ops if the diff computation is not yet finished or found no changes.
130
+ """
131
+ data = proxy.pop_module_data(module_uri)
132
+ if data is None:
133
+ # Race guard: READY may fire before _kick_module_diff finishes.
134
+ # Await the in-flight task directly (no arbitrary sleep).
135
+ await proxy.await_module_diff(module_uri)
136
+ data = proxy.pop_module_data(module_uri)
137
+ if data is None:
138
+ return
139
+ diff, current_snapshot = data
140
+
141
+ if diff is not None and not diff.is_empty:
142
+ from pygls.uris import from_fs_path, to_fs_path
143
+
144
+ module_fs = to_fs_path(module_uri) or ""
145
+ if module_fs:
146
+ module_path = Path(module_fs)
147
+ changes = (
148
+ [
149
+ {"uri": u, "type": lsp.FileChangeType.Created}
150
+ for rel in diff.added
151
+ if (u := from_fs_path(str(module_path / rel)))
152
+ ]
153
+ + [
154
+ {"uri": u, "type": lsp.FileChangeType.Changed}
155
+ for rel in diff.modified
156
+ if (u := from_fs_path(str(module_path / rel)))
157
+ ]
158
+ + [
159
+ {"uri": u, "type": lsp.FileChangeType.Deleted}
160
+ for rel in diff.removed
161
+ if (u := from_fs_path(str(module_path / rel)))
162
+ ]
163
+ )
164
+ if changes:
165
+ await proxy.send_notification("workspace/didChangeWatchedFiles", {"changes": changes})
166
+ logger.info(
167
+ "merkle: notified jdtls of %d externally changed file(s) in module",
168
+ len(changes),
169
+ )
170
+
171
+ # Always persist the current snapshot so next session has an up-to-date baseline.
172
+ snapshot_path = _module_snapshot_path(module_uri)
173
+ loop = asyncio.get_running_loop()
174
+ try:
175
+ await loop.run_in_executor(None, current_snapshot.save, snapshot_path)
176
+ except OSError as exc:
177
+ logger.warning("merkle: could not save updated snapshot: %s", exc)
178
+
179
+
105
180
  def _handle_exception(exc_type: type[BaseException], exc_value: BaseException, exc_tb: Any) -> None:
106
181
  """Log uncaught exceptions for crash debugging."""
107
182
  logger.error("Uncaught exception", exc_info=(exc_type, exc_value, exc_tb))
@@ -342,7 +417,7 @@ def on_initialize(params: lsp.InitializeParams) -> lsp.InitializeResult:
342
417
 
343
418
 
344
419
  @server.feature(lsp.INITIALIZED)
345
- async def on_initialized(params: lsp.InitializedParams) -> None:
420
+ async def on_initialized(_params: lsp.InitializedParams) -> None:
346
421
  """Check jdtls availability; actual start deferred to first didOpen."""
347
422
  logger.info(
348
423
  "java-functional-lsp initialized (rules: %s)",
@@ -451,9 +526,7 @@ def _forward_or_queue(method: str, serialized: Any) -> None:
451
526
  if server._skip_jdtls:
452
527
  return
453
528
  if server._proxy.is_available:
454
- task = asyncio.create_task(server._proxy.send_notification(method, serialized))
455
- _bg_tasks.add(task)
456
- task.add_done_callback(_bg_tasks.discard)
529
+ _fire_and_forget(server._proxy.send_notification(method, serialized))
457
530
  elif server._proxy._lazy_start_fired and not server._proxy._start_failed:
458
531
  server._proxy.queue_notification(method, serialized)
459
532
 
@@ -483,9 +556,7 @@ async def on_did_open(params: lsp.DidOpenTextDocumentParams) -> None:
483
556
  if not server._proxy._lazy_start_fired:
484
557
  # First file: kick off lazy start in background.
485
558
  server._proxy._lazy_start_fired = True
486
- task = asyncio.create_task(_lazy_start_jdtls(uri))
487
- _bg_tasks.add(task)
488
- task.add_done_callback(_bg_tasks.discard)
559
+ _fire_and_forget(_lazy_start_jdtls(uri))
489
560
 
490
561
  # Custom diagnostics always publish immediately — never blocked by jdtls.
491
562
  try:
@@ -548,20 +619,6 @@ async def _lazy_start_jdtls(file_uri: str) -> None:
548
619
  logger.warning("jdtls lazy start failed", exc_info=True)
549
620
 
550
621
 
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
622
  # --- jdtls passthrough handlers (registered dynamically, NOT at module level) ---
566
623
  #
567
624
  # These are NOT decorated with @server.feature because pygls auto-advertises