java-functional-lsp 0.9.11__tar.gz → 0.9.13__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.9.11 → java_functional_lsp-0.9.13}/PKG-INFO +32 -2
  2. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/README.md +31 -1
  3. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/pyproject.toml +1 -1
  4. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/src/java_functional_lsp/__init__.py +1 -1
  5. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/src/java_functional_lsp/server.py +96 -2
  6. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/tests/test_server.py +464 -4
  7. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/.claude-plugin/plugin.json +0 -0
  8. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/.githooks/pre-commit +0 -0
  9. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/.githooks/pre-push +0 -0
  10. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/.github/CODEOWNERS +0 -0
  11. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/.github/ISSUE_TEMPLATE/bug-report.md +0 -0
  12. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/.github/ISSUE_TEMPLATE/feature-request.md +0 -0
  13. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  14. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/.github/SECURITY.md +0 -0
  15. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/.github/dependabot.yml +0 -0
  16. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/.github/release-drafter.yml +0 -0
  17. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/.github/workflows/publish.yml +0 -0
  18. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/.github/workflows/release-drafter.yml +0 -0
  19. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/.github/workflows/stale.yml +0 -0
  20. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/.github/workflows/test.yml +0 -0
  21. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/.github/workflows/update-homebrew.yml +0 -0
  22. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/.github/workflows/vscode-ext.yml +0 -0
  23. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/.gitignore +0 -0
  24. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/CONTRIBUTING.md +0 -0
  25. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/LICENSE +0 -0
  26. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/SKILL.md +0 -0
  27. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/commands/lint-java.md +0 -0
  28. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/editors/intellij/README.md +0 -0
  29. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/editors/intellij/lsp4ij-template.json +0 -0
  30. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/editors/vscode/.vscodeignore +0 -0
  31. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/editors/vscode/README.md +0 -0
  32. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/editors/vscode/package-lock.json +0 -0
  33. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/editors/vscode/package.json +0 -0
  34. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/editors/vscode/src/extension.ts +0 -0
  35. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/editors/vscode/tsconfig.json +0 -0
  36. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/hooks/hooks.json +0 -0
  37. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/hooks/java_linter_reminder.py +0 -0
  38. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/scripts/ensure-lsp.sh +0 -0
  39. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/scripts/generate-formula.py +0 -0
  40. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/src/java_functional_lsp/__main__.py +0 -0
  41. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/src/java_functional_lsp/analyzers/__init__.py +0 -0
  42. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/src/java_functional_lsp/analyzers/base.py +0 -0
  43. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/src/java_functional_lsp/analyzers/exception_checker.py +0 -0
  44. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/src/java_functional_lsp/analyzers/functional_checker.py +0 -0
  45. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/src/java_functional_lsp/analyzers/mutation_checker.py +0 -0
  46. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/src/java_functional_lsp/analyzers/null_checker.py +0 -0
  47. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/src/java_functional_lsp/analyzers/spring_checker.py +0 -0
  48. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/src/java_functional_lsp/cli.py +0 -0
  49. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/src/java_functional_lsp/fixes.py +0 -0
  50. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/src/java_functional_lsp/merkle.py +0 -0
  51. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/src/java_functional_lsp/proxy.py +0 -0
  52. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/tests/__init__.py +0 -0
  53. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/tests/conftest.py +0 -0
  54. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/tests/test_base.py +0 -0
  55. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/tests/test_cli.py +0 -0
  56. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/tests/test_config.py +0 -0
  57. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/tests/test_e2e.py +0 -0
  58. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/tests/test_e2e_jdtls.py +0 -0
  59. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/tests/test_exception_checker.py +0 -0
  60. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/tests/test_fixes.py +0 -0
  61. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/tests/test_functional_checker.py +0 -0
  62. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/tests/test_merkle.py +0 -0
  63. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/tests/test_merkle_proxy.py +0 -0
  64. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/tests/test_mutation_checker.py +0 -0
  65. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/tests/test_null_checker.py +0 -0
  66. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/tests/test_proxy.py +0 -0
  67. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/tests/test_spring_checker.py +0 -0
  68. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/tests/test_suppress.py +0 -0
  69. {java_functional_lsp-0.9.11 → java_functional_lsp-0.9.13}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: java-functional-lsp
3
- Version: 0.9.11
3
+ Version: 0.9.13
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
@@ -270,7 +270,37 @@ The server auto-discovers `lombok.jar` from these locations (first match wins):
270
270
  3. **Maven cache** — auto-discovered from `~/.m2/repository/org/projectlombok/lombok/`
271
271
  4. **Dedicated directory** — `~/.jdtls-libs/lombok.jar`
272
272
 
273
- If Lombok is used in your project but the jar isn't found, the server logs a warning. The jdtls cache is automatically cleared on version upgrade, so adding Lombok support takes effect immediately after `brew upgrade`.
273
+ If Lombok is used in your project but the jar isn't found, the server logs a warning.
274
+
275
+ ### jdtls settings
276
+
277
+ The server sends Maven/Gradle settings to jdtls at startup via `initializationOptions.settings`. Defaults are optimized for Maven monorepos (Maven enabled, Gradle disabled, build artifact exclusions). Override via `.java-functional-lsp.json`:
278
+
279
+ ```json
280
+ {
281
+ "jdtls": {
282
+ "settings": {
283
+ "java": {
284
+ "import": {
285
+ "maven": { "enabled": true },
286
+ "gradle": { "enabled": true },
287
+ "exclusions": ["**/node_modules/**", "**/target/**"]
288
+ },
289
+ "configuration": { "updateBuildConfiguration": "automatic" },
290
+ "maven": { "downloadSources": true }
291
+ }
292
+ }
293
+ }
294
+ }
295
+ ```
296
+
297
+ Custom settings fully replace the defaults (no merge). See the [jdtls Preferences reference](https://github.com/eclipse-jdtls/eclipse.jdt.ls/blob/main/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/preferences/Preferences.java) for all available keys.
298
+
299
+ ### jdtls cache
300
+
301
+ The jdtls Eclipse workspace index is cached in `~/.cache/jdtls-data/`. Warm starts (~10-20s) reuse this cache; cold starts (60-120s) rebuild from scratch. The cache is automatically invalidated when jdtls or Java is upgraded, but **not** when java-functional-lsp is upgraded — our Python code changes don't affect the Eclipse index.
302
+
303
+ To force a clean rebuild: `rm -rf ~/.cache/jdtls-data/`
274
304
 
275
305
  ### Suppressing jdtls diagnostics
276
306
 
@@ -242,7 +242,37 @@ The server auto-discovers `lombok.jar` from these locations (first match wins):
242
242
  3. **Maven cache** — auto-discovered from `~/.m2/repository/org/projectlombok/lombok/`
243
243
  4. **Dedicated directory** — `~/.jdtls-libs/lombok.jar`
244
244
 
245
- If Lombok is used in your project but the jar isn't found, the server logs a warning. The jdtls cache is automatically cleared on version upgrade, so adding Lombok support takes effect immediately after `brew upgrade`.
245
+ If Lombok is used in your project but the jar isn't found, the server logs a warning.
246
+
247
+ ### jdtls settings
248
+
249
+ The server sends Maven/Gradle settings to jdtls at startup via `initializationOptions.settings`. Defaults are optimized for Maven monorepos (Maven enabled, Gradle disabled, build artifact exclusions). Override via `.java-functional-lsp.json`:
250
+
251
+ ```json
252
+ {
253
+ "jdtls": {
254
+ "settings": {
255
+ "java": {
256
+ "import": {
257
+ "maven": { "enabled": true },
258
+ "gradle": { "enabled": true },
259
+ "exclusions": ["**/node_modules/**", "**/target/**"]
260
+ },
261
+ "configuration": { "updateBuildConfiguration": "automatic" },
262
+ "maven": { "downloadSources": true }
263
+ }
264
+ }
265
+ }
266
+ }
267
+ ```
268
+
269
+ Custom settings fully replace the defaults (no merge). See the [jdtls Preferences reference](https://github.com/eclipse-jdtls/eclipse.jdt.ls/blob/main/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/preferences/Preferences.java) for all available keys.
270
+
271
+ ### jdtls cache
272
+
273
+ The jdtls Eclipse workspace index is cached in `~/.cache/jdtls-data/`. Warm starts (~10-20s) reuse this cache; cold starts (60-120s) rebuild from scratch. The cache is automatically invalidated when jdtls or Java is upgraded, but **not** when java-functional-lsp is upgraded — our Python code changes don't affect the Eclipse index.
274
+
275
+ To force a clean rebuild: `rm -rf ~/.cache/jdtls-data/`
246
276
 
247
277
  ### Suppressing jdtls diagnostics
248
278
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "java-functional-lsp"
7
- version = "0.9.11"
7
+ version = "0.9.13"
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.9.11"
3
+ __version__ = "0.9.13"
@@ -12,6 +12,8 @@ import logging
12
12
  import os
13
13
  import re
14
14
  import sys
15
+ import time
16
+ from collections import OrderedDict
15
17
  from collections.abc import Coroutine
16
18
  from pathlib import Path
17
19
  from typing import Any
@@ -19,7 +21,7 @@ from typing import Any
19
21
  from lsprotocol import types as lsp
20
22
  from lsprotocol.converters import get_converter
21
23
  from pygls.lsp.server import LanguageServer
22
- from pygls.uris import to_fs_path
24
+ from pygls.uris import from_fs_path, to_fs_path
23
25
 
24
26
  from .analyzers.base import Analyzer, Severity, get_parser, is_excluded, is_suppressed
25
27
  from .analyzers.base import Diagnostic as LintDiagnostic
@@ -61,6 +63,34 @@ _JDTLS_SKIP_CLIENTS = ("IntelliJ", "JetBrains")
61
63
  #: go-to-definition, references, etc.
62
64
  _converter = get_converter()
63
65
 
66
+ # Cap on _session_opened_uris: a session navigating many files still bounds memory.
67
+ # 4096 comfortably covers even aggressive monorepo navigation.
68
+ _MAX_OPENED_URIS = 4096
69
+
70
+
71
+ def _normalize_uri(uri: str) -> str:
72
+ """Canonicalize a file:// URI so equivalent paths compare equal.
73
+
74
+ Round-trips through pygls.uris to normalize URL encoding differences
75
+ (``%20`` vs literal space, ``%3A`` vs ``:``, trailing slashes, double
76
+ slashes) between what the client sends and what jdtls echoes back.
77
+
78
+ Non-file URIs (``jdt://``, ``untitled://``, etc.) are returned unchanged —
79
+ they're never stored in ``_session_opened_uris`` so they'll fall through the gate
80
+ regardless. Case-insensitive filesystems (macOS HFS+, NTFS) are not
81
+ normalized here: attempting that would require ``Path.resolve()``, which
82
+ would stat the filesystem and follow symlinks — too expensive for a hot
83
+ path that runs on every jdtls publish. On Windows, drive-letter case
84
+ (``C:`` vs ``c:``) is similarly not normalized.
85
+ """
86
+ if not uri.startswith("file:"):
87
+ return uri
88
+ fs = to_fs_path(uri)
89
+ if fs is None:
90
+ return uri
91
+ normalized = from_fs_path(fs)
92
+ return normalized if normalized is not None else uri
93
+
64
94
 
65
95
  class JavaFunctionalLspServer(LanguageServer):
66
96
  def __init__(self) -> None:
@@ -75,6 +105,33 @@ class JavaFunctionalLspServer(LanguageServer):
75
105
  self._skip_jdtls: bool = False
76
106
  self._skip_jdtls_registration: bool = False
77
107
  self._init_generation: int = 0
108
+ # URIs the client has opened at least once this session (normalized form).
109
+ # Jdtls indexes the whole workspace and publishes diagnostics for files the
110
+ # client never touched; we only republish for URIs present here so unrelated
111
+ # modules don't flood the client. Cumulative (not cleared on didClose) to
112
+ # survive the async race between didClose and late jdtls publishes.
113
+ # Bounded LRU: insertion-ordered, oldest evicted on overflow so a long
114
+ # session navigating many files can't grow the set unboundedly.
115
+ # Reset on initialize.
116
+ self._session_opened_uris: OrderedDict[str, None] = OrderedDict()
117
+
118
+ def _record_opened(self, uri: str) -> None:
119
+ """Record *uri* as opened this session, evicting the oldest entry at cap.
120
+
121
+ Stores the normalized form so gate lookups in ``_on_jdtls_diagnostics``
122
+ can use a fast raw-first check without redundant normalization.
123
+ """
124
+ uri = _normalize_uri(uri)
125
+ if uri in self._session_opened_uris:
126
+ self._session_opened_uris.move_to_end(uri)
127
+ return
128
+ self._session_opened_uris[uri] = None
129
+ # Size check uses > so the dict stabilises at exactly _MAX_OPENED_URIS
130
+ # entries after the pop: we insert first (reaching MAX+1 momentarily),
131
+ # then evict. The transient overshoot is invisible to other callers
132
+ # because asyncio is single-threaded and no await intervenes here.
133
+ if len(self._session_opened_uris) > _MAX_OPENED_URIS:
134
+ self._session_opened_uris.popitem(last=False)
78
135
 
79
136
  def _on_jdtls_diagnostics(self, uri: str, diagnostics: list[Any]) -> None:
80
137
  """Called when jdtls publishes diagnostics — merge with custom and re-publish.
@@ -107,10 +164,25 @@ class JavaFunctionalLspServer(LanguageServer):
107
164
  # First READY transition only — subsequent diagnostics for the same
108
165
  # module are no-ops to avoid spawning O(files) redundant tasks.
109
166
  _fire_and_forget(_apply_module_diff(self._proxy, module_uri))
167
+ # Only republish for files the client has opened at any point this
168
+ # session (cumulative). Jdtls scans the whole workspace; without this
169
+ # guard, diagnostics for 200+ unrelated modules leak out as
170
+ # <new-diagnostics> tags (see #71). Non-file URIs (jdt://, untitled://,
171
+ # etc.) are never recorded by _record_opened so they're dropped here.
172
+ #
173
+ # Two-phase lookup: check the raw URI first (O(1), no allocation) since
174
+ # jdtls typically echoes URIs in the same form as the client — the fast
175
+ # path avoids the to_fs_path/from_fs_path round-trip on every publish.
176
+ # Fall back to the normalized form only on a miss to handle encoding
177
+ # mismatches (e.g., client sends literal space, jdtls echoes %20).
178
+ if uri not in self._session_opened_uris and _normalize_uri(uri) not in self._session_opened_uris:
179
+ return
110
180
  try:
111
181
  _analyze_and_publish(uri)
112
182
  except FileNotFoundError:
113
- pass # Virtual jdtls file (e.g., decompiled source in jdtls-data); skip silently.
183
+ # The file was in the opened-URIs set but vanished from disk
184
+ # between didOpen and this late jdtls publish. Benign.
185
+ pass
114
186
  except Exception as e:
115
187
  logger.error("Error re-publishing diagnostics for %s: %s", uri, e)
116
188
 
@@ -389,6 +461,7 @@ def on_initialize(params: lsp.InitializeParams) -> lsp.InitializeResult:
389
461
  server._skip_jdtls = False
390
462
  server._skip_jdtls_registration = False
391
463
  server._init_generation += 1
464
+ server._session_opened_uris.clear()
392
465
  _jdtls_capabilities_registered = False
393
466
 
394
467
  jdtls_override = os.environ.get("JAVA_FUNCTIONAL_LSP_JDTLS", "").strip().lower()
@@ -577,6 +650,9 @@ async def on_did_open(params: lsp.DidOpenTextDocumentParams) -> None:
577
650
  didOpen response isn't delayed by jdtls cold-start.
578
651
  """
579
652
  uri = params.text_document.uri
653
+ # Must precede any await: same-loop jdtls diagnostic callbacks need to
654
+ # observe this URI before they arrive to avoid the publish getting gated.
655
+ server._record_opened(uri)
580
656
 
581
657
  if server._skip_jdtls:
582
658
  # Skip all jdtls forwarding — custom diagnostics only.
@@ -635,6 +711,24 @@ async def on_did_close(params: lsp.DidCloseTextDocumentParams) -> None:
635
711
  _forward_or_queue("textDocument/didClose", _serialize_params(params))
636
712
 
637
713
 
714
+ _DIDCHANGE_CONFIG_COOLDOWN = 30.0 # seconds — rate-limit didChangeConfiguration forwarding
715
+ _last_config_forward: float = 0.0
716
+
717
+
718
+ @server.feature(lsp.WORKSPACE_DID_CHANGE_CONFIGURATION)
719
+ def on_did_change_configuration(params: lsp.DidChangeConfigurationParams) -> None:
720
+ """Forward IDE configuration changes to jdtls (rate-limited)."""
721
+ global _last_config_forward
722
+ if not server._proxy.is_available:
723
+ return
724
+ now = time.monotonic()
725
+ if now - _last_config_forward < _DIDCHANGE_CONFIG_COOLDOWN:
726
+ logger.debug("jdtls: throttling didChangeConfiguration (%.0fs cooldown)", _DIDCHANGE_CONFIG_COOLDOWN)
727
+ return
728
+ _last_config_forward = now
729
+ _fire_and_forget(server._proxy.send_notification("workspace/didChangeConfiguration", _serialize_params(params)))
730
+
731
+
638
732
  async def _lazy_start_jdtls(file_uri: str) -> None:
639
733
  """Background task: start jdtls scoped to the module containing *file_uri*.
640
734
 
@@ -173,6 +173,27 @@ def _ensure_workspace() -> None:
173
173
  class TestServerInternals:
174
174
  """Direct-call tests for server.py helpers — provides in-process coverage."""
175
175
 
176
+ @pytest.fixture(autouse=True)
177
+ def _reset_server_state(self) -> Any:
178
+ """Reset mutable singleton state on ``server`` before and after every
179
+ test. Guarantees (a) a test that crashes between .add and .discard
180
+ can't leak into its successor, and (b) residue from another test
181
+ module (notably ``test_merkle_proxy``) can't leak into this class."""
182
+ import java_functional_lsp.server as srv_mod
183
+ from java_functional_lsp.server import server
184
+
185
+ def _reset() -> None:
186
+ server._session_opened_uris.clear()
187
+ server._proxy.modules._states.clear()
188
+ server._skip_jdtls = False
189
+ server._skip_jdtls_registration = False
190
+ server._init_generation = 0
191
+ srv_mod._jdtls_capabilities_registered = False
192
+
193
+ _reset()
194
+ yield
195
+ _reset()
196
+
176
197
  def test_load_config_no_workspace(self) -> None:
177
198
  from java_functional_lsp.server import _load_config
178
199
 
@@ -281,6 +302,7 @@ class TestServerInternals:
281
302
  uri=uri, language_id="java", version=1, text="class T { String f() { return null; } }"
282
303
  ),
283
304
  )
305
+ server._record_opened(uri)
284
306
  try:
285
307
  with patch.object(server, "text_document_publish_diagnostics") as mock_pub:
286
308
  server._on_jdtls_diagnostics(uri, [])
@@ -289,6 +311,7 @@ class TestServerInternals:
289
311
  assert "null-return" in codes
290
312
  finally:
291
313
  server.workspace.remove_text_document(uri)
314
+ server._session_opened_uris.pop(uri, None)
292
315
 
293
316
  def test_on_jdtls_diagnostics_non_project_skips_ready(self) -> None:
294
317
  """A publishDiagnostics batch with code-16 must NOT mark the module READY."""
@@ -322,6 +345,38 @@ class TestServerInternals:
322
345
 
323
346
  server._proxy.modules.clear()
324
347
  uri = "file:///mod/Pr.java"
348
+ server._record_opened(uri)
349
+ real_diag = {
350
+ "code": "268435844",
351
+ "message": "Some unused import",
352
+ "source": "Java",
353
+ "severity": 2,
354
+ "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 1}},
355
+ }
356
+ try:
357
+ with (
358
+ patch("java_functional_lsp.server._resolve_module_uri", return_value="file:///mod"),
359
+ patch("java_functional_lsp.server._analyze_and_publish") as mock_pub,
360
+ patch("java_functional_lsp.server._fire_and_forget"),
361
+ ):
362
+ server._on_jdtls_diagnostics(uri, [real_diag])
363
+ mock_pub.assert_called_once_with(uri)
364
+ assert server._proxy.modules.get_state("file:///mod") == ModuleState.READY
365
+ finally:
366
+ server._proxy.modules.clear()
367
+ server._session_opened_uris.pop(uri, None)
368
+
369
+ def test_on_jdtls_diagnostics_skips_publish_for_unopened_uri(self) -> None:
370
+ """Jdtls diagnostics for files the client never opened must not republish (#71)."""
371
+ from unittest.mock import patch
372
+
373
+ from java_functional_lsp.proxy import ModuleState
374
+ from java_functional_lsp.server import server
375
+
376
+ server._proxy.modules.clear()
377
+ uri = "file:///other/Module/Foo.java"
378
+ # Sanity-check: autouse fixture guarantees a clean set; explicit for readability.
379
+ assert uri not in server._session_opened_uris
325
380
  real_diag = {
326
381
  "code": "268435844",
327
382
  "message": "Some unused import",
@@ -329,14 +384,375 @@ class TestServerInternals:
329
384
  "severity": 2,
330
385
  "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 1}},
331
386
  }
387
+ try:
388
+ with (
389
+ patch("java_functional_lsp.server._resolve_module_uri", return_value="file:///other/Module"),
390
+ patch("java_functional_lsp.server._analyze_and_publish") as mock_pub,
391
+ patch("java_functional_lsp.server._fire_and_forget"),
392
+ ):
393
+ server._on_jdtls_diagnostics(uri, [real_diag])
394
+ mock_pub.assert_not_called()
395
+ # Module-READY bookkeeping must still run even when the publish is gated.
396
+ assert server._proxy.modules.get_state("file:///other/Module") == ModuleState.READY
397
+ finally:
398
+ server._proxy.modules.clear()
399
+
400
+ async def test_on_did_open_records_uri(self) -> None:
401
+ """on_did_open must add the URI to the session's opened-URIs set."""
402
+ from unittest.mock import patch
403
+
404
+ from java_functional_lsp.server import on_did_open, server
405
+
406
+ _ensure_workspace()
407
+ uri = "file:///test/Opened.java"
408
+ server._session_opened_uris.pop(uri, None)
409
+ old_skip = server._skip_jdtls
410
+ server._skip_jdtls = True # avoid exercising jdtls paths
411
+ try:
412
+ with patch("java_functional_lsp.server._analyze_and_publish"):
413
+ await on_did_open(
414
+ lsp.DidOpenTextDocumentParams(
415
+ text_document=lsp.TextDocumentItem(
416
+ uri=uri, language_id="java", version=1, text="class Opened {}"
417
+ )
418
+ )
419
+ )
420
+ assert uri in server._session_opened_uris
421
+ finally:
422
+ server._session_opened_uris.pop(uri, None)
423
+ server._skip_jdtls = old_skip
424
+
425
+ async def test_on_did_open_stores_normalized_form(self) -> None:
426
+ """on_did_open must store the normalized URI, not the raw client form.
427
+
428
+ _record_opened normalizes before storing. If the client sends a URI
429
+ with a literal space and jdtls echoes it with ``%20``, the gate lookup
430
+ must still pass — which only works if the stored key is the normalized
431
+ form.
432
+ """
433
+ from unittest.mock import patch
434
+
435
+ from java_functional_lsp.server import _normalize_uri, on_did_open, server
436
+
437
+ _ensure_workspace()
438
+ raw_uri = "file:///test/has space.java"
439
+ normalized_uri = _normalize_uri(raw_uri)
440
+ assert normalized_uri != raw_uri, "test setup: raw and normalized must differ"
441
+ for u in (raw_uri, normalized_uri):
442
+ server._session_opened_uris.pop(u, None)
443
+ old_skip = server._skip_jdtls
444
+ server._skip_jdtls = True
445
+ try:
446
+ with patch("java_functional_lsp.server._analyze_and_publish"):
447
+ await on_did_open(
448
+ lsp.DidOpenTextDocumentParams(
449
+ text_document=lsp.TextDocumentItem(
450
+ uri=raw_uri, language_id="java", version=1, text="class S {}"
451
+ )
452
+ )
453
+ )
454
+ # Raw form must NOT be stored; normalized form MUST be.
455
+ assert raw_uri not in server._session_opened_uris
456
+ assert normalized_uri in server._session_opened_uris
457
+ finally:
458
+ for u in (raw_uri, normalized_uri):
459
+ server._session_opened_uris.pop(u, None)
460
+ server._skip_jdtls = old_skip
461
+
462
+ def test_on_initialize_clears_opened_uris(self) -> None:
463
+ """Re-initialize must reset the session's opened-URIs set."""
464
+ from java_functional_lsp.server import on_initialize, server
465
+
466
+ server._record_opened("file:///stale/Leftover.java")
467
+ on_initialize(
468
+ lsp.InitializeParams(
469
+ process_id=1,
470
+ root_uri="file:///tmp",
471
+ capabilities=lsp.ClientCapabilities(),
472
+ )
473
+ )
474
+ assert not server._session_opened_uris
475
+
476
+ def test_opened_uris_evicts_oldest_at_cap(self) -> None:
477
+ """_record_opened enforces a FIFO cap so the set can't grow unbounded."""
478
+ from unittest.mock import patch
479
+
480
+ from java_functional_lsp.server import server
481
+
482
+ server._session_opened_uris.clear()
483
+ try:
484
+ with patch("java_functional_lsp.server._MAX_OPENED_URIS", 3):
485
+ for i in range(3):
486
+ server._record_opened(f"file:///u/F{i}.java")
487
+ assert list(server._session_opened_uris) == [
488
+ "file:///u/F0.java",
489
+ "file:///u/F1.java",
490
+ "file:///u/F2.java",
491
+ ]
492
+ # Fourth insert must evict the oldest.
493
+ server._record_opened("file:///u/F3.java")
494
+ assert list(server._session_opened_uris) == [
495
+ "file:///u/F1.java",
496
+ "file:///u/F2.java",
497
+ "file:///u/F3.java",
498
+ ]
499
+ # Re-opening an existing URI moves it to the end, no eviction.
500
+ server._record_opened("file:///u/F1.java")
501
+ assert list(server._session_opened_uris) == [
502
+ "file:///u/F2.java",
503
+ "file:///u/F3.java",
504
+ "file:///u/F1.java",
505
+ ]
506
+ finally:
507
+ server._session_opened_uris.clear()
508
+
509
+ def test_opened_uris_cap_one_always_evicts_previous(self) -> None:
510
+ """With cap=1, every new URI evicts the previous one."""
511
+ from unittest.mock import patch
512
+
513
+ from java_functional_lsp.server import server
514
+
515
+ server._session_opened_uris.clear()
516
+ try:
517
+ with patch("java_functional_lsp.server._MAX_OPENED_URIS", 1):
518
+ server._record_opened("file:///u/A.java")
519
+ assert list(server._session_opened_uris) == ["file:///u/A.java"]
520
+ server._record_opened("file:///u/B.java")
521
+ assert list(server._session_opened_uris) == ["file:///u/B.java"]
522
+ # Re-opening the sole entry is a no-op (move_to_end, no eviction).
523
+ server._record_opened("file:///u/B.java")
524
+ assert list(server._session_opened_uris) == ["file:///u/B.java"]
525
+ finally:
526
+ server._session_opened_uris.clear()
527
+
528
+ def test_opened_uris_burst_never_exceeds_cap(self) -> None:
529
+ """Inserting N >> cap items must leave the dict at exactly cap entries."""
530
+ from unittest.mock import patch
531
+
532
+ from java_functional_lsp.server import server
533
+
534
+ server._session_opened_uris.clear()
535
+ cap = 5
536
+ burst = 50
537
+ try:
538
+ with patch("java_functional_lsp.server._MAX_OPENED_URIS", cap):
539
+ for i in range(burst):
540
+ server._record_opened(f"file:///u/F{i}.java")
541
+ assert len(server._session_opened_uris) == cap
542
+ # Must contain the last `cap` inserted URIs.
543
+ assert list(server._session_opened_uris) == [f"file:///u/F{i}.java" for i in range(burst - cap, burst)]
544
+ finally:
545
+ server._session_opened_uris.clear()
546
+
547
+ async def test_on_did_close_preserves_opened_uris(self) -> None:
548
+ """Cumulative design: didClose must NOT evict — late jdtls publishes still go through."""
549
+ from unittest.mock import patch
550
+
551
+ from java_functional_lsp.server import on_did_close, server
552
+
553
+ _ensure_workspace()
554
+ uri = "file:///test/Closed.java"
555
+ server._record_opened(uri)
556
+ old_skip = server._skip_jdtls
557
+ server._skip_jdtls = True
558
+ try:
559
+ with patch.object(server, "text_document_publish_diagnostics"):
560
+ await on_did_close(
561
+ lsp.DidCloseTextDocumentParams(
562
+ text_document=lsp.TextDocumentIdentifier(uri=uri),
563
+ )
564
+ )
565
+ assert uri in server._session_opened_uris
566
+ finally:
567
+ server._session_opened_uris.pop(uri, None)
568
+ server._skip_jdtls = old_skip
569
+
570
+ async def test_on_did_close_preserves_opened_uris_jdtls_available(self) -> None:
571
+ """Cumulative design holds when jdtls IS available (not just the skip path)."""
572
+ from unittest.mock import AsyncMock, PropertyMock, patch
573
+
574
+ from java_functional_lsp.server import on_did_close, server
575
+
576
+ _ensure_workspace()
577
+ uri = "file:///test/ClosedLive.java"
578
+ server._record_opened(uri)
579
+ old_skip = server._skip_jdtls
580
+ server._skip_jdtls = False
581
+ try:
582
+ with (
583
+ patch.object(server, "text_document_publish_diagnostics"),
584
+ patch.object(type(server._proxy), "is_available", new_callable=PropertyMock, return_value=True),
585
+ patch.object(server._proxy, "send_notification", new=AsyncMock()),
586
+ ):
587
+ await on_did_close(
588
+ lsp.DidCloseTextDocumentParams(
589
+ text_document=lsp.TextDocumentIdentifier(uri=uri),
590
+ )
591
+ )
592
+ assert uri in server._session_opened_uris
593
+ finally:
594
+ server._session_opened_uris.pop(uri, None)
595
+ server._skip_jdtls = old_skip
596
+
597
+ async def test_on_did_open_records_uri_before_first_await(self) -> None:
598
+ """Records the URI before yielding to the event loop.
599
+
600
+ Jdtls dispatches diagnostics on the same loop as ``on_did_open``. If
601
+ the record happened after the first ``await``, a diagnostic batch that
602
+ lands between the ``await`` and the record would be gated out. The
603
+ pre-await record is the only thing that guarantees same-loop
604
+ correctness — regression-guard it here.
605
+ """
606
+ from unittest.mock import AsyncMock, PropertyMock, patch
607
+
608
+ from java_functional_lsp.server import on_did_open, server
609
+
610
+ _ensure_workspace()
611
+ uri = "file:///test/PreAwait.java"
612
+ server._session_opened_uris.pop(uri, None)
613
+
614
+ observed_before_await: dict[str, bool] = {}
615
+
616
+ async def _spy_send(*_args: Any, **_kwargs: Any) -> None:
617
+ # This spy is injected as server._proxy.send_notification, which is
618
+ # the first ``await`` reached in the is_available=True branch of
619
+ # on_did_open (line: `await server._proxy.send_notification(...)`).
620
+ # If the URI was recorded before that point it must already be in
621
+ # the set. If a future refactor moves an ``await`` earlier in
622
+ # on_did_open, this spy must be updated to hook that new first
623
+ # await so the ordering guarantee is still tested.
624
+ observed_before_await["present"] = uri in server._session_opened_uris
625
+
626
+ old_skip = server._skip_jdtls
627
+ server._skip_jdtls = False
628
+ try:
629
+ with (
630
+ patch("java_functional_lsp.server._analyze_and_publish"),
631
+ patch.object(type(server._proxy), "is_available", new_callable=PropertyMock, return_value=True),
632
+ patch.object(server._proxy, "send_notification", new=AsyncMock(side_effect=_spy_send)),
633
+ patch.object(server._proxy, "add_module_if_new", new=AsyncMock()),
634
+ ):
635
+ await on_did_open(
636
+ lsp.DidOpenTextDocumentParams(
637
+ text_document=lsp.TextDocumentItem(uri=uri, language_id="java", version=1, text="class P {}")
638
+ )
639
+ )
640
+ assert observed_before_await.get("present") is True, (
641
+ "URI must be recorded before on_did_open yields to the event loop"
642
+ )
643
+ finally:
644
+ server._session_opened_uris.pop(uri, None)
645
+ server._skip_jdtls = old_skip
646
+
647
+ async def test_on_did_open_records_uri_before_first_await_queue_mode(self) -> None:
648
+ """URI is recorded before any background task starts in queue (lazy-start) mode.
649
+
650
+ In the queue mode path (jdtls on PATH but not yet started), there is no
651
+ early ``await`` before ``_analyze_and_publish`` — the record must still
652
+ happen synchronously before the coroutine first yields. This test
653
+ verifies that the ``_record_opened`` call precedes any async work by
654
+ checking the set state directly after the call, with jdtls startup mocked
655
+ out so no background coroutine fires.
656
+ """
657
+ from unittest.mock import AsyncMock, PropertyMock, patch
658
+
659
+ from java_functional_lsp.server import on_did_open, server
660
+
661
+ _ensure_workspace()
662
+ uri = "file:///test/QueueMode.java"
663
+ server._session_opened_uris.pop(uri, None)
664
+ old_skip = server._skip_jdtls
665
+ server._skip_jdtls = False
666
+ try:
667
+ with (
668
+ patch("java_functional_lsp.server._analyze_and_publish"),
669
+ # Simulate jdtls not yet available but on PATH and not failed.
670
+ patch.object(type(server._proxy), "is_available", new_callable=PropertyMock, return_value=False),
671
+ patch.object(server._proxy, "_jdtls_on_path", True),
672
+ patch.object(server._proxy, "_start_failed", False),
673
+ patch.object(server._proxy, "_lazy_start_fired", False),
674
+ patch.object(server._proxy, "queue_notification"),
675
+ # Prevent the real lazy-start background task from firing.
676
+ patch("java_functional_lsp.server._lazy_start_jdtls", new=AsyncMock()),
677
+ patch("java_functional_lsp.server._fire_and_forget"),
678
+ ):
679
+ await on_did_open(
680
+ lsp.DidOpenTextDocumentParams(
681
+ text_document=lsp.TextDocumentItem(uri=uri, language_id="java", version=1, text="class Q {}")
682
+ )
683
+ )
684
+ # URI must be present regardless of which branch on_did_open took.
685
+ assert uri in server._session_opened_uris
686
+ finally:
687
+ server._session_opened_uris.pop(uri, None)
688
+ server._skip_jdtls = old_skip
689
+
690
+ def test_on_jdtls_diagnostics_non_project_unopened_skips_both(self) -> None:
691
+ """A code-16 batch for an unopened URI skips READY *and* publish.
692
+
693
+ Covers the interaction of two independent gates: the code-16 guard on
694
+ module-READY bookkeeping, and the opened-URIs gate on republishing.
695
+ Neither should fire.
696
+ """
697
+ from unittest.mock import patch
698
+
699
+ from java_functional_lsp.proxy import ModuleState
700
+ from java_functional_lsp.server import server
701
+
702
+ server._proxy.modules.clear()
703
+ uri = "file:///other/Module/Stranger.java"
704
+ # Sanity-check: autouse fixture guarantees a clean set; explicit for readability.
705
+ assert uri not in server._session_opened_uris
706
+ non_project_diag = {
707
+ "code": "16",
708
+ "message": "Stranger.java is a non-project file, only syntax errors are reported",
709
+ "source": "Java",
710
+ "severity": 2,
711
+ "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 1}},
712
+ }
332
713
  with (
333
- patch("java_functional_lsp.server._resolve_module_uri", return_value="file:///mod"),
334
- patch("java_functional_lsp.server._analyze_and_publish"),
714
+ patch("java_functional_lsp.server._resolve_module_uri", return_value="file:///other/Module"),
715
+ patch("java_functional_lsp.server._analyze_and_publish") as mock_pub,
335
716
  patch("java_functional_lsp.server._fire_and_forget"),
336
717
  ):
337
- server._on_jdtls_diagnostics(uri, [real_diag])
338
- assert server._proxy.modules.get_state("file:///mod") == ModuleState.READY
718
+ server._on_jdtls_diagnostics(uri, [non_project_diag])
719
+ mock_pub.assert_not_called()
720
+ assert server._proxy.modules.get_state("file:///other/Module") != ModuleState.READY
721
+
722
+ def test_normalize_uri_passes_through_file_uri(self) -> None:
723
+ from java_functional_lsp.server import _normalize_uri
724
+
725
+ assert _normalize_uri("file:///a/B.java") == "file:///a/B.java"
726
+
727
+ def test_normalize_uri_unifies_space_encoding(self) -> None:
728
+ """A literal space and %20 must canonicalize to the same URI."""
729
+ from java_functional_lsp.server import _normalize_uri
730
+
731
+ assert _normalize_uri("file:///a/has space.java") == _normalize_uri("file:///a/has%20space.java")
732
+
733
+ def test_normalize_uri_passes_through_non_file_scheme(self) -> None:
734
+ """Non-file URIs (jdt://, untitled://) are returned unchanged."""
735
+ from java_functional_lsp.server import _normalize_uri
736
+
737
+ assert _normalize_uri("jdt://contents/foo.Bar") == "jdt://contents/foo.Bar"
738
+ assert _normalize_uri("untitled://Untitled-1") == "untitled://Untitled-1"
739
+
740
+ def test_on_jdtls_diagnostics_matches_opened_uri_across_encodings(self) -> None:
741
+ """Gate compares URIs via _normalize_uri so %20 vs literal space still matches."""
742
+ from unittest.mock import patch
743
+
744
+ from java_functional_lsp.server import server
745
+
339
746
  server._proxy.modules.clear()
747
+ # Client records with literal space; jdtls echoes with %20. These must match.
748
+ server._record_opened("file:///a/has space.java")
749
+ with (
750
+ patch("java_functional_lsp.server._resolve_module_uri", return_value=None),
751
+ patch("java_functional_lsp.server._analyze_and_publish") as mock_pub,
752
+ patch("java_functional_lsp.server._fire_and_forget"),
753
+ ):
754
+ server._on_jdtls_diagnostics("file:///a/has%20space.java", [])
755
+ mock_pub.assert_called_once_with("file:///a/has%20space.java")
340
756
 
341
757
  def test_init_capabilities_exclude_jdtls_features(self) -> None:
342
758
  """Static capabilities must NOT include hover/definition/references/completion/documentSymbol.
@@ -1547,6 +1963,50 @@ class TestFindLombokJar:
1547
1963
  assert any("does not exist" in r.getMessage() for r in caplog.records)
1548
1964
 
1549
1965
 
1966
+ class TestDidChangeConfiguration:
1967
+ """Tests for workspace/didChangeConfiguration forwarding."""
1968
+
1969
+ def test_forwards_to_jdtls_when_available(self) -> None:
1970
+ from unittest.mock import AsyncMock, patch
1971
+
1972
+ import java_functional_lsp.server as srv_mod
1973
+ from java_functional_lsp.server import on_did_change_configuration, server
1974
+
1975
+ srv_mod._last_config_forward = 0.0 # reset cooldown
1976
+ server._proxy._available = True
1977
+ server._proxy.send_notification = AsyncMock() # type: ignore[assignment]
1978
+ params = lsp.DidChangeConfigurationParams(settings={"java": {"autobuild": {"enabled": True}}})
1979
+ with patch("java_functional_lsp.server._fire_and_forget") as mock_faf:
1980
+ on_did_change_configuration(params)
1981
+ mock_faf.assert_called_once()
1982
+
1983
+ def test_throttled_within_cooldown(self) -> None:
1984
+ from unittest.mock import patch
1985
+
1986
+ import java_functional_lsp.server as srv_mod
1987
+ from java_functional_lsp.server import on_did_change_configuration, server
1988
+
1989
+ server._proxy._available = True
1990
+ import time
1991
+
1992
+ srv_mod._last_config_forward = time.monotonic() # just sent
1993
+ params = lsp.DidChangeConfigurationParams(settings={})
1994
+ with patch("java_functional_lsp.server._fire_and_forget") as mock_faf:
1995
+ on_did_change_configuration(params)
1996
+ mock_faf.assert_not_called()
1997
+
1998
+ def test_skipped_when_proxy_unavailable(self) -> None:
1999
+ from unittest.mock import patch
2000
+
2001
+ from java_functional_lsp.server import on_did_change_configuration, server
2002
+
2003
+ server._proxy._available = False
2004
+ params = lsp.DidChangeConfigurationParams(settings={})
2005
+ with patch("java_functional_lsp.server._fire_and_forget") as mock_faf:
2006
+ on_did_change_configuration(params)
2007
+ mock_faf.assert_not_called()
2008
+
2009
+
1550
2010
  class TestCacheClear:
1551
2011
  """Tests for _clear_cache_on_version_change() and _compute_cache_marker()."""
1552
2012