java-functional-lsp 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/.github/workflows/release-drafter.yml +2 -4
  2. java_functional_lsp-0.2.0/.github/workflows/update-homebrew.yml +61 -0
  3. {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/PKG-INFO +8 -7
  4. {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/README.md +7 -6
  5. {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/pyproject.toml +6 -3
  6. java_functional_lsp-0.2.0/scripts/generate-formula.py +63 -0
  7. {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/src/java_functional_lsp/__init__.py +1 -1
  8. java_functional_lsp-0.2.0/src/java_functional_lsp/cli.py +121 -0
  9. java_functional_lsp-0.2.0/src/java_functional_lsp/proxy.py +235 -0
  10. java_functional_lsp-0.2.0/src/java_functional_lsp/server.py +324 -0
  11. java_functional_lsp-0.2.0/tests/test_cli.py +55 -0
  12. java_functional_lsp-0.2.0/tests/test_proxy.py +248 -0
  13. {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/uv.lock +26 -1
  14. java_functional_lsp-0.1.0/src/java_functional_lsp/server.py +0 -145
  15. {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/.github/CODEOWNERS +0 -0
  16. {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/.github/ISSUE_TEMPLATE/bug-report.md +0 -0
  17. {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/.github/ISSUE_TEMPLATE/feature-request.md +0 -0
  18. {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  19. {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/.github/SECURITY.md +0 -0
  20. {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/.github/dependabot.yml +0 -0
  21. {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/.github/release-drafter.yml +0 -0
  22. {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/.github/workflows/publish.yml +0 -0
  23. {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/.github/workflows/stale.yml +0 -0
  24. {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/.github/workflows/test.yml +0 -0
  25. {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/.gitignore +0 -0
  26. {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/CONTRIBUTING.md +0 -0
  27. {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/LICENSE +0 -0
  28. {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/src/java_functional_lsp/analyzers/__init__.py +0 -0
  29. {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/src/java_functional_lsp/analyzers/base.py +0 -0
  30. {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/src/java_functional_lsp/analyzers/exception_checker.py +0 -0
  31. {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/src/java_functional_lsp/analyzers/mutation_checker.py +0 -0
  32. {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/src/java_functional_lsp/analyzers/null_checker.py +0 -0
  33. {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/src/java_functional_lsp/analyzers/spring_checker.py +0 -0
  34. {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/tests/__init__.py +0 -0
  35. {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/tests/conftest.py +0 -0
  36. {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/tests/test_config.py +0 -0
  37. {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/tests/test_exception_checker.py +0 -0
  38. {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/tests/test_mutation_checker.py +0 -0
  39. {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/tests/test_null_checker.py +0 -0
  40. {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/tests/test_spring_checker.py +0 -0
@@ -3,17 +3,15 @@ name: Release Drafter
3
3
  on:
4
4
  push:
5
5
  branches: [main]
6
- pull_request:
7
- types: [opened, reopened, synchronize]
8
6
 
9
7
  permissions:
10
- contents: read
8
+ contents: write
11
9
  pull-requests: write
12
10
 
13
11
  jobs:
14
12
  update-release-draft:
15
13
  runs-on: ubuntu-latest
16
14
  steps:
17
- - uses: release-drafter/release-drafter@v6
15
+ - uses: release-drafter/release-drafter@v7
18
16
  env:
19
17
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -0,0 +1,61 @@
1
+ name: Update Homebrew Formula
2
+
3
+ on:
4
+ workflow_run:
5
+ workflows: ["Publish to PyPI"]
6
+ types: [completed]
7
+ workflow_dispatch:
8
+
9
+ jobs:
10
+ update-formula:
11
+ runs-on: ubuntu-latest
12
+ if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
13
+ steps:
14
+ - uses: actions/checkout@v6
15
+ - name: Get release version
16
+ id: version
17
+ env:
18
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
19
+ run: |
20
+ VERSION=$(gh api "repos/${{ github.repository }}/releases/latest" --jq .tag_name)
21
+ echo "version=${VERSION#v}" >> "$GITHUB_OUTPUT"
22
+ - name: Set up Python
23
+ uses: actions/setup-python@v6
24
+ with:
25
+ python-version: "3.12"
26
+ - name: Wait for PyPI availability
27
+ env:
28
+ PKG_VERSION: ${{ steps.version.outputs.version }}
29
+ run: |
30
+ for i in $(seq 1 30); do
31
+ if pip index versions java-functional-lsp 2>/dev/null | grep -q "$PKG_VERSION"; then
32
+ echo "java-functional-lsp==$PKG_VERSION available on PyPI"
33
+ break
34
+ fi
35
+ echo "Waiting for PyPI... attempt $i/30"
36
+ sleep 20
37
+ done
38
+ - name: Generate formula
39
+ env:
40
+ PKG_VERSION: ${{ steps.version.outputs.version }}
41
+ run: |
42
+ python3 scripts/generate-formula.py "$PKG_VERSION" > java-functional-lsp.rb
43
+ echo "Generated formula:"
44
+ cat java-functional-lsp.rb
45
+ - name: Checkout tap repo
46
+ uses: actions/checkout@v6
47
+ with:
48
+ repository: aviadshiber/homebrew-tap
49
+ token: ${{ secrets.TAP_GITHUB_TOKEN }}
50
+ path: homebrew-tap
51
+ - name: Update formula
52
+ env:
53
+ PKG_VERSION: ${{ steps.version.outputs.version }}
54
+ run: |
55
+ cp java-functional-lsp.rb homebrew-tap/Formula/java-functional-lsp.rb
56
+ cd homebrew-tap
57
+ git config user.name "github-actions[bot]"
58
+ git config user.email "github-actions[bot]@users.noreply.github.com"
59
+ git add Formula/java-functional-lsp.rb
60
+ git diff --cached --quiet || git commit -m "Update java-functional-lsp to $PKG_VERSION"
61
+ git push
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: java-functional-lsp
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Java LSP server enforcing functional programming best practices — null safety, immutability, no exceptions
5
5
  Project-URL: Homepage, https://github.com/aviadshiber/java-functional-lsp
6
6
  Project-URL: Repository, https://github.com/aviadshiber/java-functional-lsp
@@ -29,8 +29,8 @@ Description-Content-Type: text/markdown
29
29
  # java-functional-lsp
30
30
 
31
31
  [![CI](https://github.com/aviadshiber/java-functional-lsp/actions/workflows/test.yml/badge.svg)](https://github.com/aviadshiber/java-functional-lsp/actions/workflows/test.yml)
32
- [![PyPI version](https://img.shields.io/pypi/v/java-functional-lsp)](https://pypi.org/project/java-functional-lsp/)
33
- [![Python](https://img.shields.io/pypi/pyversions/java-functional-lsp)](https://pypi.org/project/java-functional-lsp/)
32
+ [![PyPI version](https://img.shields.io/pypi/v/java-functional-lsp?v=1)](https://pypi.org/project/java-functional-lsp/)
33
+ [![Python](https://img.shields.io/pypi/pyversions/java-functional-lsp?v=1)](https://pypi.org/project/java-functional-lsp/)
34
34
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
35
35
 
36
36
  A Java Language Server that enforces functional programming best practices. Designed for teams using **Vavr**, **Lombok**, and **Spring** with a functional-first approach.
@@ -55,12 +55,13 @@ A Java Language Server that enforces functional programming best practices. Desi
55
55
  ## Install
56
56
 
57
57
  ```bash
58
- pip install java-functional-lsp
59
- ```
58
+ # Homebrew
59
+ brew install aviadshiber/tap/java-functional-lsp
60
60
 
61
- Or from source:
61
+ # pip
62
+ pip install java-functional-lsp
62
63
 
63
- ```bash
64
+ # From source
64
65
  pip install git+https://github.com/aviadshiber/java-functional-lsp.git
65
66
  ```
66
67
 
@@ -1,8 +1,8 @@
1
1
  # java-functional-lsp
2
2
 
3
3
  [![CI](https://github.com/aviadshiber/java-functional-lsp/actions/workflows/test.yml/badge.svg)](https://github.com/aviadshiber/java-functional-lsp/actions/workflows/test.yml)
4
- [![PyPI version](https://img.shields.io/pypi/v/java-functional-lsp)](https://pypi.org/project/java-functional-lsp/)
5
- [![Python](https://img.shields.io/pypi/pyversions/java-functional-lsp)](https://pypi.org/project/java-functional-lsp/)
4
+ [![PyPI version](https://img.shields.io/pypi/v/java-functional-lsp?v=1)](https://pypi.org/project/java-functional-lsp/)
5
+ [![Python](https://img.shields.io/pypi/pyversions/java-functional-lsp?v=1)](https://pypi.org/project/java-functional-lsp/)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
7
 
8
8
  A Java Language Server that enforces functional programming best practices. Designed for teams using **Vavr**, **Lombok**, and **Spring** with a functional-first approach.
@@ -27,12 +27,13 @@ A Java Language Server that enforces functional programming best practices. Desi
27
27
  ## Install
28
28
 
29
29
  ```bash
30
- pip install java-functional-lsp
31
- ```
30
+ # Homebrew
31
+ brew install aviadshiber/tap/java-functional-lsp
32
32
 
33
- Or from source:
33
+ # pip
34
+ pip install java-functional-lsp
34
35
 
35
- ```bash
36
+ # From source
36
37
  pip install git+https://github.com/aviadshiber/java-functional-lsp.git
37
38
  ```
38
39
 
@@ -1,10 +1,10 @@
1
1
  [build-system]
2
- requires = ["hatchling==1.28.0"]
2
+ requires = ["hatchling==1.29.0"]
3
3
  build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "java-functional-lsp"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Java LSP server enforcing functional programming best practices — null safety, immutability, no exceptions"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -38,7 +38,7 @@ Repository = "https://github.com/aviadshiber/java-functional-lsp"
38
38
  Changelog = "https://github.com/aviadshiber/java-functional-lsp/releases"
39
39
 
40
40
  [project.scripts]
41
- java-functional-lsp = "java_functional_lsp.server:main"
41
+ java-functional-lsp = "java_functional_lsp.cli:main"
42
42
 
43
43
  [tool.hatch.build.targets.wheel]
44
44
  packages = ["src/java_functional_lsp"]
@@ -50,6 +50,7 @@ package = true
50
50
  [dependency-groups]
51
51
  dev = [
52
52
  "pytest>=8.0.0",
53
+ "pytest-asyncio>=0.23.0",
53
54
  "pytest-cov>=4.0.0",
54
55
  "pytest-mock>=3.12.0",
55
56
  "ruff>=0.1.0",
@@ -62,6 +63,7 @@ python_files = ["test_*.py"]
62
63
  python_classes = ["Test*"]
63
64
  python_functions = ["test_*"]
64
65
  addopts = "--cov=java_functional_lsp --cov-report=term-missing --cov-fail-under=60"
66
+ asyncio_mode = "auto"
65
67
 
66
68
  [tool.ruff]
67
69
  line-length = 120
@@ -75,6 +77,7 @@ ignore = ["PLR0913", "PLC0415", "PLW0603", "PLW0602"]
75
77
  [tool.ruff.lint.per-file-ignores]
76
78
  "__init__.py" = ["E402"]
77
79
  "**/test_*.py" = ["PLR2004"]
80
+ "**/cli.py" = ["T20", "PLR0912", "PLR2004"]
78
81
 
79
82
  [tool.ruff.format]
80
83
  quote-style = "double"
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env python3
2
+ """Generate a Homebrew formula for java-functional-lsp.
3
+
4
+ Usage: python3 scripts/generate-formula.py <version>
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import sys
10
+ import urllib.request
11
+
12
+
13
+ def get_pypi_sha256(package: str, version: str) -> str:
14
+ """Get the sdist sha256 for a package from PyPI."""
15
+ url = f"https://pypi.org/pypi/{package}/{version}/json"
16
+ with urllib.request.urlopen(url) as resp:
17
+ data = json.loads(resp.read())
18
+
19
+ for file_info in data.get("urls", []):
20
+ if file_info["filename"].endswith(".tar.gz"):
21
+ return file_info["digests"]["sha256"]
22
+
23
+ for file_info in data.get("urls", []):
24
+ if file_info["packagetype"] == "sdist":
25
+ return file_info["digests"]["sha256"]
26
+
27
+ raise ValueError(f"No sdist found for {package}=={version}")
28
+
29
+
30
+ def generate_formula(version: str) -> str:
31
+ """Generate the Homebrew formula."""
32
+ sha256 = get_pypi_sha256("java-functional-lsp", version)
33
+
34
+ return f'''class JavaFunctionalLsp < Formula
35
+ desc "Java LSP server enforcing functional programming best practices"
36
+ homepage "https://github.com/aviadshiber/java-functional-lsp"
37
+ url "https://files.pythonhosted.org/packages/source/j/java-functional-lsp/java_functional_lsp-{version}.tar.gz"
38
+ sha256 "{sha256}"
39
+ license "MIT"
40
+
41
+ depends_on "python@3.12"
42
+
43
+ def install
44
+ python3 = "python3.12"
45
+ venv = libexec/"venv"
46
+ system python3, "-m", "venv", venv
47
+ system venv/"bin/pip", "install", "--upgrade", "pip"
48
+ system venv/"bin/pip", "install", buildpath
49
+ bin.install_symlink Dir[venv/"bin/java-functional-lsp"]
50
+ end
51
+
52
+ test do
53
+ assert_match "java-functional-lsp", shell_output("#{{bin}}/java-functional-lsp --help 2>&1", 1)
54
+ end
55
+ end
56
+ '''
57
+
58
+
59
+ if __name__ == "__main__":
60
+ if len(sys.argv) != 2:
61
+ print(f"Usage: {sys.argv[0]} <version>", file=sys.stderr)
62
+ sys.exit(1)
63
+ print(generate_formula(sys.argv[1]))
@@ -1,3 +1,3 @@
1
1
  """java-functional-lsp: A Java LSP server enforcing functional programming best practices."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.0"
@@ -0,0 +1,121 @@
1
+ """CLI check mode — run java-functional-lsp as a standalone linter without LSP."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from .analyzers.base import Analyzer, Diagnostic, Severity, get_parser
11
+ from .analyzers.exception_checker import ExceptionChecker
12
+ from .analyzers.mutation_checker import MutationChecker
13
+ from .analyzers.null_checker import NullChecker
14
+ from .analyzers.spring_checker import SpringChecker
15
+
16
+ _ANALYZERS: list[Analyzer] = [NullChecker(), ExceptionChecker(), MutationChecker(), SpringChecker()]
17
+
18
+ _SEVERITY_SYMBOLS = {
19
+ Severity.ERROR: "E",
20
+ Severity.WARNING: "W",
21
+ Severity.INFO: "I",
22
+ Severity.HINT: "H",
23
+ }
24
+
25
+
26
+ def load_config(start_path: Path) -> dict[str, Any]:
27
+ """Walk up from start_path to find .deeperdive-linter.json."""
28
+ current = start_path if start_path.is_dir() else start_path.parent
29
+ while current != current.parent:
30
+ config_path = current / ".deeperdive-linter.json"
31
+ if config_path.exists():
32
+ try:
33
+ result: dict[str, Any] = json.loads(config_path.read_text())
34
+ return result
35
+ except (json.JSONDecodeError, OSError):
36
+ pass
37
+ current = current.parent
38
+ return {}
39
+
40
+
41
+ def check_file(path: Path, config: dict[str, Any]) -> list[Diagnostic]:
42
+ """Analyze a single Java file and return diagnostics."""
43
+ parser = get_parser()
44
+ source = path.read_bytes()
45
+ tree = parser.parse(source)
46
+
47
+ all_diags: list[Diagnostic] = []
48
+ for analyzer in _ANALYZERS:
49
+ diags = analyzer.analyze(tree, source, config)
50
+ all_diags.extend(diags)
51
+
52
+ all_diags.sort(key=lambda d: (d.line, d.col))
53
+ return all_diags
54
+
55
+
56
+ def format_diagnostic(path: Path, d: Diagnostic) -> str:
57
+ """Format a diagnostic as a single line: path:line:col: [S] code: message."""
58
+ sym = _SEVERITY_SYMBOLS.get(d.severity, "W")
59
+ return f"{path}:{d.line + 1}:{d.col}: [{sym}] {d.code}: {d.message}"
60
+
61
+
62
+ def main() -> None:
63
+ """CLI entry point: java-functional-lsp check <files...>"""
64
+ args = sys.argv[1:]
65
+
66
+ if not args or args[0] in ("-h", "--help"):
67
+ print("Usage: java-functional-lsp check <file.java> [file2.java ...]")
68
+ print(" java-functional-lsp check --dir <directory>")
69
+ print(" java-functional-lsp (start LSP server on stdio)")
70
+ sys.exit(0)
71
+
72
+ if args[0] != "check":
73
+ # Not check mode — fall through to LSP server
74
+ from .server import main as lsp_main
75
+
76
+ lsp_main()
77
+ return
78
+
79
+ args = args[1:] # skip "check"
80
+
81
+ # Collect files
82
+ files: list[Path] = []
83
+ if args and args[0] == "--dir":
84
+ if len(args) < 2:
85
+ print("Error: --dir requires a directory path", file=sys.stderr)
86
+ sys.exit(1)
87
+ directory = Path(args[1])
88
+ if not directory.is_dir():
89
+ print(f"Error: {directory} is not a directory", file=sys.stderr)
90
+ sys.exit(1)
91
+ files = sorted(directory.rglob("*.java"))
92
+ else:
93
+ for arg in args:
94
+ p = Path(arg)
95
+ if p.is_file():
96
+ files.append(p)
97
+ elif p.is_dir():
98
+ files.extend(sorted(p.rglob("*.java")))
99
+ else:
100
+ print(f"Warning: {arg} not found, skipping", file=sys.stderr)
101
+
102
+ if not files:
103
+ print("No .java files found", file=sys.stderr)
104
+ sys.exit(1)
105
+
106
+ # Load config from first file's directory
107
+ config = load_config(files[0])
108
+
109
+ total_diags = 0
110
+ for path in files:
111
+ diags = check_file(path, config)
112
+ for d in diags:
113
+ print(format_diagnostic(path, d))
114
+ total_diags += len(diags)
115
+
116
+ if total_diags > 0:
117
+ print(f"\n{total_diags} diagnostic(s) in {len(files)} file(s)", file=sys.stderr)
118
+ sys.exit(1)
119
+ else:
120
+ print(f"No issues found in {len(files)} file(s)", file=sys.stderr)
121
+ sys.exit(0)
@@ -0,0 +1,235 @@
1
+ """jdtls proxy — manages a jdtls subprocess and forwards LSP messages via JSON-RPC over stdio."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import shutil
9
+ from collections.abc import Callable
10
+ from typing import Any
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ REQUEST_TIMEOUT = 30.0 # seconds
15
+
16
+
17
+ def encode_message(body: dict[str, Any]) -> bytes:
18
+ """Encode a JSON-RPC message with Content-Length header."""
19
+ content = json.dumps(body).encode("utf-8")
20
+ header = f"Content-Length: {len(content)}\r\n\r\n".encode("ascii")
21
+ return header + content
22
+
23
+
24
+ async def read_message(reader: asyncio.StreamReader) -> dict[str, Any] | None:
25
+ """Read a Content-Length framed JSON-RPC message from a stream."""
26
+ try:
27
+ # Read headers until blank line
28
+ content_length = -1
29
+ while True:
30
+ line = await reader.readline()
31
+ if not line:
32
+ return None # EOF
33
+ line_str = line.decode("ascii").strip()
34
+ if not line_str:
35
+ break # End of headers
36
+ if line_str.lower().startswith("content-length:"):
37
+ content_length = int(line_str.split(":", 1)[1].strip())
38
+
39
+ if content_length < 0:
40
+ return None
41
+
42
+ # Read body
43
+ body_bytes = await reader.readexactly(content_length)
44
+ result: dict[str, Any] = json.loads(body_bytes)
45
+ return result
46
+ except (asyncio.IncompleteReadError, ConnectionError, OSError):
47
+ return None
48
+
49
+
50
+ class JdtlsProxy:
51
+ """Manages a jdtls subprocess and provides async request/notification forwarding."""
52
+
53
+ def __init__(self, on_diagnostics: Callable[[str, list[Any]], None] | None = None) -> None:
54
+ self._process: asyncio.subprocess.Process | None = None
55
+ self._reader_task: asyncio.Task[None] | None = None
56
+ self._next_id: int = 1
57
+ self._pending: dict[int, asyncio.Future[Any]] = {}
58
+ self._diagnostics_cache: dict[str, list[Any]] = {}
59
+ self._on_diagnostics = on_diagnostics
60
+ self._available = False
61
+ self._jdtls_capabilities: dict[str, Any] = {}
62
+
63
+ @property
64
+ def is_available(self) -> bool:
65
+ """Whether jdtls is running and responsive."""
66
+ return self._available
67
+
68
+ @property
69
+ def capabilities(self) -> dict[str, Any]:
70
+ """jdtls server capabilities from initialize response."""
71
+ return self._jdtls_capabilities
72
+
73
+ def get_cached_diagnostics(self, uri: str) -> list[Any]:
74
+ """Get the latest jdtls diagnostics for a URI."""
75
+ return list(self._diagnostics_cache.get(uri, []))
76
+
77
+ async def start(self, init_params: dict[str, Any]) -> bool:
78
+ """Start jdtls subprocess and initialize it."""
79
+ jdtls_path = shutil.which("jdtls")
80
+ if not jdtls_path:
81
+ logger.warning("jdtls not found on PATH — running in standalone mode (custom rules only)")
82
+ return False
83
+
84
+ try:
85
+ self._process = await asyncio.create_subprocess_exec(
86
+ jdtls_path,
87
+ stdin=asyncio.subprocess.PIPE,
88
+ stdout=asyncio.subprocess.PIPE,
89
+ stderr=asyncio.subprocess.PIPE,
90
+ )
91
+ logger.info("jdtls subprocess started (pid=%s)", self._process.pid)
92
+
93
+ # Start background reader
94
+ assert self._process.stdout is not None
95
+ self._reader_task = asyncio.create_task(self._reader_loop(self._process.stdout))
96
+
97
+ # Send initialize request
98
+ result = await self.send_request("initialize", init_params)
99
+ if result is None:
100
+ logger.error("jdtls initialize request failed or timed out")
101
+ await self.stop()
102
+ return False
103
+
104
+ self._jdtls_capabilities = result.get("capabilities", {})
105
+ logger.info("jdtls initialized (capabilities: %s)", list(self._jdtls_capabilities.keys()))
106
+
107
+ # Send initialized notification
108
+ await self.send_notification("initialized", {})
109
+ self._available = True
110
+ return True
111
+
112
+ except (OSError, FileNotFoundError) as e:
113
+ logger.error("Failed to start jdtls: %s", e)
114
+ return False
115
+
116
+ async def stop(self) -> None:
117
+ """Shutdown jdtls subprocess gracefully."""
118
+ self._available = False
119
+
120
+ if self._reader_task and not self._reader_task.done():
121
+ self._reader_task.cancel()
122
+
123
+ if self._process and self._process.returncode is None:
124
+ try:
125
+ await self.send_request("shutdown", None, timeout=5.0)
126
+ await self.send_notification("exit", None)
127
+ await asyncio.wait_for(self._process.wait(), timeout=5.0)
128
+ except (asyncio.TimeoutError, OSError):
129
+ self._process.kill()
130
+ await self._process.wait()
131
+
132
+ # Cancel all pending requests
133
+ for future in self._pending.values():
134
+ if not future.done():
135
+ future.cancel()
136
+ self._pending.clear()
137
+
138
+ async def send_request(self, method: str, params: Any, timeout: float = REQUEST_TIMEOUT) -> Any | None:
139
+ """Send a JSON-RPC request and wait for the response."""
140
+ if not self._process or self._process.stdin is None:
141
+ return None
142
+
143
+ request_id = self._next_id
144
+ self._next_id += 1
145
+
146
+ msg: dict[str, Any] = {
147
+ "jsonrpc": "2.0",
148
+ "id": request_id,
149
+ "method": method,
150
+ }
151
+ if params is not None:
152
+ msg["params"] = params
153
+
154
+ future: asyncio.Future[Any] = asyncio.get_event_loop().create_future()
155
+ self._pending[request_id] = future
156
+
157
+ try:
158
+ self._process.stdin.write(encode_message(msg))
159
+ await self._process.stdin.drain()
160
+ result = await asyncio.wait_for(future, timeout=timeout)
161
+ return result
162
+ except asyncio.TimeoutError:
163
+ logger.warning("jdtls request %s timed out after %.1fs", method, timeout)
164
+ self._pending.pop(request_id, None)
165
+ return None
166
+ except (OSError, ConnectionError) as e:
167
+ logger.error("jdtls communication error on %s: %s", method, e)
168
+ self._pending.pop(request_id, None)
169
+ self._available = False
170
+ return None
171
+
172
+ async def send_notification(self, method: str, params: Any) -> None:
173
+ """Send a JSON-RPC notification (no response expected)."""
174
+ if not self._process or self._process.stdin is None:
175
+ return
176
+
177
+ msg: dict[str, Any] = {
178
+ "jsonrpc": "2.0",
179
+ "method": method,
180
+ }
181
+ if params is not None:
182
+ msg["params"] = params
183
+
184
+ try:
185
+ self._process.stdin.write(encode_message(msg))
186
+ await self._process.stdin.drain()
187
+ except (OSError, ConnectionError) as e:
188
+ logger.error("jdtls notification error on %s: %s", method, e)
189
+ self._available = False
190
+
191
+ async def _reader_loop(self, reader: asyncio.StreamReader) -> None:
192
+ """Background task: read jdtls stdout and dispatch messages."""
193
+ try:
194
+ while True:
195
+ msg = await read_message(reader)
196
+ if msg is None:
197
+ logger.warning("jdtls stdout closed — subprocess may have exited")
198
+ self._available = False
199
+ break
200
+
201
+ self._dispatch_message(msg)
202
+ except asyncio.CancelledError:
203
+ pass
204
+ except Exception as e:
205
+ logger.error("jdtls reader loop error: %s", e)
206
+ self._available = False
207
+
208
+ def _dispatch_message(self, msg: dict[str, Any]) -> None:
209
+ """Route a message from jdtls to the appropriate handler."""
210
+ if "id" in msg and "method" not in msg:
211
+ # Response to a request we sent
212
+ request_id = msg["id"]
213
+ future = self._pending.pop(request_id, None)
214
+ if future and not future.done():
215
+ if "error" in msg:
216
+ logger.warning("jdtls error response (id=%s): %s", request_id, msg["error"])
217
+ future.set_result(None)
218
+ else:
219
+ future.set_result(msg.get("result"))
220
+ elif "method" in msg and "id" not in msg:
221
+ # Notification from jdtls
222
+ self._handle_notification(msg)
223
+
224
+ def _handle_notification(self, msg: dict[str, Any]) -> None:
225
+ """Handle a notification from jdtls."""
226
+ method = msg.get("method", "")
227
+ params = msg.get("params", {})
228
+
229
+ if method == "textDocument/publishDiagnostics":
230
+ uri = params.get("uri", "")
231
+ diagnostics = params.get("diagnostics", [])
232
+ self._diagnostics_cache[uri] = diagnostics
233
+ if self._on_diagnostics:
234
+ self._on_diagnostics(uri, diagnostics)
235
+ # Other notifications (window/logMessage, etc.) are silently ignored