Skip to content

Commit db8cbdb

Browse files
authored
Merge pull request #1 from magaransoft/add-java-direct
add java-direct (jdtls) wrapper — v1.1.0
2 parents d36c361 + 75f7dec commit db8cbdb

15 files changed

Lines changed: 357 additions & 13 deletions

File tree

.github/workflows/ci.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,5 +68,9 @@ jobs:
6868
if: matrix.os == 'macos-latest'
6969
run: brew install jq
7070

71-
- name: probe python + typescript fixtures
71+
- name: install jdtls (macos)
72+
if: matrix.os == 'macos-latest'
73+
run: brew install jdtls
74+
75+
- name: probe fixtures
7276
run: bash scripts/verify.sh

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ fixtures/**/bin/
2323
fixtures/**/dist/
2424
fixtures/**/.bloop/
2525
fixtures/**/target/
26+
# eclipse jdt.ls writes these into the java fixture on first start
27+
fixtures/java/.classpath
28+
fixtures/java/.project
29+
fixtures/java/.settings/
2630

2731
# local state (user-owned, not repo data)
2832
.claude/

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,24 @@ All notable changes to this project will be documented here.
44

55
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [SemVer](https://semver.org/).
66

7+
## [1.1.0] — 2026-04-21
8+
9+
### Added
10+
- `bin/java-direct` — Java via jdtls (Eclipse JDT.LS) proxy; per-workspace `-data` dir under wrapper state hash; 180s start timeout for JVM + Equinox boot
11+
- `fixtures/java/` — minimal Maven project (`pom.xml` + `src/main/java/com/example/Hello.java`) for CI + verify
12+
- `docs/per-language/java.md` — install (`brew install jdtls`), workspace markers, op surface, jdtls quirks (build-job latency, `~/.eclipse` write requirement)
13+
- `docs/convention.md` — java row added to language table
14+
- `hooks/enforce-lsp-over-grep.py` — extended `CODE_EXT`/`EXT_LANG`/`RG_TYPE_LANG`/`POS_CODE_FILE_RE`/`LANG_DIRECT_WRAPPER`/`PLUGIN_BINARY_MAP` to cover `.java`; reuses python/typescript/csharp suggestion branch
15+
- `hooks/tests/test_enforce_lsp_over_grep.py` — java cases for bash grep/rg/find, native `Grep` tool (type/glob/path), positional code-file detection
16+
- `scripts/install.sh` + `scripts/verify.sh``java-direct` symlinked + java fixture probe added
17+
- `.github/workflows/ci.yml``brew install jdtls` step on macos-latest (linux skipped — no first-class jdtls package)
18+
- `README.md` — java row in benchmarks table + per-language link list
19+
20+
### Verified
21+
- functional probe: `documentSymbol` (2 symbols), `workspace/symbol "Hello"` (1 result after build settle), `references` on `greet` method (2 refs)
22+
- timing: cold start 2.16s, cold call 907ms, warm avg ~85ms (`documentSymbol`/`workspace/symbol`/`references`)
23+
- hook tests: 97/97 pass
24+
725
## [1.0.0] — 2026-04-21
826

927
### Added
@@ -20,4 +38,5 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [S
2038
- `fixtures/` — minimal sample projects for CI + local verification
2139
- GitHub Actions CI on macOS + Ubuntu
2240

41+
[1.1.0]: https://github.com/CHANGE-ME/claude-lsp-direct/releases/tag/v1.1.0
2342
[1.0.0]: https://github.com/CHANGE-ME/claude-lsp-direct/releases/tag/v1.0.0

README.md

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# claude-lsp-direct
22

3-
Per-workspace LSP proxies over HTTP for **Python**, **TypeScript / JavaScript**, **C#**, **Vue**, **Scala**. Sub-100ms steady-state; sidesteps the per-tool-call round-trip that agent harnesses pay. Per-workspace isolation fixes servers that bind `rootUri` at init (e.g. `csharp-ls`).
3+
Per-workspace LSP proxies over HTTP for **Python**, **TypeScript / JavaScript**, **C#**, **Vue**, **Scala**, **Java**. Sub-100ms steady-state; sidesteps the per-tool-call round-trip that agent harnesses pay. Per-workspace isolation fixes servers that bind `rootUri` at init (e.g. `csharp-ls`).
44

55
Extends the pattern Angel Blanco ([@NovaMage](https://github.com/NovaMage)) first demonstrated for Scala in [agents-metals-direct-lsp](https://github.com/NovaMage/agents-metals-direct-lsp) across the full set of language servers common in multi-stack monorepos.
66

@@ -21,11 +21,13 @@ Direct-wrapper numbers measured 2026-04-21 on macOS 26.4.1 arm64 (see *Tested ve
2121
| csharp | my measurement, `LSP()` tool * | ~9s (empty) | ~10s (empty) | 30-120s † | 0.07s | rootUri fix + **~130× warm** |
2222
| vue | unsupported ‡ ||| 6.6s | 0.09s | enables capability |
2323
| scala | [Angel Blanco benchmark](https://github.com/anthropics/claude-code/issues/45132#issuecomment-3492812921), Claude MCP/stdio | 10.7s | ~6.3s avg | 0.14s § | 0.08s | **~80×** (his direct-HTTP baseline: 0.038s, essentially the same) |
24+
| java | my measurement, `LSP()` tool (`jdtls-lsp@claude-plugins-official`) | ~9s ¶ | ~9s ¶ | 0.91s | 0.085s | **~100×** |
2425

2526
\* `csharp-ls` bound to wrong `rootUri` (cwd outside `.sln` ancestor) returns empty in ~9-10s.
2627
† cs-direct cold = MSBuild solution load + NuGet restore. Amortized across the session.
2728
‡ Vue LS v3 is hybrid-mandatory (needs paired tsserver + `@vue/typescript-plugin`); Claude Code's plugin loader can't host the paired setup, so native `LSP()` on `.vue` isn't available.
2829
§ metals-direct cold of 0.14s is the server-adoption path (reuses an existing `metals-mcp` via `<workspace>/.metals/mcp.json`); fresh cold with Bloop re-import is 30-120s.
30+
¶ java "before" matches the documented `LSP()` tool harness round-trip floor (~8-9s per invocation); `jdtls-lsp@claude-plugins-official` plugin pools the server but each call still pays the per-tool-turn cost. java-direct cold of 0.91s is the first call after `start` (Eclipse "Building workspace" job runs in background); subsequent calls steady at ~85ms. On a real Maven/Gradle project, expect cold of 30-120s on first start (dependency resolution), then sub-100ms warm.
2931

3032
The point isn't the specific numbers — it's the order-of-magnitude gap between a persistent HTTP proxy and the minimum cost of a per-call tool turn.
3133

@@ -49,7 +51,7 @@ CLI → <lang>-direct (bash)
4951

5052
Full spec: [`docs/convention.md`](docs/convention.md) · [`docs/architecture.md`](docs/architecture.md) · [`docs/troubleshooting.md`](docs/troubleshooting.md)
5153

52-
Per-language: [Python](docs/per-language/python.md) · [TypeScript](docs/per-language/typescript.md) · [C#](docs/per-language/csharp.md) · [Vue](docs/per-language/vue.md) · [Scala](docs/per-language/scala.md)
54+
Per-language: [Python](docs/per-language/python.md) · [TypeScript](docs/per-language/typescript.md) · [C#](docs/per-language/csharp.md) · [Vue](docs/per-language/vue.md) · [Scala](docs/per-language/scala.md) · [Java](docs/per-language/java.md)
5355

5456
## Quickstart
5557

@@ -69,6 +71,25 @@ py-direct call textDocument/documentSymbol \
6971

7072
Manual install (non-Claude-Code users): `ln -s ~/projects/claude-lsp-direct/bin/* ~/.local/bin/`. Any editor or agent that can shell + curl can use this.
7173

74+
## What `install.sh` changes on your system
75+
76+
Full transparency — `scripts/install.sh` is idempotent and only touches paths under `~/.claude/`. Inspect the script before running if you'd rather apply changes manually.
77+
78+
| change | scope | reversible |
79+
|---|---|---|
80+
| symlinks `bin/*``~/.claude/bin/<wrapper>` (8 files: 6 wrappers + 2 coordinators) | filesystem | `scripts/uninstall.sh` removes them; pre-existing files are backed up to `<file>.bak-<ts>` |
81+
| symlinks `hooks/*``~/.claude/hooks/<hook>` (2 hooks) | filesystem | same — uninstall + backups |
82+
| merges into `~/.claude/settings.json` `permissions.allow`: `Bash(~/.claude/bin/<wrapper> *)` for each wrapper | Claude Code permission allowlist | `~/.claude/settings.json.bak-<ts>` written before merge; revert by restoring the backup |
83+
| merges into `~/.claude/settings.json` `sandbox.filesystem.allowWrite`: `~/.cache/<wrapper>/**` for each wrapper, plus `~/.eclipse/**` (jdtls JNI extraction) | Claude Code sandbox | same backup |
84+
85+
Why each sandbox write is needed:
86+
- `~/.cache/<wrapper>/**` — per-workspace state dir each wrapper uses for `pid/port/workspace/log` files. Hash-scoped; no shared writes.
87+
- `~/.eclipse/**` — only for `java-direct`. Eclipse Equinox launcher extracts JNI native libraries here on first jdtls start. One-time write, then read-only. Standard Eclipse-tooling path; same dir VSCode-Java, IntelliJ Eclipse plugin, etc. write to.
88+
89+
`install.sh` does NOT touch: shell rc files, your PATH, system directories, network configs, secrets, plugins outside `~/.claude/`. Skip the script entirely if you only want the wrappers — `ln -s` them into any PATH dir and the rest is no-op for non-Claude-Code agents.
90+
91+
Do it at your own discretion — the changes are small and visible, but you own your sandbox config.
92+
7293
## Tested versions
7394

7495
Exact versions this was developed and benchmarked against. Other versions likely work; these are what's verified.
@@ -86,6 +107,8 @@ Exact versions this was developed and benchmarked against. Other versions likely
86107
| @vue/typescript-plugin | 3.2.6 |
87108
| csharp-ls | 0.24.0.0 |
88109
| metals-mcp | 1.6.7 (Angel's benchmark); `brew install metals` (latest) otherwise |
110+
| jdtls | 1.58.0 (`brew install jdtls`) |
111+
| OpenJDK | 21.0.5 LTS (Corretto) — any JDK 17+ works |
89112
| .NET SDK | 9.x recommended (10.x has an MSBuild BuildHost pipe issue with csharp-ls on macOS — see [csharp docs](docs/per-language/csharp.md)) |
90113

91114
If you're running different versions, `scripts/verify.sh` is the quickest way to confirm the wrapper still works end-to-end on your stack.
@@ -110,7 +133,7 @@ Happy to chat if any of this is under consideration or if the patterns here woul
110133

111134
## Contributing
112135

113-
See [CONTRIBUTING.md](CONTRIBUTING.md). Adding a language is usually a ~100 LOC bash wrapper + fixture + doc page + CI entry. PRs welcome for Go, Rust, Ruby, Java, Kotlin, Swift, Elixir, …
136+
See [CONTRIBUTING.md](CONTRIBUTING.md). Adding a language is usually a ~100 LOC bash wrapper + fixture + doc page + CI entry. PRs welcome for Go, Rust, Ruby, Kotlin, Swift, Elixir, …
114137

115138
## License
116139

bin/java-direct

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
#!/usr/bin/env bash
2+
# java-direct — proxy jdtls (Eclipse JDT.LS) over persistent HTTP; per-workspace isolation; LSP-workaround convention per rules/lsp.md
3+
# subcommands: start [workspace] | call <method> <json-params> [workspace] | stop [workspace] | status | tools [workspace]
4+
set -euo pipefail
5+
6+
STATE_ROOT="${JAVA_DIRECT_STATE:-$HOME/.cache/java-direct}"
7+
PROXY="$HOME/.claude/bin/lsp-stdio-proxy.js"
8+
LSP_BIN="jdtls"
9+
LANG_ID="java"
10+
WORKSPACE_MARKERS=(pom.xml build.gradle.kts build.gradle settings.gradle.kts settings.gradle .project)
11+
12+
die() { echo "java-direct: $*" >&2; exit 1; }
13+
14+
resolve_workspace() {
15+
local ws="${1:-}"
16+
if [ -n "$ws" ]; then ws="$(cd "$ws" && pwd)"; echo "$ws"; return; fi
17+
ws="$PWD"
18+
while [ "$ws" != "/" ]; do
19+
for marker in "${WORKSPACE_MARKERS[@]}"; do
20+
[ -e "$ws/$marker" ] && { echo "$ws"; return; }
21+
done
22+
ws="$(dirname "$ws")"
23+
done
24+
echo "$PWD"
25+
}
26+
27+
state_dir() {
28+
local hash
29+
hash="$(printf '%s' "$1" | shasum | awk '{print $1}' | cut -c1-12)"
30+
echo "$STATE_ROOT/$hash"
31+
}
32+
33+
free_port() {
34+
python3 -c 'import socket; s=socket.socket(); s.bind(("",0)); print(s.getsockname()[1]); s.close()'
35+
}
36+
37+
state_get() { [ -f "$1/$2" ] && cat "$1/$2" || echo ""; }
38+
39+
port_ready() {
40+
curl -fsS -m 2 "http://127.0.0.1:$1/health" >/dev/null 2>&1
41+
}
42+
43+
server_alive() {
44+
local port; port="$(state_get "$1" port)"
45+
[ -z "$port" ] && return 1
46+
port_ready "$port"
47+
}
48+
49+
cmd_start() {
50+
command -v "$LSP_BIN" >/dev/null || die "$LSP_BIN not on PATH — install jdtls (brew install jdtls; requires JDK 17+)"
51+
[ -f "$PROXY" ] || die "proxy missing: $PROXY"
52+
local ws dir port pid
53+
ws="$(resolve_workspace "${1:-}")"
54+
dir="$(state_dir "$ws")"
55+
mkdir -p "$dir"
56+
if server_alive "$dir"; then
57+
echo "already running: workspace=$ws port=$(state_get "$dir" port) pid=$(state_get "$dir" pid)"
58+
return 0
59+
fi
60+
port="$(free_port)"
61+
echo "$ws" > "$dir/workspace"
62+
echo "$port" > "$dir/port"
63+
# jdtls needs a per-workspace -data dir (Eclipse workspace metadata); scope under our state hash for clean stop
64+
local lsp_args=(-data "$dir/jdt-data")
65+
nohup node "$PROXY" --workspace "$ws" --port "$port" --lang-id "$LANG_ID" -- "$LSP_BIN" "${lsp_args[@]}" >"$dir/log" 2>&1 &
66+
pid=$!
67+
echo "$pid" > "$dir/pid"
68+
local i=0
69+
until port_ready "$port"; do
70+
sleep 1
71+
i=$((i+1))
72+
[ "$i" -ge 180 ] && { kill "$pid" 2>/dev/null; die "proxy did not bind port $port within 180s — check $dir/log"; }
73+
done
74+
echo "started: workspace=$ws port=$port pid=$pid state=$dir"
75+
}
76+
77+
cmd_call() {
78+
local method="${1:-}"; shift || true
79+
local params_json="${1-}"; shift || true
80+
[ -z "$params_json" ] && params_json='{}'
81+
[ -z "$method" ] && die "usage: java-direct call <method> '<json-params>' [workspace]"
82+
local ws dir port
83+
ws="$(resolve_workspace "${1:-}")"
84+
dir="$(state_dir "$ws")"
85+
server_alive "$dir" || { echo "server not running for $ws — starting..." >&2; cmd_start "$ws" >&2; }
86+
port="$(state_get "$dir" port)"
87+
local payload
88+
payload="$(jq -cn --arg method "$method" --argjson params "$params_json" '{method:$method,params:$params}')"
89+
curl -fsS -m 120 "http://localhost:$port/lsp" -X POST \
90+
-H 'Content-Type: application/json' \
91+
-d "$payload" || die "lsp call failed"
92+
echo
93+
}
94+
95+
cmd_tools() {
96+
cat <<EOF
97+
java-direct exposes raw LSP methods (jdtls / Eclipse JDT.LS surface).
98+
invoke: java-direct call <method> '<json-params>' [workspace]
99+
100+
common methods:
101+
textDocument/documentSymbol outline of a .java file
102+
textDocument/hover type + javadoc at position
103+
textDocument/definition jump to definition
104+
textDocument/references find references
105+
textDocument/typeDefinition jump to type
106+
textDocument/implementation find implementations
107+
textDocument/completion completions at position
108+
textDocument/signatureHelp call signature
109+
textDocument/prepareCallHierarchy get call-hierarchy handle
110+
callHierarchy/incomingCalls callers
111+
callHierarchy/outgoingCalls callees
112+
workspace/symbol fuzzy search across workspace
113+
114+
params MUST include textDocument.uri as file://<abs-path> for textDocument/* methods.
115+
note: jdtls runs a background "Building workspace" job after start — workspace/symbol may return empty until it completes (typically 5-15s on small projects, longer on Maven/Gradle imports).
116+
117+
example:
118+
java-direct call textDocument/documentSymbol '{"textDocument":{"uri":"file:///path/to/Hello.java"}}'
119+
EOF
120+
}
121+
122+
cmd_stop() {
123+
local ws dir pid
124+
ws="$(resolve_workspace "${1:-}")"
125+
dir="$(state_dir "$ws")"
126+
pid="$(state_get "$dir" pid)"
127+
if [ -n "$pid" ]; then
128+
kill "$pid" 2>/dev/null && echo "stopped: pid=$pid workspace=$ws" || echo "kill failed (pid=$pid) — state cleared for $ws"
129+
else
130+
echo "no pid for $ws"
131+
fi
132+
rm -f "$dir/pid" "$dir/port"
133+
}
134+
135+
cmd_status() {
136+
[ -d "$STATE_ROOT" ] || { echo "no servers tracked"; return; }
137+
local any=0
138+
for dir in "$STATE_ROOT"/*/; do
139+
[ -d "$dir" ] || continue
140+
any=1
141+
local ws pid port alive
142+
ws="$(state_get "$dir" workspace)"
143+
pid="$(state_get "$dir" pid)"
144+
port="$(state_get "$dir" port)"
145+
alive="dead"
146+
[ -n "$port" ] && port_ready "$port" && alive="alive"
147+
printf 'workspace=%s port=%s pid=%s %s\n' "$ws" "$port" "$pid" "$alive"
148+
done
149+
[ "$any" = 0 ] && echo "no servers tracked"
150+
}
151+
152+
case "${1:-}" in
153+
start) shift; cmd_start "$@" ;;
154+
call) shift; cmd_call "$@" ;;
155+
tools) shift; cmd_tools "$@" ;;
156+
stop) shift; cmd_stop "$@" ;;
157+
status) shift; cmd_status "$@" ;;
158+
""|-h|--help)
159+
cat <<EOF
160+
java-direct — proxy jdtls (Eclipse JDT.LS) over persistent HTTP; one server per workspace
161+
162+
usage:
163+
java-direct start [workspace] spawn proxy for workspace (default: cwd walking up for pom.xml/build.gradle/settings.gradle/.project)
164+
java-direct call <method> '<json-params>' [ws] issue raw LSP method — auto-starts server
165+
java-direct tools list LSP method surface
166+
java-direct stop [workspace] kill server for workspace
167+
java-direct status show all tracked servers
168+
169+
workspace markers (walk-up order): ${WORKSPACE_MARKERS[*]}
170+
171+
state: $STATE_ROOT/<workspace-hash>/{pid,port,workspace,log}
172+
jdt-data (Eclipse workspace metadata): $STATE_ROOT/<workspace-hash>/jdt-data/
173+
174+
prereqs:
175+
brew install jdtls # ships launcher script + Eclipse JDT.LS
176+
java >= 17 # JDK, not just JRE
177+
EOF
178+
;;
179+
*) die "unknown subcommand: $1 (try --help)" ;;
180+
esac

docs/architecture.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ Thin layer. Resolves workspace (walk-up for language-specific markers), derives
3333
Why bash: zero runtime, works out of the box on macOS + Linux, no package install.
3434

3535
### `bin/lsp-stdio-proxy.js` — shared Node coordinator
36-
Generic coordinator for any STANDALONE stdio LSP (python, typescript, csharp, and future additions like Go/Rust/Ruby). Args:
36+
Generic coordinator for any STANDALONE stdio LSP (python, typescript, csharp, java, and future additions like Go/Rust/Ruby). Args:
3737
```
3838
node lsp-stdio-proxy.js --workspace <path> --port <N> --lang-id <id> -- <lsp-cmd> [<lsp-args>...]
3939
```

docs/convention.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Each supported language has a bash wrapper in `bin/` that proxies its LSP over p
1212
| python | `py-direct` | pyright-langserver | `pyrightconfig.json` > `pyproject.toml` > `setup.cfg` > `setup.py` |
1313
| typescript | `ts-direct` | typescript-language-server | `tsconfig.json` > `package.json` |
1414
| csharp | `cs-direct` | csharp-ls | `.slnx` > `.sln` > `.csproj` |
15+
| java | `java-direct` | jdtls (Eclipse JDT.LS) | `pom.xml` > `build.gradle.kts` > `build.gradle` > `settings.gradle.kts` > `settings.gradle` > `.project` |
1516

1617
## Invariants
1718

0 commit comments

Comments
 (0)