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.
- {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/.github/workflows/release-drafter.yml +2 -4
- java_functional_lsp-0.2.0/.github/workflows/update-homebrew.yml +61 -0
- {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/PKG-INFO +8 -7
- {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/README.md +7 -6
- {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/pyproject.toml +6 -3
- java_functional_lsp-0.2.0/scripts/generate-formula.py +63 -0
- {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/src/java_functional_lsp/__init__.py +1 -1
- java_functional_lsp-0.2.0/src/java_functional_lsp/cli.py +121 -0
- java_functional_lsp-0.2.0/src/java_functional_lsp/proxy.py +235 -0
- java_functional_lsp-0.2.0/src/java_functional_lsp/server.py +324 -0
- java_functional_lsp-0.2.0/tests/test_cli.py +55 -0
- java_functional_lsp-0.2.0/tests/test_proxy.py +248 -0
- {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/uv.lock +26 -1
- java_functional_lsp-0.1.0/src/java_functional_lsp/server.py +0 -145
- {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/.github/CODEOWNERS +0 -0
- {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/.github/ISSUE_TEMPLATE/bug-report.md +0 -0
- {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/.github/ISSUE_TEMPLATE/feature-request.md +0 -0
- {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/.github/SECURITY.md +0 -0
- {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/.github/dependabot.yml +0 -0
- {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/.github/release-drafter.yml +0 -0
- {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/.github/workflows/publish.yml +0 -0
- {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/.github/workflows/stale.yml +0 -0
- {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/.github/workflows/test.yml +0 -0
- {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/.gitignore +0 -0
- {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/CONTRIBUTING.md +0 -0
- {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/LICENSE +0 -0
- {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/src/java_functional_lsp/analyzers/__init__.py +0 -0
- {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/src/java_functional_lsp/analyzers/base.py +0 -0
- {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/src/java_functional_lsp/analyzers/exception_checker.py +0 -0
- {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/src/java_functional_lsp/analyzers/mutation_checker.py +0 -0
- {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/src/java_functional_lsp/analyzers/null_checker.py +0 -0
- {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/src/java_functional_lsp/analyzers/spring_checker.py +0 -0
- {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/tests/__init__.py +0 -0
- {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/tests/conftest.py +0 -0
- {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/tests/test_config.py +0 -0
- {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/tests/test_exception_checker.py +0 -0
- {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/tests/test_mutation_checker.py +0 -0
- {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/tests/test_null_checker.py +0 -0
- {java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/tests/test_spring_checker.py +0 -0
{java_functional_lsp-0.1.0 → java_functional_lsp-0.2.0}/.github/workflows/release-drafter.yml
RENAMED
|
@@ -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:
|
|
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@
|
|
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.
|
|
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
|
[](https://github.com/aviadshiber/java-functional-lsp/actions/workflows/test.yml)
|
|
32
|
-
[](https://pypi.org/project/java-functional-lsp/)
|
|
33
|
-
[](https://pypi.org/project/java-functional-lsp/)
|
|
32
|
+
[](https://pypi.org/project/java-functional-lsp/)
|
|
33
|
+
[](https://pypi.org/project/java-functional-lsp/)
|
|
34
34
|
[](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
|
-
|
|
59
|
-
|
|
58
|
+
# Homebrew
|
|
59
|
+
brew install aviadshiber/tap/java-functional-lsp
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
# pip
|
|
62
|
+
pip install java-functional-lsp
|
|
62
63
|
|
|
63
|
-
|
|
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
|
[](https://github.com/aviadshiber/java-functional-lsp/actions/workflows/test.yml)
|
|
4
|
-
[](https://pypi.org/project/java-functional-lsp/)
|
|
5
|
-
[](https://pypi.org/project/java-functional-lsp/)
|
|
4
|
+
[](https://pypi.org/project/java-functional-lsp/)
|
|
5
|
+
[](https://pypi.org/project/java-functional-lsp/)
|
|
6
6
|
[](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
|
-
|
|
31
|
-
|
|
30
|
+
# Homebrew
|
|
31
|
+
brew install aviadshiber/tap/java-functional-lsp
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
# pip
|
|
34
|
+
pip install java-functional-lsp
|
|
34
35
|
|
|
35
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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]))
|
|
@@ -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
|