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.
Files changed (69) hide show
  1. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/PKG-INFO +1 -1
  2. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/pyproject.toml +1 -1
  3. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/src/java_functional_lsp/__init__.py +1 -1
  4. java_functional_lsp-0.9.1/src/java_functional_lsp/merkle.py +222 -0
  5. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/src/java_functional_lsp/proxy.py +124 -7
  6. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/src/java_functional_lsp/server.py +92 -26
  7. java_functional_lsp-0.9.1/tests/test_merkle.py +314 -0
  8. java_functional_lsp-0.9.1/tests/test_merkle_proxy.py +366 -0
  9. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/tests/test_proxy.py +7 -3
  10. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/uv.lock +1 -1
  11. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/.claude-plugin/plugin.json +0 -0
  12. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/.githooks/pre-commit +0 -0
  13. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/.githooks/pre-push +0 -0
  14. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/.github/CODEOWNERS +0 -0
  15. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/.github/ISSUE_TEMPLATE/bug-report.md +0 -0
  16. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/.github/ISSUE_TEMPLATE/feature-request.md +0 -0
  17. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  18. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/.github/SECURITY.md +0 -0
  19. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/.github/dependabot.yml +0 -0
  20. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/.github/release-drafter.yml +0 -0
  21. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/.github/workflows/publish.yml +0 -0
  22. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/.github/workflows/release-drafter.yml +0 -0
  23. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/.github/workflows/stale.yml +0 -0
  24. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/.github/workflows/test.yml +0 -0
  25. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/.github/workflows/update-homebrew.yml +0 -0
  26. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/.github/workflows/vscode-ext.yml +0 -0
  27. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/.gitignore +0 -0
  28. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/CONTRIBUTING.md +0 -0
  29. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/LICENSE +0 -0
  30. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/README.md +0 -0
  31. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/SKILL.md +0 -0
  32. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/commands/lint-java.md +0 -0
  33. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/editors/intellij/README.md +0 -0
  34. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/editors/intellij/lsp4ij-template.json +0 -0
  35. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/editors/vscode/.vscodeignore +0 -0
  36. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/editors/vscode/README.md +0 -0
  37. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/editors/vscode/package-lock.json +0 -0
  38. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/editors/vscode/package.json +0 -0
  39. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/editors/vscode/src/extension.ts +0 -0
  40. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/editors/vscode/tsconfig.json +0 -0
  41. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/hooks/hooks.json +0 -0
  42. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/hooks/java_linter_reminder.py +0 -0
  43. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/scripts/ensure-lsp.sh +0 -0
  44. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/scripts/generate-formula.py +0 -0
  45. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/src/java_functional_lsp/__main__.py +0 -0
  46. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/src/java_functional_lsp/analyzers/__init__.py +0 -0
  47. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/src/java_functional_lsp/analyzers/base.py +0 -0
  48. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/src/java_functional_lsp/analyzers/exception_checker.py +0 -0
  49. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/src/java_functional_lsp/analyzers/functional_checker.py +0 -0
  50. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/src/java_functional_lsp/analyzers/mutation_checker.py +0 -0
  51. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/src/java_functional_lsp/analyzers/null_checker.py +0 -0
  52. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/src/java_functional_lsp/analyzers/spring_checker.py +0 -0
  53. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/src/java_functional_lsp/cli.py +0 -0
  54. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/src/java_functional_lsp/fixes.py +0 -0
  55. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/tests/__init__.py +0 -0
  56. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/tests/conftest.py +0 -0
  57. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/tests/test_base.py +0 -0
  58. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/tests/test_cli.py +0 -0
  59. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/tests/test_config.py +0 -0
  60. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/tests/test_e2e.py +0 -0
  61. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/tests/test_e2e_jdtls.py +0 -0
  62. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/tests/test_exception_checker.py +0 -0
  63. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/tests/test_fixes.py +0 -0
  64. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/tests/test_functional_checker.py +0 -0
  65. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/tests/test_mutation_checker.py +0 -0
  66. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/tests/test_null_checker.py +0 -0
  67. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/tests/test_server.py +0 -0
  68. {java_functional_lsp-0.8.1 → java_functional_lsp-0.9.1}/tests/test_spring_checker.py +0 -0
  69. {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.8.1
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.8.1"
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" }
@@ -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.1"
@@ -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:
@@ -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 initial module folder to avoid double-indexing.
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
- if self._initial_module_uri and self._initial_module_uri != root_uri:
776
- ini_path = to_fs_path(self._initial_module_uri) or self._initial_module_uri
777
- removed.append({"uri": self._initial_module_uri, "name": Path(ini_path).name})
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, 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)
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(params: lsp.InitializedParams) -> None:
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
- task = asyncio.create_task(server._proxy.send_notification(method, serialized))
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
- task = asyncio.create_task(_lazy_start_jdtls(uri))
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. After jdtls initializes, registers capabilities and flushes
535
- queued notifications. Workspace expansion is NOT done eagerly — modules
536
- are loaded on-demand via ``add_module_if_new()`` as files are opened.
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