java-functional-lsp 0.9.13__tar.gz → 0.9.15__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 (70) hide show
  1. java_functional_lsp-0.9.15/.claude-plugin/plugin.json +16 -0
  2. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/PKG-INFO +21 -7
  3. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/README.md +20 -6
  4. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/SKILL.md +3 -6
  5. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/pyproject.toml +1 -1
  6. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/src/java_functional_lsp/__init__.py +1 -1
  7. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/src/java_functional_lsp/proxy.py +84 -0
  8. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/src/java_functional_lsp/server.py +21 -1
  9. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/tests/test_e2e_jdtls.py +16 -2
  10. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/tests/test_proxy.py +235 -0
  11. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/tests/test_server.py +117 -10
  12. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/uv.lock +1 -1
  13. java_functional_lsp-0.9.13/.claude-plugin/plugin.json +0 -5
  14. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/.githooks/pre-commit +0 -0
  15. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/.githooks/pre-push +0 -0
  16. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/.github/CODEOWNERS +0 -0
  17. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/.github/ISSUE_TEMPLATE/bug-report.md +0 -0
  18. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/.github/ISSUE_TEMPLATE/feature-request.md +0 -0
  19. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  20. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/.github/SECURITY.md +0 -0
  21. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/.github/dependabot.yml +0 -0
  22. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/.github/release-drafter.yml +0 -0
  23. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/.github/workflows/publish.yml +0 -0
  24. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/.github/workflows/release-drafter.yml +0 -0
  25. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/.github/workflows/stale.yml +0 -0
  26. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/.github/workflows/test.yml +0 -0
  27. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/.github/workflows/update-homebrew.yml +0 -0
  28. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/.github/workflows/vscode-ext.yml +0 -0
  29. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/.gitignore +0 -0
  30. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/CONTRIBUTING.md +0 -0
  31. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/LICENSE +0 -0
  32. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/commands/lint-java.md +0 -0
  33. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/editors/intellij/README.md +0 -0
  34. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/editors/intellij/lsp4ij-template.json +0 -0
  35. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/editors/vscode/.vscodeignore +0 -0
  36. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/editors/vscode/README.md +0 -0
  37. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/editors/vscode/package-lock.json +0 -0
  38. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/editors/vscode/package.json +0 -0
  39. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/editors/vscode/src/extension.ts +0 -0
  40. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/editors/vscode/tsconfig.json +0 -0
  41. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/hooks/hooks.json +0 -0
  42. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/hooks/java_linter_reminder.py +0 -0
  43. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/scripts/ensure-lsp.sh +0 -0
  44. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/scripts/generate-formula.py +0 -0
  45. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/src/java_functional_lsp/__main__.py +0 -0
  46. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/src/java_functional_lsp/analyzers/__init__.py +0 -0
  47. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/src/java_functional_lsp/analyzers/base.py +0 -0
  48. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/src/java_functional_lsp/analyzers/exception_checker.py +0 -0
  49. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/src/java_functional_lsp/analyzers/functional_checker.py +0 -0
  50. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/src/java_functional_lsp/analyzers/mutation_checker.py +0 -0
  51. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/src/java_functional_lsp/analyzers/null_checker.py +0 -0
  52. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/src/java_functional_lsp/analyzers/spring_checker.py +0 -0
  53. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/src/java_functional_lsp/cli.py +0 -0
  54. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/src/java_functional_lsp/fixes.py +0 -0
  55. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/src/java_functional_lsp/merkle.py +0 -0
  56. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/tests/__init__.py +0 -0
  57. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/tests/conftest.py +0 -0
  58. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/tests/test_base.py +0 -0
  59. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/tests/test_cli.py +0 -0
  60. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/tests/test_config.py +0 -0
  61. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/tests/test_e2e.py +0 -0
  62. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/tests/test_exception_checker.py +0 -0
  63. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/tests/test_fixes.py +0 -0
  64. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/tests/test_functional_checker.py +0 -0
  65. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/tests/test_merkle.py +0 -0
  66. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/tests/test_merkle_proxy.py +0 -0
  67. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/tests/test_mutation_checker.py +0 -0
  68. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/tests/test_null_checker.py +0 -0
  69. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/tests/test_spring_checker.py +0 -0
  70. {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/tests/test_suppress.py +0 -0
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "java-functional-lsp",
3
+ "description": "Java LSP with functional programming rules enforcement — null safety, immutability, no exceptions, Spring best practices. Wraps jdtls for full Java language support.",
4
+ "version": "0.4.2",
5
+ "lspServers": {
6
+ "java-functional": {
7
+ "command": "java-functional-lsp",
8
+ "extensionToLanguage": {
9
+ ".java": "java"
10
+ },
11
+ "startupTimeout": 120000,
12
+ "restartOnCrash": true,
13
+ "maxRestarts": 5
14
+ }
15
+ }
16
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: java-functional-lsp
3
- Version: 0.9.13
3
+ Version: 0.9.15
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
@@ -136,11 +136,17 @@ See [editors/intellij/README.md](editors/intellij/README.md) for detailed instru
136
136
 
137
137
  **Step 1: Enable LSP support** (required, one-time):
138
138
 
139
- Add to `~/.claude/settings.json`:
139
+ Add `lspServers` to `~/.claude/settings.json` (the plugin handles this automatically — only needed for manual setup):
140
140
  ```json
141
141
  {
142
- "env": {
143
- "ENABLE_LSP_TOOL": "1"
142
+ "lspServers": {
143
+ "java-functional": {
144
+ "command": "java-functional-lsp",
145
+ "extensionToLanguage": { ".java": "java" },
146
+ "startupTimeout": 120000,
147
+ "restartOnCrash": true,
148
+ "maxRestarts": 5
149
+ }
144
150
  }
145
151
  }
146
152
  ```
@@ -160,19 +166,27 @@ Or manually add to your Claude Code config:
160
166
  "lspServers": {
161
167
  "java-functional": {
162
168
  "command": "java-functional-lsp",
163
- "extensionToLanguage": { ".java": "java" }
169
+ "extensionToLanguage": { ".java": "java" },
170
+ "startupTimeout": 120000,
171
+ "restartOnCrash": true,
172
+ "maxRestarts": 5
164
173
  }
165
174
  }
166
175
  }
167
176
  ```
168
177
 
178
+ (`startupTimeout: 120000` accommodates jdtls cold-start; `restartOnCrash` keeps the server alive across session.)
179
+
169
180
  **Alternative: project-level `.lsp.json`** — instead of installing the plugin or editing global config, add a `.lsp.json` file at your project root:
170
181
 
171
182
  ```json
172
183
  {
173
184
  "java-functional": {
174
185
  "command": "java-functional-lsp",
175
- "extensionToLanguage": { ".java": "java" }
186
+ "extensionToLanguage": { ".java": "java" },
187
+ "startupTimeout": 120000,
188
+ "restartOnCrash": true,
189
+ "maxRestarts": 5
176
190
  }
177
191
  }
178
192
  ```
@@ -198,7 +212,7 @@ moving on. Fix any type errors or missing imports immediately.
198
212
 
199
213
  | Issue | Fix |
200
214
  |-------|-----|
201
- | No diagnostics appear | Ensure `ENABLE_LSP_TOOL=1` is set, restart Claude Code |
215
+ | No diagnostics appear | Ensure `lspServers` is configured (plugin or settings.json), restart |
202
216
  | "java-functional-lsp not found" | Run `brew install aviadshiber/tap/java-functional-lsp` |
203
217
  | Plugin not active | Run `claude plugin list` to verify, then `/reload-plugins` |
204
218
  | Diagnostics slow on first open | Normal — tree-sitter parses on first load, then incremental |
@@ -108,11 +108,17 @@ See [editors/intellij/README.md](editors/intellij/README.md) for detailed instru
108
108
 
109
109
  **Step 1: Enable LSP support** (required, one-time):
110
110
 
111
- Add to `~/.claude/settings.json`:
111
+ Add `lspServers` to `~/.claude/settings.json` (the plugin handles this automatically — only needed for manual setup):
112
112
  ```json
113
113
  {
114
- "env": {
115
- "ENABLE_LSP_TOOL": "1"
114
+ "lspServers": {
115
+ "java-functional": {
116
+ "command": "java-functional-lsp",
117
+ "extensionToLanguage": { ".java": "java" },
118
+ "startupTimeout": 120000,
119
+ "restartOnCrash": true,
120
+ "maxRestarts": 5
121
+ }
116
122
  }
117
123
  }
118
124
  ```
@@ -132,19 +138,27 @@ Or manually add to your Claude Code config:
132
138
  "lspServers": {
133
139
  "java-functional": {
134
140
  "command": "java-functional-lsp",
135
- "extensionToLanguage": { ".java": "java" }
141
+ "extensionToLanguage": { ".java": "java" },
142
+ "startupTimeout": 120000,
143
+ "restartOnCrash": true,
144
+ "maxRestarts": 5
136
145
  }
137
146
  }
138
147
  }
139
148
  ```
140
149
 
150
+ (`startupTimeout: 120000` accommodates jdtls cold-start; `restartOnCrash` keeps the server alive across session.)
151
+
141
152
  **Alternative: project-level `.lsp.json`** — instead of installing the plugin or editing global config, add a `.lsp.json` file at your project root:
142
153
 
143
154
  ```json
144
155
  {
145
156
  "java-functional": {
146
157
  "command": "java-functional-lsp",
147
- "extensionToLanguage": { ".java": "java" }
158
+ "extensionToLanguage": { ".java": "java" },
159
+ "startupTimeout": 120000,
160
+ "restartOnCrash": true,
161
+ "maxRestarts": 5
148
162
  }
149
163
  }
150
164
  ```
@@ -170,7 +184,7 @@ moving on. Fix any type errors or missing imports immediately.
170
184
 
171
185
  | Issue | Fix |
172
186
  |-------|-----|
173
- | No diagnostics appear | Ensure `ENABLE_LSP_TOOL=1` is set, restart Claude Code |
187
+ | No diagnostics appear | Ensure `lspServers` is configured (plugin or settings.json), restart |
174
188
  | "java-functional-lsp not found" | Run `brew install aviadshiber/tap/java-functional-lsp` |
175
189
  | Plugin not active | Run `claude plugin list` to verify, then `/reload-plugins` |
176
190
  | Diagnostics slow on first open | Normal — tree-sitter parses on first load, then incremental |
@@ -112,14 +112,11 @@ To release a new version:
112
112
 
113
113
  ## Enabling LSP in Claude Code
114
114
 
115
- LSP support requires `ENABLE_LSP_TOOL=1` in `~/.claude/settings.json`:
116
- ```json
117
- { "env": { "ENABLE_LSP_TOOL": "1" } }
118
- ```
115
+ Declare `lspServers` in `~/.claude/settings.json` or in `plugin.json` — Claude Code enables the LSP tool automatically when `lspServers` is configured. `ENABLE_LSP_TOOL=1` is no longer needed.
119
116
 
120
117
  For containers or CI, add a `.lsp.json` at the project root instead of installing the plugin:
121
118
  ```json
122
- { "java-functional": { "command": "java-functional-lsp", "extensionToLanguage": { ".java": "java" } } }
119
+ { "java-functional": { "command": "java-functional-lsp", "extensionToLanguage": { ".java": "java" }, "startupTimeout": 120000, "restartOnCrash": true, "maxRestarts": 5 } }
123
120
  ```
124
121
 
125
122
  To nudge Claude to act on diagnostics, add to your project's `CLAUDE.md`:
@@ -130,7 +127,7 @@ Fix any violations immediately — do not explain, just apply the fix.
130
127
 
131
128
  ## Troubleshooting
132
129
 
133
- - **No diagnostics in Claude Code**: Ensure `ENABLE_LSP_TOOL=1` is set, restart Claude Code
130
+ - **No diagnostics in Claude Code**: Ensure `lspServers` is configured (in `plugin.json` or `settings.json`), restart Claude Code
134
131
  - **"java-functional-lsp not found"**: Run `brew install aviadshiber/tap/java-functional-lsp`
135
132
  - **No completions/hover**: Install jdtls: `brew install jdtls` (requires JDK 21+)
136
133
  - **Too many warnings**: Create `.java-functional-lsp.json` with `excludes` or per-rule severity
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "java-functional-lsp"
7
- version = "0.9.13"
7
+ version = "0.9.15"
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.13"
3
+ __version__ = "0.9.14"
@@ -29,6 +29,7 @@ _START_RETRY_COOLDOWN = 300.0 # seconds — retry jdtls startup after transient
29
29
  DEFAULT_JVM_MAX_HEAP = "4g"
30
30
  _STDERR_LINE_MAX = 1000
31
31
  _MAX_EXPANDED_GROUPS: int = 5 # hard cap on concurrent Maven group workspace folders (prevents jdtls OOM)
32
+ _WORKSPACE_CACHE_MAX_SIZE: int = 10 # default LRU cap — override via {"cache": {"maxWorkspaces": N}}
32
33
 
33
34
  # Default jdtls initialization settings. Sent via initializationOptions.settings
34
35
  # so they apply BEFORE the Maven import scan (didChangeConfiguration is too late).
@@ -563,6 +564,87 @@ def _compute_module_diff(
563
564
  return (diff, current)
564
565
 
565
566
 
567
+ def _dir_mtime(path: Path) -> float:
568
+ """Return ``st_mtime`` of *path*, or ``float('inf')`` on any OSError.
569
+
570
+ Returning infinity on error sorts the directory as the *newest* entry,
571
+ so stat-inaccessible directories are conservatively kept rather than
572
+ silently evicted.
573
+ """
574
+ try:
575
+ return path.stat().st_mtime
576
+ except OSError:
577
+ return float("inf")
578
+
579
+
580
+ def _parse_max_workspaces(config: Mapping[str, Any] | None) -> int:
581
+ """Parse and validate ``cache.maxWorkspaces`` from the project config.
582
+
583
+ Returns *_WORKSPACE_CACHE_MAX_SIZE* when the key is absent, when the value
584
+ cannot be converted to ``int``, or when the result is not positive. Logs a
585
+ warning in each error case so the user can diagnose the misconfiguration.
586
+ """
587
+ raw = (config or {}).get("cache", {}).get("maxWorkspaces")
588
+ if raw is None:
589
+ return _WORKSPACE_CACHE_MAX_SIZE
590
+ try:
591
+ value = int(raw)
592
+ except (TypeError, ValueError):
593
+ logger.warning(
594
+ "jdtls: invalid cache.maxWorkspaces %r — using default %d",
595
+ raw,
596
+ _WORKSPACE_CACHE_MAX_SIZE,
597
+ )
598
+ return _WORKSPACE_CACHE_MAX_SIZE
599
+ if value <= 0:
600
+ logger.warning(
601
+ "jdtls: cache.maxWorkspaces must be >= 1, got %d — using default %d",
602
+ value,
603
+ _WORKSPACE_CACHE_MAX_SIZE,
604
+ )
605
+ return _WORKSPACE_CACHE_MAX_SIZE
606
+ return value
607
+
608
+
609
+ def _evict_lru_workspaces(cache_root: Path, *, max_size: int = _WORKSPACE_CACHE_MAX_SIZE) -> None:
610
+ """Evict least-recently-used jdtls workspace directories when count exceeds *max_size*.
611
+
612
+ Implements Caffeine's ``maximumSize`` eviction policy for a filesystem cache.
613
+ Directories are ordered by ``st_mtime`` (last modification time), which is a
614
+ reliable proxy for last-used time because jdtls always writes to its workspace
615
+ on startup. On macOS APFS (``noatime`` default) ``atime`` is not updated on
616
+ reads, so ``mtime`` is used exclusively.
617
+
618
+ The *max_size* most-recently-modified real directories are kept; the rest are
619
+ removed. Dotfiles (e.g. ``.version``), symlinks, and non-directory entries
620
+ are excluded from the eviction candidates.
621
+
622
+ A directory whose ``stat()`` raises ``OSError`` is treated as the *newest*
623
+ entry (``float('inf')`` mtime) so it is conservatively kept.
624
+
625
+ Runs in the executor thread (``_blocking_startup``) — never blocks the event loop.
626
+
627
+ Users can tune the cap via ``.java-functional-lsp.json``:
628
+ ``{"cache": {"maxWorkspaces": 20}}``
629
+ """
630
+ if not cache_root.is_dir():
631
+ return
632
+ try:
633
+ dirs = [d for d in cache_root.iterdir() if d.is_dir() and not d.is_symlink() and not d.name.startswith(".")]
634
+ except OSError as e:
635
+ logger.warning("jdtls: cannot scan cache root for eviction: %s", e)
636
+ return
637
+ if len(dirs) <= max_size:
638
+ return
639
+ dirs.sort(key=_dir_mtime, reverse=True) # newest first
640
+ for stale in dirs[max_size:]:
641
+ try:
642
+ shutil.rmtree(stale)
643
+ logger.info("jdtls: evicted workspace cache %s (LRU)", stale.name)
644
+ except OSError as e:
645
+ logger.warning("jdtls: failed to evict workspace cache %s: %s", stale.name, e)
646
+
647
+
566
648
  def _wipe_data_dir(data_dir: Path) -> None:
567
649
  """Remove *data_dir* so the next jdtls startup gets a clean cold start.
568
650
 
@@ -793,6 +875,8 @@ class JdtlsProxy:
793
875
 
794
876
  def _blocking_startup() -> tuple[dict[str, str], str | None]:
795
877
  _clear_cache_on_version_change(cache_root)
878
+ max_workspaces = _parse_max_workspaces(config)
879
+ _evict_lru_workspaces(cache_root, max_size=max_workspaces)
796
880
  return build_jdtls_env(), _find_lombok_jar(config)
797
881
 
798
882
  loop = asyncio.get_running_loop()
@@ -369,6 +369,24 @@ def _jdtls_raw_to_lsp_diagnostics(raw_diagnostics: list[Any]) -> list[lsp.Diagno
369
369
 
370
370
  _MAX_PATTERN_LENGTH = 500 # Cap regex length to mitigate ReDoS from pathological patterns
371
371
 
372
+ _M2E_SOURCES: frozenset[str] = frozenset({"org.eclipse.m2e"})
373
+ _M2E_MESSAGE_PATTERNS: re.Pattern[str] = re.compile(
374
+ r"Plugin execution not covered by lifecycle configuration"
375
+ r"|The project cannot be built until its prerequisite"
376
+ r"|Failed to execute mojo"
377
+ )
378
+
379
+
380
+ def _is_m2e_marker(diag: dict[str, Any]) -> bool:
381
+ """Return True if the diagnostic is an Eclipse/M2E lifecycle marker (not a Java error)."""
382
+ if str(diag.get("code", "")) == "0":
383
+ return True
384
+ if diag.get("source", "") in _M2E_SOURCES:
385
+ return True
386
+ if _M2E_MESSAGE_PATTERNS.search(diag.get("message", "")):
387
+ return True
388
+ return False
389
+
372
390
 
373
391
  def _compile_user_patterns(config: dict[str, Any]) -> list[re.Pattern[str]]:
374
392
  """Compile user-defined suppressJdtlsPatterns from config."""
@@ -417,7 +435,9 @@ def _run_analysis(source: str, uri: str) -> list[lsp.Diagnostic]:
417
435
  if server._proxy.is_available:
418
436
  try:
419
437
  raw = server._proxy.get_cached_diagnostics(uri)
420
- raw = [d for d in raw if not _is_jdtls_suppressed(d, server._user_suppress_patterns)]
438
+ raw = [
439
+ d for d in raw if not _is_m2e_marker(d) and not _is_jdtls_suppressed(d, server._user_suppress_patterns)
440
+ ]
421
441
  jdtls_diags = _jdtls_raw_to_lsp_diagnostics(raw)
422
442
  except Exception as e:
423
443
  logger.warning("jdtls diagnostic processing failed for %s: %s", uri, e)
@@ -68,6 +68,10 @@ pytestmark = [
68
68
  # so a hung jdtls doesn't wedge the suite.
69
69
  _E2E_TEST_TIMEOUT_SEC = 120
70
70
  _JDTLS_PARSE_WAIT_SEC = 2.5
71
+ # Max wall-clock seconds to poll for a non-empty documentSymbol result.
72
+ # macOS CI runners are slower than Linux; 60s accommodates cold-start variance.
73
+ _JDTLS_SYMBOL_POLL_TIMEOUT_SEC = 60
74
+ _JDTLS_SYMBOL_POLL_INTERVAL_SEC = 1.0
71
75
 
72
76
  _HELLO_JAVA = """\
73
77
  public class Hello {
@@ -260,7 +264,16 @@ class TestJdtlsEndToEnd:
260
264
  assert "textDocument" in serialized, f"_serialize_params emitted wrong field names: {serialized.keys()}"
261
265
  assert "text_document" not in serialized
262
266
 
263
- result = await proxy.send_request("textDocument/documentSymbol", serialized)
267
+ # Poll until jdtls returns a non-empty symbol list or we time out.
268
+ # A fixed sleep is unreliable on macOS CI where jdtls cold-start
269
+ # can reach 20s; polling adapts to the actual indexing speed.
270
+ deadline = asyncio.get_event_loop().time() + _JDTLS_SYMBOL_POLL_TIMEOUT_SEC
271
+ result = None
272
+ while asyncio.get_event_loop().time() < deadline:
273
+ result = await proxy.send_request("textDocument/documentSymbol", serialized)
274
+ if result:
275
+ break
276
+ await asyncio.sleep(_JDTLS_SYMBOL_POLL_INTERVAL_SEC)
264
277
 
265
278
  # Primary assertion: jdtls returned something. With a correctly-shaped
266
279
  # request, jdtls always returns a list (possibly empty) for a parsable
@@ -274,7 +287,8 @@ class TestJdtlsEndToEnd:
274
287
  assert isinstance(result, list)
275
288
  # Hello.java declares a top-level class, so we expect at least one symbol.
276
289
  assert len(result) > 0, (
277
- "jdtls returned an empty symbol list for a file with a top-level class. "
290
+ "jdtls returned an empty symbol list for a file with a top-level class "
291
+ f"after polling for {_JDTLS_SYMBOL_POLL_TIMEOUT_SEC}s. "
278
292
  "Either jdtls didn't finish parsing or the file wasn't opened correctly."
279
293
  )
280
294
 
@@ -4,7 +4,10 @@ from __future__ import annotations
4
4
 
5
5
  import asyncio
6
6
  import json
7
+ import os
8
+ import shutil
7
9
  import subprocess
10
+ import time
8
11
  from pathlib import Path
9
12
  from typing import Any
10
13
  from unittest.mock import MagicMock, patch
@@ -1517,3 +1520,235 @@ class TestWipeDataDir:
1517
1520
  from java_functional_lsp.proxy import _wipe_data_dir
1518
1521
 
1519
1522
  _wipe_data_dir(tmp_path / "ghost") # should not raise
1523
+
1524
+
1525
+ class TestEvictLruWorkspaces:
1526
+ """Tests for _evict_lru_workspaces — count-based LRU eviction of jdtls workspace dirs."""
1527
+
1528
+ @staticmethod
1529
+ def _make_workspace(cache_root: Path, name: str, mtime_age_days: float) -> Path:
1530
+ """Create a fake workspace dir and backdate its mtime."""
1531
+ d = cache_root / name
1532
+ d.mkdir()
1533
+ ts = time.time() - mtime_age_days * 86400.0
1534
+ os.utime(d, (ts, ts))
1535
+ return d
1536
+
1537
+ def test_noop_on_missing_cache_root(self, tmp_path: Path) -> None:
1538
+ """No error when cache_root does not exist."""
1539
+ from java_functional_lsp.proxy import _evict_lru_workspaces
1540
+
1541
+ _evict_lru_workspaces(tmp_path / "nonexistent", max_size=10)
1542
+ # No exception raised — that is the assertion
1543
+
1544
+ def test_noop_when_at_or_below_max_size(self, tmp_path: Path) -> None:
1545
+ """No eviction when count <= max_size."""
1546
+ from java_functional_lsp.proxy import _evict_lru_workspaces
1547
+
1548
+ d1 = self._make_workspace(tmp_path, "aabbccdd1234", mtime_age_days=5)
1549
+ d2 = self._make_workspace(tmp_path, "1122334455ab", mtime_age_days=3)
1550
+ _evict_lru_workspaces(tmp_path, max_size=2)
1551
+ assert d1.exists()
1552
+ assert d2.exists()
1553
+
1554
+ def test_evicts_oldest_when_over_max_size(self, tmp_path: Path) -> None:
1555
+ """Oldest dirs (lowest mtime) are removed; newest are kept."""
1556
+ from java_functional_lsp.proxy import _evict_lru_workspaces
1557
+
1558
+ oldest = self._make_workspace(tmp_path, "aabbccdd1111", mtime_age_days=10)
1559
+ middle = self._make_workspace(tmp_path, "aabbccdd2222", mtime_age_days=5)
1560
+ newest = self._make_workspace(tmp_path, "aabbccdd3333", mtime_age_days=1)
1561
+ _evict_lru_workspaces(tmp_path, max_size=2)
1562
+ assert not oldest.exists()
1563
+ assert middle.exists()
1564
+ assert newest.exists()
1565
+
1566
+ def test_dotfiles_never_evicted(self, tmp_path: Path) -> None:
1567
+ """.version and other dotfiles are excluded from eviction."""
1568
+ from java_functional_lsp.proxy import _evict_lru_workspaces
1569
+
1570
+ marker = tmp_path / ".version"
1571
+ marker.write_text("abc|21")
1572
+ old_ts = time.time() - 999 * 86400.0
1573
+ os.utime(marker, (old_ts, old_ts))
1574
+ # max_size=0 would evict everything — dotfiles must still survive
1575
+ _evict_lru_workspaces(tmp_path, max_size=0)
1576
+ assert marker.exists()
1577
+
1578
+ def test_dotdirs_never_evicted(self, tmp_path: Path) -> None:
1579
+ """Dotfile directories (e.g. .hidden/) are excluded from eviction."""
1580
+ from java_functional_lsp.proxy import _evict_lru_workspaces
1581
+
1582
+ hidden_dir = tmp_path / ".hidden"
1583
+ hidden_dir.mkdir()
1584
+ old_ts = time.time() - 999 * 86400.0
1585
+ os.utime(hidden_dir, (old_ts, old_ts))
1586
+ _evict_lru_workspaces(tmp_path, max_size=0)
1587
+ assert hidden_dir.exists()
1588
+
1589
+ def test_non_directory_entries_ignored(self, tmp_path: Path) -> None:
1590
+ """Plain files in cache_root are never removed."""
1591
+ from java_functional_lsp.proxy import _evict_lru_workspaces
1592
+
1593
+ stray = tmp_path / "stray.txt"
1594
+ stray.write_text("irrelevant")
1595
+ old_ts = time.time() - 999 * 86400.0
1596
+ os.utime(stray, (old_ts, old_ts))
1597
+ _evict_lru_workspaces(tmp_path, max_size=0)
1598
+ assert stray.exists()
1599
+
1600
+ def test_symlinks_never_evicted(self, tmp_path: Path) -> None:
1601
+ """Symlinks in cache_root are excluded — shutil.rmtree must not follow them."""
1602
+ from java_functional_lsp.proxy import _evict_lru_workspaces
1603
+
1604
+ # Target is a dotdir (excluded from candidates) so it is never evicted.
1605
+ # This prevents the symlink from going dangling during the test.
1606
+ target = tmp_path / ".hidden_target"
1607
+ target.mkdir()
1608
+ link = tmp_path / "aabbccdd1111"
1609
+ link.symlink_to(target)
1610
+ old_ts = time.time() - 999 * 86400.0
1611
+ os.utime(link, (old_ts, old_ts))
1612
+ _evict_lru_workspaces(tmp_path, max_size=0)
1613
+ assert link.exists() # symlink itself must survive
1614
+ assert target.exists() # target must not be deleted
1615
+
1616
+ def test_dir_mtime_returns_inf_on_oserror(self, tmp_path: Path) -> None:
1617
+ """_dir_mtime returns float('inf') when stat() fails — non-zero sentinel."""
1618
+ from java_functional_lsp.proxy import _dir_mtime
1619
+
1620
+ assert _dir_mtime(tmp_path / "nonexistent") == float("inf")
1621
+
1622
+ def test_stat_error_conservatively_keeps_dir(self, tmp_path: Path) -> None:
1623
+ """Dir whose _dir_mtime returns inf is sorted as newest — conservatively kept.
1624
+
1625
+ Patching _dir_mtime directly (not Path.stat) avoids interfering with
1626
+ is_dir() which also calls stat() internally.
1627
+ """
1628
+ from java_functional_lsp.proxy import _evict_lru_workspaces
1629
+
1630
+ older = self._make_workspace(tmp_path, "aabbccdd1111", mtime_age_days=10)
1631
+ kept_dir = self._make_workspace(tmp_path, "aabbccdd2222", mtime_age_days=5)
1632
+
1633
+ original_dir_mtime = proxy_mod._dir_mtime
1634
+
1635
+ def patched_dir_mtime(path: Path) -> float:
1636
+ if path.name == kept_dir.name:
1637
+ return float("inf")
1638
+ return original_dir_mtime(path)
1639
+
1640
+ with patch("java_functional_lsp.proxy._dir_mtime", patched_dir_mtime):
1641
+ # kept_dir: _dir_mtime returns inf → sorted as newest → kept
1642
+ # older: mtime=10 days ago → sorted as oldest → evicted
1643
+ _evict_lru_workspaces(tmp_path, max_size=1)
1644
+
1645
+ assert kept_dir.exists() # conservatively kept (inf mtime)
1646
+ assert not older.exists() # evicted as the oldest accessible dir
1647
+
1648
+ def test_rmtree_error_logged_and_continued(self, tmp_path: Path) -> None:
1649
+ """OSError on rmtree emits a warning and continues evicting remaining dirs."""
1650
+ from java_functional_lsp.proxy import _evict_lru_workspaces
1651
+
1652
+ d1 = self._make_workspace(tmp_path, "aabbccdd1111", mtime_age_days=10)
1653
+ self._make_workspace(tmp_path, "aabbccdd2222", mtime_age_days=5)
1654
+ d3 = self._make_workspace(tmp_path, "aabbccdd3333", mtime_age_days=1)
1655
+
1656
+ call_count = 0
1657
+ original_rmtree = shutil.rmtree
1658
+
1659
+ def patched_rmtree(path: Any, **kw: Any) -> None:
1660
+ nonlocal call_count
1661
+ call_count += 1
1662
+ if Path(path).name == d1.name:
1663
+ raise OSError("busy")
1664
+ original_rmtree(path, **kw)
1665
+
1666
+ with patch("java_functional_lsp.proxy.shutil.rmtree", side_effect=patched_rmtree):
1667
+ with patch("java_functional_lsp.proxy.logger") as mock_logger:
1668
+ _evict_lru_workspaces(tmp_path, max_size=1)
1669
+ # Warning for d1 (rmtree failed); info for d2 (evicted successfully)
1670
+ assert mock_logger.warning.call_count >= 1
1671
+ assert mock_logger.info.call_count >= 1
1672
+
1673
+ assert d3.exists() # newest kept
1674
+
1675
+ def test_logs_info_on_eviction(self, tmp_path: Path) -> None:
1676
+ """Info log is emitted for each evicted workspace dir, including 'LRU'."""
1677
+ from java_functional_lsp.proxy import _evict_lru_workspaces
1678
+
1679
+ self._make_workspace(tmp_path, "aabbccdd1111", mtime_age_days=10)
1680
+ self._make_workspace(tmp_path, "aabbccdd2222", mtime_age_days=1)
1681
+
1682
+ with patch("java_functional_lsp.proxy.logger") as mock_logger:
1683
+ _evict_lru_workspaces(tmp_path, max_size=1)
1684
+
1685
+ info_calls = [str(c) for c in mock_logger.info.call_args_list]
1686
+ assert any("aabbccdd1111" in m and "LRU" in m for m in info_calls)
1687
+
1688
+ def test_max_size_param_respected(self, tmp_path: Path) -> None:
1689
+ """Passing max_size=3 keeps exactly 3 dirs (the 3 newest)."""
1690
+ from java_functional_lsp.proxy import _evict_lru_workspaces
1691
+
1692
+ dirs = [self._make_workspace(tmp_path, f"workspace{i:04d}", mtime_age_days=float(10 - i)) for i in range(6)]
1693
+ _evict_lru_workspaces(tmp_path, max_size=3)
1694
+ surviving = [d for d in dirs if d.exists()]
1695
+ assert len(surviving) == 3
1696
+ # The 3 newest (smallest age = largest mtime) must survive
1697
+ for d in dirs[-3:]:
1698
+ assert d.exists()
1699
+
1700
+
1701
+ class TestParseMaxWorkspaces:
1702
+ """Tests for _parse_max_workspaces — config validation for the LRU cap."""
1703
+
1704
+ def test_none_config_returns_default(self) -> None:
1705
+ from java_functional_lsp.proxy import _WORKSPACE_CACHE_MAX_SIZE, _parse_max_workspaces
1706
+
1707
+ assert _parse_max_workspaces(None) == _WORKSPACE_CACHE_MAX_SIZE
1708
+
1709
+ def test_empty_config_returns_default(self) -> None:
1710
+ from java_functional_lsp.proxy import _WORKSPACE_CACHE_MAX_SIZE, _parse_max_workspaces
1711
+
1712
+ assert _parse_max_workspaces({}) == _WORKSPACE_CACHE_MAX_SIZE
1713
+
1714
+ def test_missing_cache_key_returns_default(self) -> None:
1715
+ from java_functional_lsp.proxy import _WORKSPACE_CACHE_MAX_SIZE, _parse_max_workspaces
1716
+
1717
+ assert _parse_max_workspaces({"jdtls": {}}) == _WORKSPACE_CACHE_MAX_SIZE
1718
+
1719
+ def test_valid_value_is_returned(self) -> None:
1720
+ from java_functional_lsp.proxy import _parse_max_workspaces
1721
+
1722
+ assert _parse_max_workspaces({"cache": {"maxWorkspaces": 5}}) == 5
1723
+ assert _parse_max_workspaces({"cache": {"maxWorkspaces": 1}}) == 1
1724
+ assert _parse_max_workspaces({"cache": {"maxWorkspaces": 100}}) == 100
1725
+
1726
+ def test_string_integer_is_accepted(self) -> None:
1727
+ """A string that represents a valid integer (e.g. from JSON) is parsed."""
1728
+ from java_functional_lsp.proxy import _parse_max_workspaces
1729
+
1730
+ assert _parse_max_workspaces({"cache": {"maxWorkspaces": "7"}}) == 7
1731
+
1732
+ def test_invalid_string_returns_default_with_warning(self) -> None:
1733
+ from java_functional_lsp.proxy import _WORKSPACE_CACHE_MAX_SIZE, _parse_max_workspaces
1734
+
1735
+ with patch("java_functional_lsp.proxy.logger") as mock_logger:
1736
+ result = _parse_max_workspaces({"cache": {"maxWorkspaces": "bad"}})
1737
+ assert result == _WORKSPACE_CACHE_MAX_SIZE
1738
+ mock_logger.warning.assert_called_once()
1739
+
1740
+ def test_zero_returns_default_with_warning(self) -> None:
1741
+ from java_functional_lsp.proxy import _WORKSPACE_CACHE_MAX_SIZE, _parse_max_workspaces
1742
+
1743
+ with patch("java_functional_lsp.proxy.logger") as mock_logger:
1744
+ result = _parse_max_workspaces({"cache": {"maxWorkspaces": 0}})
1745
+ assert result == _WORKSPACE_CACHE_MAX_SIZE
1746
+ mock_logger.warning.assert_called_once()
1747
+
1748
+ def test_negative_returns_default_with_warning(self) -> None:
1749
+ from java_functional_lsp.proxy import _WORKSPACE_CACHE_MAX_SIZE, _parse_max_workspaces
1750
+
1751
+ with patch("java_functional_lsp.proxy.logger") as mock_logger:
1752
+ result = _parse_max_workspaces({"cache": {"maxWorkspaces": -1}})
1753
+ assert result == _WORKSPACE_CACHE_MAX_SIZE
1754
+ mock_logger.warning.assert_called_once()
@@ -71,10 +71,15 @@ async def lsp_client(tmp_path: Any) -> LanguageClient: # type: ignore[misc]
71
71
 
72
72
  # Collect published diagnostics so tests can assert on them.
73
73
  client._published: dict[str, list[lsp.Diagnostic]] = {} # type: ignore[attr-defined]
74
+ # Per-URI events signalled by on_publish — used by _open_and_wait_for_diagnostics.
75
+ client._diag_events: dict[str, asyncio.Event] = {} # type: ignore[attr-defined]
74
76
 
75
77
  @client.feature(lsp.TEXT_DOCUMENT_PUBLISH_DIAGNOSTICS)
76
78
  def on_publish(params: lsp.PublishDiagnosticsParams) -> None:
77
79
  client._published[params.uri] = list(params.diagnostics) # type: ignore[attr-defined]
80
+ evt = client._diag_events.get(params.uri) # type: ignore[attr-defined]
81
+ if evt is not None:
82
+ evt.set()
78
83
 
79
84
  await client.start_io(sys.executable, "-m", "java_functional_lsp")
80
85
 
@@ -121,10 +126,21 @@ async def _open_and_wait_for_diagnostics(
121
126
  ) -> list[lsp.Diagnostic]:
122
127
  """Open a document and wait until publishDiagnostics arrives for its URI.
123
128
 
124
- The server publishes diagnostics asynchronously after didOpen. We poll
125
- the client's collected notifications until the URI appears or timeout.
129
+ The server publishes diagnostics asynchronously after didOpen. We register
130
+ an asyncio.Event that on_publish signals, then await it with wait_for so the
131
+ full timeout is always respected — even when the event loop is transiently
132
+ busy (e.g. jdtls subprocess creation on a slow macOS runner).
133
+
134
+ Note: the server always emits publishDiagnostics for every opened file
135
+ (including clean ones with zero findings) synchronously inside on_did_open,
136
+ before jdtls is involved. The event is therefore guaranteed to fire on the
137
+ first — and correct — notification for each URI in this test setup.
126
138
  """
127
139
  client._published.pop(uri, None) # type: ignore[attr-defined]
140
+ # Register event before sending didOpen — no await between these two lines,
141
+ # so on_publish cannot fire and miss the registration.
142
+ event = asyncio.Event()
143
+ client._diag_events[uri] = event # type: ignore[attr-defined]
128
144
 
129
145
  client.text_document_did_open(
130
146
  lsp.DidOpenTextDocumentParams(
@@ -137,14 +153,18 @@ async def _open_and_wait_for_diagnostics(
137
153
  )
138
154
  )
139
155
 
140
- deadline = asyncio.get_running_loop().time() + timeout
141
- while asyncio.get_running_loop().time() < deadline:
142
- if uri in client._published: # type: ignore[attr-defined]
143
- return client._published[uri] # type: ignore[attr-defined]
144
- await asyncio.sleep(0.1)
156
+ try:
157
+ await asyncio.wait_for(event.wait(), timeout=timeout)
158
+ except asyncio.TimeoutError:
159
+ # pytest.fail() raises Failed (a BaseException subclass);
160
+ # the finally block below still runs before the exception propagates.
161
+ pytest.fail(f"Timed out waiting for publishDiagnostics on {uri}")
162
+ finally:
163
+ client._diag_events.pop(uri, None) # type: ignore[attr-defined]
145
164
 
146
- pytest.fail(f"Timed out waiting for publishDiagnostics on {uri}")
147
- return [] # unreachable, but satisfies type checker
165
+ # on_publish stores to _published before setting the event, so the key
166
+ # is guaranteed to exist here — use direct access to surface any violation.
167
+ return client._published[uri] # type: ignore[attr-defined]
148
168
 
149
169
 
150
170
  # --------------------------------------------------------------------------
@@ -2077,7 +2097,7 @@ class TestCacheClear:
2077
2097
  # --------------------------------------------------------------------------
2078
2098
 
2079
2099
 
2080
- @pytest.mark.timeout(30)
2100
+ @pytest.mark.timeout(60) # covers fixture setup (subprocess spawn + initialize handshake) on slow macOS runners
2081
2101
  class TestLspLifecycle:
2082
2102
  """Full LSP lifecycle tests via real stdio transport — zero mocks."""
2083
2103
 
@@ -2234,3 +2254,90 @@ class TestLspLifecycle:
2234
2254
  assert uri in lsp_client._published, "Timed out waiting for updated diagnostics after didChange" # type: ignore[attr-defined]
2235
2255
  new_diags = lsp_client._published[uri] # type: ignore[attr-defined]
2236
2256
  assert len(new_diags) == 0, f"Expected zero diagnostics after fixing, got {[d.code for d in new_diags]}"
2257
+
2258
+
2259
+ class TestM2EMarkerFilter:
2260
+ """Tests for built-in suppression of Eclipse/M2E lifecycle markers."""
2261
+
2262
+ def test_code_zero_string_is_suppressed(self) -> None:
2263
+ from java_functional_lsp.server import _is_m2e_marker
2264
+
2265
+ assert _is_m2e_marker({"code": "0", "message": "some message"}) is True
2266
+
2267
+ def test_code_zero_int_is_suppressed(self) -> None:
2268
+ from java_functional_lsp.server import _is_m2e_marker
2269
+
2270
+ assert _is_m2e_marker({"code": 0, "message": "some message"}) is True
2271
+
2272
+ def test_m2e_source_is_suppressed(self) -> None:
2273
+ from java_functional_lsp.server import _is_m2e_marker
2274
+
2275
+ assert _is_m2e_marker({"code": "100", "source": "org.eclipse.m2e", "message": "anything"}) is True
2276
+
2277
+ def test_plugin_execution_message_is_suppressed(self) -> None:
2278
+ from java_functional_lsp.server import _is_m2e_marker
2279
+
2280
+ diag = {"message": "Plugin execution not covered by lifecycle configuration: maven-antrun-plugin:3.1.0:run"}
2281
+ assert _is_m2e_marker(diag) is True
2282
+
2283
+ def test_prerequisite_message_is_suppressed(self) -> None:
2284
+ from java_functional_lsp.server import _is_m2e_marker
2285
+
2286
+ diag = {"message": "The project cannot be built until its prerequisite com.example-core is built."}
2287
+ assert _is_m2e_marker(diag) is True
2288
+
2289
+ def test_failed_mojo_message_is_suppressed(self) -> None:
2290
+ from java_functional_lsp.server import _is_m2e_marker
2291
+
2292
+ diag = {"message": "Failed to execute mojo org.apache.maven.plugins:maven-dependency-plugin:3.6.0"}
2293
+ assert _is_m2e_marker(diag) is True
2294
+
2295
+ def test_non_project_code_16_is_not_suppressed(self) -> None:
2296
+ """Code 16 (jdtls non-project) must not be caught by the M2E filter."""
2297
+ from java_functional_lsp.server import _is_m2e_marker
2298
+
2299
+ diag = {"code": "16", "source": "jdtls", "message": "File not on the classpath"}
2300
+ assert _is_m2e_marker(diag) is False
2301
+
2302
+ def test_real_java_error_is_not_suppressed(self) -> None:
2303
+ from java_functional_lsp.server import _is_m2e_marker
2304
+
2305
+ diag = {"code": "100", "source": "jdtls", "message": "The method foo() is undefined for the type Bar"}
2306
+ assert _is_m2e_marker(diag) is False
2307
+
2308
+ def test_missing_fields_do_not_raise(self) -> None:
2309
+ from java_functional_lsp.server import _is_m2e_marker
2310
+
2311
+ assert _is_m2e_marker({}) is False
2312
+
2313
+ def test_m2e_marker_excluded_from_run_analysis(self) -> None:
2314
+ """_run_analysis must drop M2E markers from jdtls cached diagnostics."""
2315
+ from unittest.mock import patch
2316
+
2317
+ from java_functional_lsp.server import _run_analysis
2318
+
2319
+ m2e_diag = {
2320
+ "code": "0",
2321
+ "source": "org.eclipse.m2e",
2322
+ "message": "Plugin execution not covered by lifecycle configuration",
2323
+ }
2324
+ real_diag = {
2325
+ "code": "50",
2326
+ "source": "jdtls",
2327
+ "message": "The method foo() is undefined",
2328
+ "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 5}},
2329
+ "severity": 1,
2330
+ }
2331
+
2332
+ with patch("java_functional_lsp.server.server") as mock_server:
2333
+ mock_server._proxy.is_available = True
2334
+ mock_server._proxy.get_cached_diagnostics.return_value = [m2e_diag, real_diag]
2335
+ mock_server._user_suppress_patterns = []
2336
+ mock_server._config = {}
2337
+ mock_server._parser = __import__("java_functional_lsp.analyzers.base", fromlist=["get_parser"]).get_parser()
2338
+
2339
+ result = _run_analysis("public class Foo {}", "file:///test/Foo.java")
2340
+
2341
+ codes = [str(d.code) for d in result if d.source == "jdtls"]
2342
+ assert "0" not in codes, "M2E marker (code=0) should be filtered out"
2343
+ assert "50" in codes, "Real jdtls diagnostic should still be present"
@@ -184,7 +184,7 @@ wheels = [
184
184
 
185
185
  [[package]]
186
186
  name = "java-functional-lsp"
187
- version = "0.9.6"
187
+ version = "0.9.14"
188
188
  source = { editable = "." }
189
189
  dependencies = [
190
190
  { name = "pygls" },
@@ -1,5 +0,0 @@
1
- {
2
- "name": "java-functional-lsp",
3
- "description": "Java LSP with functional programming rules enforcement — null safety, immutability, no exceptions, Spring best practices. Wraps jdtls for full Java language support.",
4
- "version": "0.4.1"
5
- }