LSP Configuration

This commit is contained in:
Jarek Rozanski 2026-09-28 09:59:14 +02:00
commit 3cd15d53a2
No known key found for this signature in database
3 changed files with 219 additions and 10 deletions

View file

@ -1,9 +1,35 @@
# OpenCode dev sandbox: rootless-Podman container with JDK 25, Node 24, pnpm,
# git, curl, and a hash-pinned build of OpenCode (github.com/anomalyco/opencode).
# OpenCode dev sandbox: rootless-Podman container with JDK 25, Node 24, Go,
# Rust, pnpm, git, curl, and a hash-pinned build of OpenCode
# (github.com/anomalyco/opencode).
#
# Built as three stages: JDK and Node are pulled from their official upstream
# images and copied (not apt-installed) into a slim Debian final stage, to
# keep the image small while staying on a mainstream glibc distro.
# Built as multi-stage: JDK, Node, Go, and Rust toolchains are pulled from
# their official upstream images and copied (not apt-installed) into a slim
# Debian final stage, to keep the image reasonably small while staying on a
# mainstream glibc distro.
#
# LSP support for OpenCode's built-in LSP client (off by default -- needs
# `"lsp": true` in opencode.json to actually turn on):
# - Go -> gopls, built from source against the pinned Go
# toolchain below.
# - Rust -> rust-analyzer, installed as a rustup
# component (same trust chain as the rest of the
# copied Rust toolchain). OpenCode has no
# auto-download fallback for this one, unlike
# gopls, so it must already be on PATH.
# - TypeScript/JavaScript -> typescript + typescript-language-server,
# installed as global npm packages; NODE_PATH
# below makes the global `typescript` package
# resolvable from any mounted project dir, even
# ones with no local node_modules.
# - Java -> jdtls, pre-baked (OpenCode has NO auto-download
# fallback for Java at all -- it must already be
# on PATH). No bin/jdtls ships upstream, so
# scripts/jdtls is a hand-written launcher
# wrapper, the same convention every other jdtls
# consumer (nvim-jdtls, mason.nvim, coc-java) uses.
# - Kotlin -> kotlin-lsp (JetBrains' official server),
# pre-baked. Ships its own bundled JetBrains
# Runtime, so it needs none of this image's JDK.
# ---- Stage: JDK source ------------------------------------------------------
# JDK 24 is EOL (Adoptium stopped building it Sept 2025; no 24-jdk* images
@ -17,6 +43,18 @@ FROM eclipse-temurin:25-jdk-noble AS jdk-source
# final base so glibc versions are identical -- no compatibility risk at all.
FROM node:24-trixie-slim AS node-source
# ---- Stage: Go source --------------------------------------------------------
# Full toolchain, not just a standalone LSP binary: gopls shells out to `go`
# itself and needs the standard library's source/type info to be useful --
# the same reason the JDK above is copied in whole rather than just a jar.
# Matched to Debian Trixie for the same glibc-alignment reason as node-source.
FROM golang:1.27.1-trixie AS go-source
# ---- Stage: Rust source -------------------------------------------------------
# Full toolchain, for the same reason as Go above -- rust-analyzer needs a
# real sysroot (std source + metadata) to do anything beyond bare syntax.
FROM rust:1.98.1-trixie AS rust-source
# ---- Final stage --------------------------------------------------------------
FROM debian:trixie-slim AS final
@ -40,6 +78,39 @@ ARG OPENCODE_SHA256_ARM64=568461b7d4d8c19865c97e9a1102e613049c6039d01fe772154de8
# the pnpm team doesn't recommend Corepack, and Node 25+ dropped bundling it).
ARG PNPM_VERSION=12.6.0
# gopls version, built from source at image-build time against the pinned Go
# toolchain (FROM golang:... above) via `go install`. Go's module system
# verifies this against the public checksum database (sum.golang.org)
# automatically, so no separate hash-pinning step is needed here.
ARG GOPLS_VERSION=0.23.0
# typescript + typescript-language-server versions, installed as global npm
# packages. Pinned to typescript's LAST JavaScript-based release (6.0.x) on
# purpose, NOT "latest" (7.x): TypeScript 7 is a native Go rewrite that no
# longer ships tsserver.js, which both typescript-language-server and
# OpenCode's own LSP client (which resolves "typescript/lib/tsserver.js"
# directly) require. Re-evaluate this pin if typescript-language-server ever
# ships a tsgo-native mode.
ARG TYPESCRIPT_VERSION=6.0.3
ARG TS_LS_VERSION=6.0.1
# jdtls: NOT distributed via GitHub Releases -- real distribution is
# Eclipse's own download server. Architecture-independent (pure JVM). Hash
# hand-verified against Eclipse's own published .sha256 file at
# https://download.eclipse.org/jdtls/milestones/<version>/<asset>.sha256 --
# re-run that check whenever JDTLS_VERSION/JDTLS_BUILD are bumped.
ARG JDTLS_VERSION=1.61.0
ARG JDTLS_BUILD=202609031315
ARG JDTLS_SHA256=338e7e73d61836651ba2453919a0d34fa763eb4e7c03342092309bffb8934c64
# kotlin-lsp: JetBrains' official server (github.com/Kotlin/kotlin-lsp), not
# the stale, long-unmaintained fwcd/kotlin-language-server. Per-arch native
# binary; JetBrains publishes no checksum, so these are hand-computed from
# the downloaded assets themselves, same as OPENCODE_SHA256_* above.
ARG KOTLIN_LSP_VERSION=263.4702.0
ARG KOTLIN_LSP_SHA256_AMD64=1e11d2e5fefbf9ea215ad8dd6be95f2222897cd086e8cb7a661a52084a590405
ARG KOTLIN_LSP_SHA256_ARM64=ec7cb254a6662a07fff9f10e4365226afab6c40008f8a974c10ac5e785d6510f
# ca-certificates : TLS roots for curl/git/npm/pnpm/JVM.
# fontconfig,
# libfreetype6 : Temurin's font subsystem needs these even headless.
@ -60,16 +131,38 @@ RUN apt-get update && \
openssh-client && \
rm -rf /var/lib/apt/lists/*
# --- JDK / Node, copied from the source stages ---------------------------------
# --- JDK / Node / Go / Rust, copied from the source stages ---------------------
COPY --from=jdk-source /opt/java/openjdk /opt/java/openjdk
COPY --from=node-source /usr/local/bin /usr/local/bin
COPY --from=node-source /usr/local/lib/node_modules /usr/local/lib/node_modules
COPY --from=go-source /usr/local/go /usr/local/go
COPY --from=rust-source /usr/local/cargo /usr/local/cargo
COPY --from=rust-source /usr/local/rustup /usr/local/rustup
# /usr/local/bin is already on Debian's default PATH, so node/npm/npx need no
# extra PATH entry. JAVA_HOME/bin and PNPM_HOME are non-standard and need one.
# extra PATH entry. Everything else here (JDK, pnpm, Go, Rust) lives under a
# non-standard prefix and needs one.
ENV JAVA_HOME=/opt/java/openjdk
ENV PNPM_HOME=/opt/pnpm
ENV PATH="${JAVA_HOME}/bin:${PNPM_HOME}/bin:${PATH}"
# GOPATH: writable install/cache dir for `go install` -- both the build-time
# gopls install below and anything `go`/gopls itself does at runtime (e.g.
# module downloads while analyzing a mounted project). Made world-writable
# further down, for the same --userns=keep-id reason /home/dev is.
ENV GOPATH=/go
ENV GOROOT=/usr/local/go
# CARGO_HOME/RUSTUP_HOME: matches the layout already baked into the
# rust-source image, so the copied toolchain and rustup metadata (including
# the recorded default toolchain) keep working unmodified.
ENV CARGO_HOME=/usr/local/cargo
ENV RUSTUP_HOME=/usr/local/rustup
ENV PATH="${JAVA_HOME}/bin:${PNPM_HOME}/bin:${GOROOT}/bin:${GOPATH}/bin:${CARGO_HOME}/bin:${PATH}"
# Makes the global `typescript` package (installed below) resolvable via
# Node's module resolution from *any* project directory OpenCode mounts at
# /workspace, even ones with no local node_modules of their own -- needed
# because OpenCode's TypeScript LSP support resolves
# "typescript/lib/tsserver.js" relative to the project directory, and
# NODE_PATH is the fallback once that climb reaches the filesystem root.
ENV NODE_PATH=/usr/local/lib/node_modules
# Fixed HOME: the container is run with --userns=keep-id, so the runtime UID
# is whatever host user invoked `podman run`, which has no /etc/passwd entry
@ -81,6 +174,9 @@ ENV HOME=/home/dev
# OPENCODE_CONFIG_DIR is OpenCode's one confirmed config-location override.
ENV OPENCODE_CONFIG_DIR=/home/dev/.config/opencode
RUN mkdir -p /home/dev && chmod 1777 /home/dev
# GOPATH needs to be writable by any --userns=keep-id-mapped UID too, for the
# same reason /home/dev is (see above).
RUN mkdir -p /go && chmod 1777 /go
# pnpm: standalone installer, pinned version. SHELL must be set explicitly --
# the installer can't infer a shell type inside a minimal `RUN` environment
@ -88,6 +184,58 @@ RUN mkdir -p /home/dev && chmod 1777 /home/dev
RUN curl -fsSL https://get.pnpm.io/install.sh | env PNPM_VERSION="${PNPM_VERSION}" SHELL=/bin/bash sh - && \
chmod -R a+rX "${PNPM_HOME}"
# rust-analyzer: installed as a rustup component, same trust chain (signed,
# checksum-verified by rustup itself) as the rest of the copied Rust
# toolchain. Unlike gopls below, OpenCode has no auto-download fallback for
# rust-analyzer -- it must already be resolvable on PATH.
RUN rustup component add rust-analyzer
# gopls: built from source via `go install`, pinned to GOPLS_VERSION (see the
# ARG comment above for why this needs no separate hash-pinning step).
RUN go install golang.org/x/tools/gopls@v${GOPLS_VERSION} && \
chmod -R a+rwX "${GOPATH}"
# typescript + typescript-language-server: global npm install, pinned (see
# the ARG comment above for why TYPESCRIPT_VERSION deliberately isn't "latest").
RUN npm install -g \
"typescript@${TYPESCRIPT_VERSION}" \
"typescript-language-server@${TS_LS_VERSION}"
# jdtls: architecture-independent (pure JVM) tarball, fetched directly from
# Eclipse's own download server (not GitHub Releases). No bin/jdtls launcher
# ships in it -- scripts/jdtls (copied in below) is the hand-written wrapper.
RUN set -eu; \
curl -fsSL -o /tmp/jdtls.tar.gz \
"https://download.eclipse.org/jdtls/milestones/${JDTLS_VERSION}/jdt-language-server-${JDTLS_VERSION}-${JDTLS_BUILD}.tar.gz"; \
printf '%s /tmp/jdtls.tar.gz\n' "$JDTLS_SHA256" | sha256sum -c -; \
mkdir -p /opt/jdtls; \
tar -xzf /tmp/jdtls.tar.gz -C /opt/jdtls; \
rm -f /tmp/jdtls.tar.gz
COPY scripts/jdtls /usr/local/bin/jdtls
RUN chmod +x /usr/local/bin/jdtls
# kotlin-lsp: arch-aware fetch (same pattern as the OpenCode section below),
# SHA256 verified by hand (see the ARG comment above). The tarball extracts
# to a versioned top-level dir, flattened here via --strip-components=1.
# bin/intellij-server is the real binary -- kotlin-lsp.sh (not copied) is
# just a deprecated wrapper that execs it with no extra flags, confirmed by
# reading that script directly. Symlinked under both plausible command names
# since it's unconfirmed which one OpenCode's Kotlin LSP integration expects.
RUN set -eu; \
case "$TARGETARCH" in \
amd64) KLS_ASSET="kotlin-server-${KOTLIN_LSP_VERSION}.tar.gz"; KLS_SHA256="$KOTLIN_LSP_SHA256_AMD64" ;; \
arm64) KLS_ASSET="kotlin-server-${KOTLIN_LSP_VERSION}-aarch64.tar.gz"; KLS_SHA256="$KOTLIN_LSP_SHA256_ARM64" ;; \
*) echo "ERROR: unsupported TARGETARCH='$TARGETARCH'" >&2; exit 1 ;; \
esac; \
curl -fsSL -o /tmp/kotlin-lsp.tar.gz \
"https://download.jetbrains.com/language-server/kotlin-server/${KOTLIN_LSP_VERSION}/${KLS_ASSET}"; \
printf '%s /tmp/kotlin-lsp.tar.gz\n' "$KLS_SHA256" | sha256sum -c -; \
mkdir -p /opt/kotlin-lsp; \
tar -xzf /tmp/kotlin-lsp.tar.gz -C /opt/kotlin-lsp --strip-components=1; \
rm -f /tmp/kotlin-lsp.tar.gz; \
ln -s /opt/kotlin-lsp/bin/intellij-server /usr/local/bin/kotlin-lsp; \
ln -s /opt/kotlin-lsp/bin/intellij-server /usr/local/bin/kotlin-language-server
# OpenCode: arch-aware fetch, SHA256 verification, install.
# Asset naming uses "x64" for the amd64 arch, not "amd64".
# Confirmed layout: both tarballs contain the binary flat at top level as
@ -113,7 +261,14 @@ RUN { ldd /usr/local/bin/opencode || true; } | tee /tmp/opencode-ldd.txt && \
exit 1; \
fi && \
rm -f /tmp/opencode-ldd.txt
RUN java -version && node --version && pnpm --version && git --version && curl --version
# jdtls/kotlin-lsp aren't invoked here (both block waiting on stdio LSP
# input with no --version-style flag) -- just checked for presence instead.
RUN java -version && node --version && pnpm --version && git --version && curl --version && \
go version && rustc --version && rust-analyzer --version && gopls version && \
tsc --version && typescript-language-server --version && \
test -x /usr/local/bin/jdtls && \
ls /opt/jdtls/plugins/org.eclipse.equinox.launcher_*.jar >/dev/null && \
test -x /usr/local/bin/kotlin-lsp && test -x /opt/kotlin-lsp/bin/intellij-server
WORKDIR /workspace
CMD ["opencode"]

View file

@ -5,7 +5,8 @@ A rootless Podman container for running [OpenCode](https://github.com/anomalyco/
## What this gives you
- A hash-pinned, integrity-verified build of OpenCode (not "latest" — an exact version, checked against a SHA256 captured at build time).
- JDK 25 (LTS), Node 24 (LTS), pnpm, git, curl, openssh-client.
- JDK 25 (LTS), Node 24 (LTS), Go, Rust, pnpm, git, curl, openssh-client.
- Language servers for Go, Rust, TypeScript/JavaScript, Java, and Kotlin, ready for OpenCode's built-in LSP client (see [LSP support](#lsp-support) below).
- The current directory mounted read-write as `/workspace`.
- OpenCode's config, data, and session state shared across every workdir/container, so logging in once carries over everywhere.
- Every container joined to a shared Podman network so it can reach other dev containers by name.
@ -50,6 +51,20 @@ The edit is wrapped in `# >>> opencode-sandbox >>>` markers so re-running
`make install` never duplicates it, and is skipped entirely if
`~/.local/bin` is already on `PATH`.
## LSP support
OpenCode's built-in LSP client is off by default — turn it on per-project with `"lsp": true` in that project's `opencode.json` (or configure individual servers there; see OpenCode's own docs for the object form). Once enabled, this image is ready for it:
| Language | Server | How it gets there |
|---|---|---|
| Go | `gopls` | Built from source at image-build time (`go install`), pinned via `GOPLS_VERSION` in the Containerfile. |
| Rust | `rust-analyzer` | Installed as a `rustup` component at image-build time — OpenCode has no auto-download fallback for this one, so it has to already be on `PATH`. |
| TypeScript / JavaScript | `typescript` + `typescript-language-server` | Installed as global npm packages, pinned via `TYPESCRIPT_VERSION`/`TS_LS_VERSION`. Pinned to TypeScript's last JavaScript-based release (6.0.x) — TypeScript 7 is a native Go rewrite that dropped `tsserver.js`, which `typescript-language-server` needs to attach to. `NODE_PATH` is set so the global `typescript` package resolves even from a project with no local `node_modules`. |
| Java | `jdtls` | Pre-baked — OpenCode has no auto-download fallback for Java at all, so this has to be on `PATH`. Fetched straight from Eclipse's own download server (not GitHub Releases; architecture-independent, pure JVM). No `bin/jdtls` ships upstream, so `scripts/jdtls` is a hand-written launcher wrapper — the same convention every other jdtls consumer (nvim-jdtls, mason.nvim, coc-java) uses — which also manages a persistent, per-project `-data` workspace dir under OpenCode's host-persisted data dir. |
| Kotlin | `kotlin-lsp` | Pre-baked — JetBrains' official server (`github.com/Kotlin/kotlin-lsp`), fetched per-arch and hash-verified by hand (JetBrains publishes no checksum). Ships its own bundled JetBrains Runtime, so it needs none of this image's JDK. Symlinked as both `kotlin-lsp` and `kotlin-language-server`, since it's unconfirmed which name OpenCode's Kotlin integration expects. |
Bumping a pinned LSP version means editing the corresponding `ARG`/`FROM` tag in the Containerfile and rebuilding (`make build`).
## How it's put together
**Containerfile** — multi-stage build on `debian:trixie-slim`:

39
scripts/jdtls Executable file
View file

@ -0,0 +1,39 @@
#!/bin/sh
# jdtls launcher: the eclipse.jdt.ls project ships no bin/jdtls of its own --
# every consumer (nvim-jdtls, mason.nvim, coc-java, etc.) hand-writes one, so
# this mirrors that same well-established convention. Installed at
# /usr/local/bin/jdtls by the Containerfile so OpenCode's Java LSP
# integration (which has no auto-download fallback for jdtls, unlike gopls)
# can find a bare `jdtls` command on PATH.
set -eu
JDTLS_HOME=/opt/jdtls
# jdtls requires a persistent, project-specific workspace dir (`-data`): the
# same one must be reused across restarts for a given project, and different
# projects must NOT share one (jdtls indexes it as if it were a single
# workspace). Derived from a hash of the real project path and stored under
# OpenCode's own host-persisted data dir (see the Makefile's DATA_DIR bind
# mount) so it survives container restarts instead of vanishing with `--rm`.
workspace_hash=$(printf '%s' "$(pwd -P)" | cksum | cut -d' ' -f1)
data_dir="$HOME/.local/share/opencode/jdtls-workspaces/$workspace_hash"
mkdir -p "$data_dir"
launcher=$(ls "$JDTLS_HOME"/plugins/org.eclipse.equinox.launcher_*.jar 2>/dev/null | head -n1)
if [ -z "$launcher" ]; then
echo "jdtls: equinox launcher jar not found under $JDTLS_HOME/plugins" >&2
exit 1
fi
exec java \
--add-modules=ALL-SYSTEM \
--add-opens java.base/java.util=ALL-UNNAMED \
--add-opens java.base/java.lang=ALL-UNNAMED \
-Declipse.application=org.eclipse.jdt.ls.core.id1 \
-Dosgi.bundles.defaultStartLevel=4 \
-Declipse.product=org.eclipse.jdt.ls.core.product \
-Dlog.protocol=true -Dlog.level=ALL -Xmx1G \
-jar "$launcher" \
-configuration "$JDTLS_HOME/config_linux" \
-data "$data_dir" \
"$@"