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.
- java_functional_lsp-0.9.15/.claude-plugin/plugin.json +16 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/PKG-INFO +21 -7
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/README.md +20 -6
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/SKILL.md +3 -6
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/pyproject.toml +1 -1
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/src/java_functional_lsp/__init__.py +1 -1
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/src/java_functional_lsp/proxy.py +84 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/src/java_functional_lsp/server.py +21 -1
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/tests/test_e2e_jdtls.py +16 -2
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/tests/test_proxy.py +235 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/tests/test_server.py +117 -10
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/uv.lock +1 -1
- java_functional_lsp-0.9.13/.claude-plugin/plugin.json +0 -5
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/.githooks/pre-commit +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/.githooks/pre-push +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/.github/CODEOWNERS +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/.github/ISSUE_TEMPLATE/bug-report.md +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/.github/ISSUE_TEMPLATE/feature-request.md +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/.github/SECURITY.md +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/.github/dependabot.yml +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/.github/release-drafter.yml +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/.github/workflows/publish.yml +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/.github/workflows/release-drafter.yml +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/.github/workflows/stale.yml +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/.github/workflows/test.yml +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/.github/workflows/update-homebrew.yml +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/.github/workflows/vscode-ext.yml +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/.gitignore +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/CONTRIBUTING.md +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/LICENSE +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/commands/lint-java.md +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/editors/intellij/README.md +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/editors/intellij/lsp4ij-template.json +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/editors/vscode/.vscodeignore +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/editors/vscode/README.md +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/editors/vscode/package-lock.json +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/editors/vscode/package.json +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/editors/vscode/src/extension.ts +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/editors/vscode/tsconfig.json +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/hooks/hooks.json +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/hooks/java_linter_reminder.py +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/scripts/ensure-lsp.sh +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/scripts/generate-formula.py +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/src/java_functional_lsp/__main__.py +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/src/java_functional_lsp/analyzers/__init__.py +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/src/java_functional_lsp/analyzers/base.py +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/src/java_functional_lsp/analyzers/exception_checker.py +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/src/java_functional_lsp/analyzers/functional_checker.py +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/src/java_functional_lsp/analyzers/mutation_checker.py +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/src/java_functional_lsp/analyzers/null_checker.py +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/src/java_functional_lsp/analyzers/spring_checker.py +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/src/java_functional_lsp/cli.py +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/src/java_functional_lsp/fixes.py +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/src/java_functional_lsp/merkle.py +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/tests/__init__.py +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/tests/conftest.py +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/tests/test_base.py +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/tests/test_cli.py +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/tests/test_config.py +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/tests/test_e2e.py +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/tests/test_exception_checker.py +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/tests/test_fixes.py +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/tests/test_functional_checker.py +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/tests/test_merkle.py +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/tests/test_merkle_proxy.py +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/tests/test_mutation_checker.py +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/tests/test_null_checker.py +0 -0
- {java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/tests/test_spring_checker.py +0 -0
- {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.
|
|
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
|
-
"
|
|
143
|
-
"
|
|
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 `
|
|
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
|
-
"
|
|
115
|
-
"
|
|
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 `
|
|
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
|
|
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 `
|
|
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.
|
|
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" }
|
|
@@ -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 = [
|
|
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
|
-
|
|
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
|
|
125
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
147
|
-
|
|
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(
|
|
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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/.github/ISSUE_TEMPLATE/bug-report.md
RENAMED
|
File without changes
|
{java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/.github/ISSUE_TEMPLATE/feature-request.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/.github/workflows/release-drafter.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/.github/workflows/update-homebrew.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/editors/intellij/lsp4ij-template.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/src/java_functional_lsp/__main__.py
RENAMED
|
File without changes
|
|
File without changes
|
{java_functional_lsp-0.9.13 → java_functional_lsp-0.9.15}/src/java_functional_lsp/analyzers/base.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|