|
| 1 | +#!/usr/bin/env bash |
| 2 | +# cs-roslyn-direct — proxy Microsoft Roslyn Language Server over persistent HTTP |
| 3 | +# uses the binary shipped with VS Code C# Dev Kit / ms-dotnettools.csharp extension |
| 4 | +# faster cold-start than csharp-ls (uses Microsoft.CodeAnalysis.LanguageServer directly) |
| 5 | +# subcommands: start [workspace] | call <method> <json-params> [workspace] | stop [workspace] | status | tools [workspace] | prune |
| 6 | +set -euo pipefail |
| 7 | + |
| 8 | +STATE_ROOT="${CS_ROSLYN_DIRECT_STATE:-$HOME/.cache/cs-roslyn-direct}" |
| 9 | +PROXY="$HOME/.claude/bin/lsp-stdio-proxy.js" |
| 10 | + |
| 11 | +# locate Roslyn LS binary: env override → highest-version arm64 VSCode csharp ext → any platform variant |
| 12 | +if [ -n "${CS_ROSLYN_BIN:-}" ]; then |
| 13 | + LSP_BIN="$CS_ROSLYN_BIN" |
| 14 | +else |
| 15 | + LSP_BIN="$(ls -d "$HOME"/.vscode/extensions/ms-dotnettools.csharp-*-darwin-arm64/.roslyn/Microsoft.CodeAnalysis.LanguageServer 2>/dev/null | sort -V | tail -1 || true)" |
| 16 | + [ -z "$LSP_BIN" ] && LSP_BIN="$(ls -d "$HOME"/.vscode/extensions/ms-dotnettools.csharp-*/.roslyn/Microsoft.CodeAnalysis.LanguageServer 2>/dev/null | sort -V | tail -1 || true)" |
| 17 | +fi |
| 18 | + |
| 19 | +# Roslyn args: --stdio for proxy IPC, --logLevel Warning to suppress info noise |
| 20 | +LSP_ARGS=(--stdio --logLevel Warning) |
| 21 | +LANG_ID="csharp" |
| 22 | +WORKSPACE_MARKERS=(.slnx .sln .csproj) |
| 23 | + |
| 24 | +die() { echo "cs-roslyn-direct: $*" >&2; exit 1; } |
| 25 | + |
| 26 | +resolve_workspace() { |
| 27 | + local ws="${1:-}" |
| 28 | + if [ -n "$ws" ]; then ws="$(cd "$ws" && pwd)"; echo "$ws"; return; fi |
| 29 | + ws="$PWD" |
| 30 | + while [ "$ws" != "/" ]; do |
| 31 | + for marker in "${WORKSPACE_MARKERS[@]}"; do |
| 32 | + [ -e "$ws/$marker" ] && { echo "$ws"; return; } |
| 33 | + done |
| 34 | + ws="$(dirname "$ws")" |
| 35 | + done |
| 36 | + echo "$PWD" |
| 37 | +} |
| 38 | + |
| 39 | +state_dir() { |
| 40 | + local hash |
| 41 | + hash="$(printf '%s' "$1" | shasum | awk '{print $1}' | cut -c1-12)" |
| 42 | + echo "$STATE_ROOT/$hash" |
| 43 | +} |
| 44 | + |
| 45 | +free_port() { |
| 46 | + python3 -c 'import socket; s=socket.socket(); s.bind(("",0)); print(s.getsockname()[1]); s.close()' |
| 47 | +} |
| 48 | + |
| 49 | +state_get() { [ -f "$1/$2" ] && cat "$1/$2" || echo ""; } |
| 50 | + |
| 51 | +port_ready() { |
| 52 | + curl -fsS -m 2 "http://127.0.0.1:$1/health" >/dev/null 2>&1 |
| 53 | +} |
| 54 | + |
| 55 | +server_alive() { |
| 56 | + local port; port="$(state_get "$1" port)" |
| 57 | + [ -z "$port" ] && return 1 |
| 58 | + port_ready "$port" |
| 59 | +} |
| 60 | + |
| 61 | +cmd_start() { |
| 62 | + [ -n "$LSP_BIN" ] && [ -x "$LSP_BIN" ] || die "Roslyn LS binary not found — install VS Code C# extension (ms-dotnettools.csharp) or set CS_ROSLYN_BIN env to absolute path" |
| 63 | + [ -f "$PROXY" ] || die "proxy missing: $PROXY" |
| 64 | + local ws dir port pid |
| 65 | + ws="$(resolve_workspace "${1:-}")" |
| 66 | + dir="$(state_dir "$ws")" |
| 67 | + mkdir -p "$dir" |
| 68 | + if server_alive "$dir"; then |
| 69 | + echo "already running: workspace=$ws port=$(state_get "$dir" port) pid=$(state_get "$dir" pid)" |
| 70 | + return 0 |
| 71 | + fi |
| 72 | + port="$(free_port)" |
| 73 | + echo "$ws" > "$dir/workspace" |
| 74 | + echo "$port" > "$dir/port" |
| 75 | + nohup node "$PROXY" --tool-name cs-roslyn-direct --workspace "$ws" --port "$port" --lang-id "$LANG_ID" -- "$LSP_BIN" --extensionLogDirectory "$dir" ${LSP_ARGS[@]+"${LSP_ARGS[@]}"} >"$dir/log" 2>&1 & |
| 76 | + pid=$! |
| 77 | + echo "$pid" > "$dir/pid" |
| 78 | + local i=0 |
| 79 | + until port_ready "$port"; do |
| 80 | + sleep 1 |
| 81 | + i=$((i+1)) |
| 82 | + [ "$i" -ge 180 ] && { kill "$pid" 2>/dev/null; die "proxy did not bind port $port within 180s — check $dir/log"; } |
| 83 | + done |
| 84 | + echo "started: workspace=$ws port=$port pid=$pid state=$dir" |
| 85 | +} |
| 86 | + |
| 87 | +cmd_call() { |
| 88 | + local method="${1:-}"; shift || true |
| 89 | + local params_json="${1-}"; shift || true |
| 90 | + [ -z "$params_json" ] && params_json='{}' |
| 91 | + [ -z "$method" ] && die "usage: cs-roslyn-direct call <method> '<json-params>' [workspace]" |
| 92 | + local ws dir port |
| 93 | + ws="$(resolve_workspace "${1:-}")" |
| 94 | + dir="$(state_dir "$ws")" |
| 95 | + server_alive "$dir" || { echo "server not running for $ws — starting..." >&2; cmd_start "$ws" >&2; } |
| 96 | + port="$(state_get "$dir" port)" |
| 97 | + local payload |
| 98 | + payload="$(jq -cn --arg method "$method" --argjson params "$params_json" '{method:$method,params:$params}')" |
| 99 | + curl -fsS -m 120 "http://localhost:$port/lsp" -X POST \ |
| 100 | + -H 'Content-Type: application/json' \ |
| 101 | + -d "$payload" || die "lsp call failed (method=$method) — verify method name via '$0 tools' and daemon health via '$0 status'" |
| 102 | + echo |
| 103 | +} |
| 104 | + |
| 105 | +cmd_tools() { |
| 106 | + cat <<EOF |
| 107 | +cs-roslyn-direct exposes raw LSP methods (Microsoft Roslyn LS surface). |
| 108 | +invoke: cs-roslyn-direct call <method> '<json-params>' [workspace] |
| 109 | +
|
| 110 | +common methods: |
| 111 | + textDocument/documentSymbol outline of a .cs file |
| 112 | + textDocument/hover type + xmldoc at position |
| 113 | + textDocument/definition jump to definition |
| 114 | + textDocument/references find references |
| 115 | + textDocument/typeDefinition jump to type |
| 116 | + textDocument/implementation find implementations |
| 117 | + textDocument/completion completions at position |
| 118 | + textDocument/signatureHelp call signature |
| 119 | + textDocument/prepareCallHierarchy get call-hierarchy handle |
| 120 | + callHierarchy/incomingCalls callers |
| 121 | + callHierarchy/outgoingCalls callees |
| 122 | + workspace/symbol fuzzy search across workspace |
| 123 | +
|
| 124 | +params MUST include textDocument.uri as file://<abs-path> for textDocument/* methods. |
| 125 | +
|
| 126 | +example: |
| 127 | + cs-roslyn-direct call textDocument/documentSymbol '{"textDocument":{"uri":"file:///path/to/File.cs"}}' |
| 128 | +EOF |
| 129 | +} |
| 130 | + |
| 131 | +cmd_stop() { |
| 132 | + local ws dir pid |
| 133 | + ws="$(resolve_workspace "${1:-}")" |
| 134 | + dir="$(state_dir "$ws")" |
| 135 | + pid="$(state_get "$dir" pid)" |
| 136 | + if [ -n "$pid" ]; then |
| 137 | + kill "$pid" 2>/dev/null && echo "stopped: pid=$pid workspace=$ws" || echo "kill failed (pid=$pid) — state cleared for $ws" |
| 138 | + else |
| 139 | + echo "no pid for $ws" |
| 140 | + fi |
| 141 | + rm -f "$dir/pid" "$dir/port" |
| 142 | +} |
| 143 | + |
| 144 | +cmd_status() { |
| 145 | + [ -d "$STATE_ROOT" ] || { echo "no servers tracked"; return; } |
| 146 | + local any=0 |
| 147 | + for dir in "$STATE_ROOT"/*/; do |
| 148 | + [ -d "$dir" ] || continue |
| 149 | + any=1 |
| 150 | + local ws pid port alive |
| 151 | + ws="$(state_get "$dir" workspace)" |
| 152 | + pid="$(state_get "$dir" pid)" |
| 153 | + port="$(state_get "$dir" port)" |
| 154 | + alive="dead" |
| 155 | + [ -n "$port" ] && port_ready "$port" && alive="alive" |
| 156 | + printf 'workspace=%s port=%s pid=%s %s\n' "$ws" "$port" "$pid" "$alive" |
| 157 | + done |
| 158 | + [ "$any" = 0 ] && echo "no servers tracked" |
| 159 | +} |
| 160 | + |
| 161 | +cmd_prune() { |
| 162 | + [ -d "$STATE_ROOT" ] || { echo "no state dir"; return; } |
| 163 | + local removed=0 |
| 164 | + for dir in "$STATE_ROOT"/*/; do |
| 165 | + [ -d "$dir" ] || continue |
| 166 | + local pid port alive=0 |
| 167 | + pid="$(state_get "$dir" pid)" |
| 168 | + port="$(state_get "$dir" port)" |
| 169 | + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then alive=1; fi |
| 170 | + if [ "$alive" = 0 ] && [ -n "$port" ] && port_ready "$port"; then alive=1; fi |
| 171 | + if [ "$alive" = 0 ]; then |
| 172 | + rm -rf "$dir" |
| 173 | + removed=$((removed+1)) |
| 174 | + fi |
| 175 | + done |
| 176 | + echo "pruned $removed dead state dir(s) from $STATE_ROOT" |
| 177 | +} |
| 178 | + |
| 179 | +case "${1:-}" in |
| 180 | + start) shift; cmd_start "$@" ;; |
| 181 | + call) shift; cmd_call "$@" ;; |
| 182 | + tools) shift; cmd_tools "$@" ;; |
| 183 | + stop) shift; cmd_stop "$@" ;; |
| 184 | + status) shift; cmd_status "$@" ;; |
| 185 | + prune) shift; cmd_prune "$@" ;; |
| 186 | + ""|-h|--help) |
| 187 | + cat <<EOF |
| 188 | +cs-roslyn-direct — proxy Microsoft Roslyn LS over persistent HTTP; one server per workspace |
| 189 | +
|
| 190 | +usage: |
| 191 | + cs-roslyn-direct start [workspace] spawn proxy for workspace (default: cwd walking up for .slnx/.sln/.csproj) |
| 192 | + cs-roslyn-direct call <method> '<json-params>' [ws] issue raw LSP method — auto-starts server |
| 193 | + cs-roslyn-direct tools list LSP method surface |
| 194 | + cs-roslyn-direct stop [workspace] kill server for workspace |
| 195 | + cs-roslyn-direct status show all tracked servers |
| 196 | + cs-roslyn-direct prune remove state dirs for dead servers |
| 197 | +
|
| 198 | +workspace markers (walk-up order): ${WORKSPACE_MARKERS[*]} |
| 199 | +
|
| 200 | +state: $STATE_ROOT/<workspace-hash>/{pid,port,workspace,log} |
| 201 | +EOF |
| 202 | + ;; |
| 203 | + *) die "unknown subcommand: $1 (try --help)" ;; |
| 204 | +esac |
0 commit comments