- LSPClient: JSON-RPC over stdio with Content-Length framing - LSPServerInstance: single server lifecycle with lazy startup and crash recovery - LSPServerManager: multi-server routing by file extension, file sync (didOpen/didChange/didSave/didClose) - LSPDiagnosticRegistry: batch+cross-turn dedup, per-file/total limits, severity sorting - LSPServerConfig: server config record with extension-to-language mapping - Features: exponential backoff for content-modified errors, LRU cache for delivered diagnostics, handler registration before connection ready, formatForContext() for agent injection Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>pull/1/head
parent
dcba6c86ae
commit
293dd5657c
@ -0,0 +1,308 @@ |
||||
package com.claudecode.lsp; |
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode; |
||||
import com.fasterxml.jackson.databind.ObjectMapper; |
||||
import com.fasterxml.jackson.databind.node.ObjectNode; |
||||
import org.slf4j.Logger; |
||||
import org.slf4j.LoggerFactory; |
||||
|
||||
import java.io.*; |
||||
import java.nio.charset.StandardCharsets; |
||||
import java.util.Map; |
||||
import java.util.concurrent.*; |
||||
import java.util.concurrent.atomic.AtomicInteger; |
||||
import java.util.function.Consumer; |
||||
|
||||
/** |
||||
* LSP JSON-RPC 客户端 —— 对应 claude-code/src/services/lsp/LSPClient.ts。 |
||||
* <p> |
||||
* 通过 stdio 管道与 LSP 服务器进行 JSON-RPC 2.0 通信。 |
||||
* <p> |
||||
* 协议格式: |
||||
* <pre> |
||||
* Content-Length: {length}\r\n |
||||
* \r\n |
||||
* {JSON-RPC body} |
||||
* </pre> |
||||
*/ |
||||
public class LSPClient implements AutoCloseable { |
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LSPClient.class); |
||||
private static final ObjectMapper MAPPER = new ObjectMapper(); |
||||
|
||||
private final String serverName; |
||||
private final AtomicInteger requestIdCounter = new AtomicInteger(0); |
||||
|
||||
/** 挂起的请求:id → CompletableFuture */ |
||||
private final ConcurrentHashMap<Integer, CompletableFuture<JsonNode>> pendingRequests |
||||
= new ConcurrentHashMap<>(); |
||||
|
||||
/** 通知处理器:method → handler */ |
||||
private final ConcurrentHashMap<String, Consumer<JsonNode>> notificationHandlers |
||||
= new ConcurrentHashMap<>(); |
||||
|
||||
private Process process; |
||||
private OutputStream stdin; |
||||
private Thread readerThread; |
||||
private volatile boolean running = false; |
||||
private volatile boolean stopping = false; |
||||
|
||||
public LSPClient(String serverName) { |
||||
this.serverName = serverName; |
||||
} |
||||
|
||||
/** |
||||
* 启动 LSP 服务器进程。 |
||||
*/ |
||||
public void start(String command, java.util.List<String> args, |
||||
Map<String, String> env, String cwd) throws IOException { |
||||
var cmd = new java.util.ArrayList<String>(); |
||||
cmd.add(command); |
||||
if (args != null) cmd.addAll(args); |
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(cmd) |
||||
.redirectErrorStream(false); |
||||
if (cwd != null) pb.directory(new File(cwd)); |
||||
if (env != null) pb.environment().putAll(env); |
||||
|
||||
process = pb.start(); |
||||
stdin = process.getOutputStream(); |
||||
running = true; |
||||
|
||||
// 启动 stdout 读取线程
|
||||
readerThread = Thread.ofVirtual() |
||||
.name("lsp-reader-" + serverName) |
||||
.start(() -> readLoop(process.getInputStream())); |
||||
|
||||
// stderr 日志线程
|
||||
Thread.ofVirtual() |
||||
.name("lsp-stderr-" + serverName) |
||||
.start(() -> { |
||||
try (var reader = new BufferedReader( |
||||
new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8))) { |
||||
String line; |
||||
while ((line = reader.readLine()) != null) { |
||||
if (!stopping) { |
||||
log.debug("[{}] stderr: {}", serverName, line); |
||||
} |
||||
} |
||||
} catch (IOException e) { |
||||
if (!stopping) log.debug("[{}] stderr read error", serverName, e); |
||||
} |
||||
}); |
||||
|
||||
log.info("[{}] LSP server started: {} {}", serverName, command, args); |
||||
} |
||||
|
||||
/** |
||||
* 发送 JSON-RPC 请求并等待响应。 |
||||
*/ |
||||
public JsonNode sendRequest(String method, Object params) throws Exception { |
||||
return sendRequest(method, params, 30_000); |
||||
} |
||||
|
||||
public JsonNode sendRequest(String method, Object params, long timeoutMs) throws Exception { |
||||
int id = requestIdCounter.incrementAndGet(); |
||||
CompletableFuture<JsonNode> future = new CompletableFuture<>(); |
||||
pendingRequests.put(id, future); |
||||
|
||||
try { |
||||
ObjectNode request = MAPPER.createObjectNode(); |
||||
request.put("jsonrpc", "2.0"); |
||||
request.put("id", id); |
||||
request.put("method", method); |
||||
if (params != null) { |
||||
request.set("params", MAPPER.valueToTree(params)); |
||||
} |
||||
|
||||
sendRaw(request); |
||||
return future.get(timeoutMs, TimeUnit.MILLISECONDS); |
||||
} catch (TimeoutException e) { |
||||
pendingRequests.remove(id); |
||||
throw new IOException("Request timed out: " + method); |
||||
} finally { |
||||
pendingRequests.remove(id); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* 发送 JSON-RPC 通知(无需响应)。 |
||||
*/ |
||||
public void sendNotification(String method, Object params) throws IOException { |
||||
ObjectNode notification = MAPPER.createObjectNode(); |
||||
notification.put("jsonrpc", "2.0"); |
||||
notification.put("method", method); |
||||
if (params != null) { |
||||
notification.set("params", MAPPER.valueToTree(params)); |
||||
} |
||||
sendRaw(notification); |
||||
} |
||||
|
||||
/** |
||||
* 注册通知处理器。 |
||||
*/ |
||||
public void onNotification(String method, Consumer<JsonNode> handler) { |
||||
notificationHandlers.put(method, handler); |
||||
} |
||||
|
||||
/** |
||||
* 发送初始化请求。 |
||||
*/ |
||||
public JsonNode initialize(Map<String, Object> initParams) throws Exception { |
||||
JsonNode result = sendRequest("initialize", initParams, 60_000); |
||||
// 发送 initialized 通知
|
||||
sendNotification("initialized", Map.of()); |
||||
return result; |
||||
} |
||||
|
||||
/** |
||||
* 优雅关闭。 |
||||
*/ |
||||
@Override |
||||
public void close() { |
||||
if (!running) return; |
||||
stopping = true; |
||||
running = false; |
||||
|
||||
try { |
||||
// 发送 shutdown 请求
|
||||
sendRequest("shutdown", null, 5_000); |
||||
} catch (Exception e) { |
||||
log.debug("[{}] Shutdown request failed (expected if server crashed)", serverName); |
||||
} |
||||
|
||||
try { |
||||
// 发送 exit 通知
|
||||
sendNotification("exit", null); |
||||
} catch (Exception ignored) {} |
||||
|
||||
// 取消所有挂起的请求
|
||||
pendingRequests.values().forEach(f -> |
||||
f.completeExceptionally(new IOException("Client shutting down"))); |
||||
pendingRequests.clear(); |
||||
|
||||
// 终止进程
|
||||
if (process != null && process.isAlive()) { |
||||
process.destroy(); |
||||
try { |
||||
if (!process.waitFor(3, TimeUnit.SECONDS)) { |
||||
process.destroyForcibly(); |
||||
} |
||||
} catch (InterruptedException e) { |
||||
process.destroyForcibly(); |
||||
Thread.currentThread().interrupt(); |
||||
} |
||||
} |
||||
|
||||
log.info("[{}] LSP client closed", serverName); |
||||
} |
||||
|
||||
// ==================== 内部实现 ====================
|
||||
|
||||
private synchronized void sendRaw(JsonNode message) throws IOException { |
||||
if (!running || stdin == null) { |
||||
throw new IOException("Client not running"); |
||||
} |
||||
|
||||
byte[] body = MAPPER.writeValueAsBytes(message); |
||||
String header = "Content-Length: " + body.length + "\r\n\r\n"; |
||||
|
||||
stdin.write(header.getBytes(StandardCharsets.US_ASCII)); |
||||
stdin.write(body); |
||||
stdin.flush(); |
||||
} |
||||
|
||||
private void readLoop(InputStream inputStream) { |
||||
try (var reader = new BufferedReader( |
||||
new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { |
||||
while (running) { |
||||
// 读取 header
|
||||
int contentLength = readContentLength(reader); |
||||
if (contentLength < 0) break; |
||||
|
||||
// 读取 body
|
||||
char[] body = new char[contentLength]; |
||||
int read = 0; |
||||
while (read < contentLength) { |
||||
int n = reader.read(body, read, contentLength - read); |
||||
if (n < 0) break; |
||||
read += n; |
||||
} |
||||
if (read < contentLength) break; |
||||
|
||||
// 解析 JSON-RPC
|
||||
try { |
||||
JsonNode message = MAPPER.readTree(new String(body)); |
||||
handleMessage(message); |
||||
} catch (Exception e) { |
||||
log.error("[{}] Failed to parse message", serverName, e); |
||||
} |
||||
} |
||||
} catch (IOException e) { |
||||
if (!stopping) { |
||||
log.error("[{}] Read loop error", serverName, e); |
||||
} |
||||
} |
||||
} |
||||
|
||||
private int readContentLength(BufferedReader reader) throws IOException { |
||||
String line; |
||||
int contentLength = -1; |
||||
|
||||
while ((line = reader.readLine()) != null) { |
||||
if (line.isEmpty()) { |
||||
// 空行 = header 结束
|
||||
return contentLength; |
||||
} |
||||
if (line.startsWith("Content-Length:")) { |
||||
contentLength = Integer.parseInt(line.substring("Content-Length:".length()).trim()); |
||||
} |
||||
} |
||||
return -1; // EOF
|
||||
} |
||||
|
||||
private void handleMessage(JsonNode message) { |
||||
if (message.has("id")) { |
||||
// Response
|
||||
int id = message.get("id").asInt(); |
||||
CompletableFuture<JsonNode> future = pendingRequests.get(id); |
||||
if (future != null) { |
||||
if (message.has("error")) { |
||||
JsonNode error = message.get("error"); |
||||
future.completeExceptionally(new LSPException( |
||||
error.path("code").asInt(), |
||||
error.path("message").asText("Unknown error"))); |
||||
} else { |
||||
future.complete(message.get("result")); |
||||
} |
||||
} |
||||
} else if (message.has("method")) { |
||||
// Notification
|
||||
String method = message.get("method").asText(); |
||||
Consumer<JsonNode> handler = notificationHandlers.get(method); |
||||
if (handler != null) { |
||||
try { |
||||
handler.accept(message.get("params")); |
||||
} catch (Exception e) { |
||||
log.error("[{}] Notification handler error for {}", serverName, method, e); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
public boolean isRunning() { |
||||
return running && process != null && process.isAlive(); |
||||
} |
||||
|
||||
/** LSP 协议错误 */ |
||||
public static class LSPException extends Exception { |
||||
private final int code; |
||||
public LSPException(int code, String message) { |
||||
super(message); |
||||
this.code = code; |
||||
} |
||||
public int getCode() { return code; } |
||||
/** 内容被修改(可重试) */ |
||||
public boolean isContentModified() { return code == -32801; } |
||||
} |
||||
} |
||||
@ -0,0 +1,251 @@ |
||||
package com.claudecode.lsp; |
||||
|
||||
import org.slf4j.Logger; |
||||
import org.slf4j.LoggerFactory; |
||||
|
||||
import java.security.MessageDigest; |
||||
import java.util.*; |
||||
import java.util.concurrent.ConcurrentHashMap; |
||||
import java.util.concurrent.ConcurrentLinkedDeque; |
||||
|
||||
/** |
||||
* LSP 诊断注册表 —— 对应 claude-code/src/services/lsp/LSPDiagnosticRegistry.ts。 |
||||
* <p> |
||||
* 管理从 LSP 服务器收到的诊断信息: |
||||
* <ul> |
||||
* <li>批内去重(同一轮次内的重复诊断)</li> |
||||
* <li>跨轮去重(已交付过的诊断不再重复)</li> |
||||
* <li>数量限制(每文件 10 条,总计 30 条)</li> |
||||
* <li>严重性排序(Error > Warning > Info > Hint)</li> |
||||
* </ul> |
||||
*/ |
||||
public class LSPDiagnosticRegistry { |
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LSPDiagnosticRegistry.class); |
||||
|
||||
public static final int MAX_DIAGNOSTICS_PER_FILE = 10; |
||||
public static final int MAX_TOTAL_DIAGNOSTICS = 30; |
||||
private static final int MAX_DELIVERED_FILES_CACHE = 500; |
||||
|
||||
// ==================== 数据类型 ====================
|
||||
|
||||
public record Diagnostic( |
||||
String message, |
||||
String severity, // "Error", "Warning", "Info", "Hint"
|
||||
int startLine, |
||||
int startChar, |
||||
int endLine, |
||||
int endChar, |
||||
String source, |
||||
String code |
||||
) { |
||||
public String key() { |
||||
return message + "|" + severity + "|" + |
||||
startLine + ":" + startChar + "-" + endLine + ":" + endChar + "|" + |
||||
(source != null ? source : "") + "|" + |
||||
(code != null ? code : ""); |
||||
} |
||||
|
||||
/** 严重性排序权重(越小越严重) */ |
||||
public int severityWeight() { |
||||
return switch (severity) { |
||||
case "Error" -> 0; |
||||
case "Warning" -> 1; |
||||
case "Info" -> 2; |
||||
case "Hint" -> 3; |
||||
default -> 4; |
||||
}; |
||||
} |
||||
} |
||||
|
||||
public record DiagnosticFile( |
||||
String filePath, |
||||
List<Diagnostic> diagnostics |
||||
) {} |
||||
|
||||
public record PendingBatch( |
||||
String serverName, |
||||
List<DiagnosticFile> files, |
||||
long timestamp |
||||
) {} |
||||
|
||||
// ==================== 状态 ====================
|
||||
|
||||
/** 待处理的诊断批次 */ |
||||
private final ConcurrentLinkedDeque<PendingBatch> pendingBatches = new ConcurrentLinkedDeque<>(); |
||||
|
||||
/** 已交付的诊断键(跨轮去重):filePath → Set<diagnosticKey> */ |
||||
private final ConcurrentHashMap<String, Set<String>> deliveredDiagnostics = new ConcurrentHashMap<>(); |
||||
|
||||
/** LRU 顺序追踪(配合 deliveredDiagnostics 做容量限制) */ |
||||
private final ConcurrentLinkedDeque<String> deliveredFilesOrder = new ConcurrentLinkedDeque<>(); |
||||
|
||||
// ==================== 注册诊断 ====================
|
||||
|
||||
/** |
||||
* 注册一批诊断信息(由 LSP 服务器通知触发)。 |
||||
*/ |
||||
public void registerDiagnostics(String serverName, String filePath, List<Diagnostic> diagnostics) { |
||||
if (diagnostics == null || diagnostics.isEmpty()) return; |
||||
|
||||
var file = new DiagnosticFile(filePath, diagnostics); |
||||
pendingBatches.addLast(new PendingBatch(serverName, List.of(file), System.currentTimeMillis())); |
||||
} |
||||
|
||||
// ==================== 检查和提取 ====================
|
||||
|
||||
/** |
||||
* 检查并提取待处理的诊断信息。 |
||||
* <p> |
||||
* 执行去重和数量限制后返回。已交付的诊断会被记录,不再重复返回。 |
||||
* |
||||
* @return 去重后的诊断文件列表(为空时返回空列表) |
||||
*/ |
||||
public List<DiagnosticFile> checkAndExtract() { |
||||
if (pendingBatches.isEmpty()) return List.of(); |
||||
|
||||
// 收集所有待处理批次
|
||||
List<PendingBatch> batches = new ArrayList<>(); |
||||
PendingBatch batch; |
||||
while ((batch = pendingBatches.pollFirst()) != null) { |
||||
batches.add(batch); |
||||
} |
||||
|
||||
if (batches.isEmpty()) return List.of(); |
||||
|
||||
// 按文件分组并合并
|
||||
Map<String, List<Diagnostic>> fileGroups = new LinkedHashMap<>(); |
||||
for (PendingBatch b : batches) { |
||||
for (DiagnosticFile df : b.files) { |
||||
fileGroups.computeIfAbsent(df.filePath, k -> new ArrayList<>()) |
||||
.addAll(df.diagnostics); |
||||
} |
||||
} |
||||
|
||||
// 去重 + 限制
|
||||
List<DiagnosticFile> result = new ArrayList<>(); |
||||
int totalCount = 0; |
||||
|
||||
for (var entry : fileGroups.entrySet()) { |
||||
String filePath = entry.getKey(); |
||||
List<Diagnostic> diagnostics = entry.getValue(); |
||||
|
||||
// 批内去重
|
||||
Set<String> seenKeys = new HashSet<>(); |
||||
List<Diagnostic> deduped = new ArrayList<>(); |
||||
for (Diagnostic d : diagnostics) { |
||||
if (seenKeys.add(d.key())) { |
||||
deduped.add(d); |
||||
} |
||||
} |
||||
|
||||
// 跨轮去重
|
||||
Set<String> delivered = deliveredDiagnostics.get(filePath); |
||||
if (delivered != null) { |
||||
deduped.removeIf(d -> delivered.contains(d.key())); |
||||
} |
||||
|
||||
if (deduped.isEmpty()) continue; |
||||
|
||||
// 按严重性排序
|
||||
deduped.sort(Comparator.comparingInt(Diagnostic::severityWeight)); |
||||
|
||||
// 每文件限制
|
||||
if (deduped.size() > MAX_DIAGNOSTICS_PER_FILE) { |
||||
int truncated = deduped.size() - MAX_DIAGNOSTICS_PER_FILE; |
||||
deduped = new ArrayList<>(deduped.subList(0, MAX_DIAGNOSTICS_PER_FILE)); |
||||
log.debug("Truncated {} diagnostics for file {}", truncated, filePath); |
||||
} |
||||
|
||||
// 总量限制
|
||||
int remaining = MAX_TOTAL_DIAGNOSTICS - totalCount; |
||||
if (remaining <= 0) break; |
||||
if (deduped.size() > remaining) { |
||||
deduped = new ArrayList<>(deduped.subList(0, remaining)); |
||||
} |
||||
|
||||
totalCount += deduped.size(); |
||||
|
||||
// 记录为已交付
|
||||
markDelivered(filePath, deduped); |
||||
|
||||
result.add(new DiagnosticFile(filePath, deduped)); |
||||
} |
||||
|
||||
return result; |
||||
} |
||||
|
||||
// ==================== 已交付追踪 ====================
|
||||
|
||||
private void markDelivered(String filePath, List<Diagnostic> diagnostics) { |
||||
Set<String> keys = deliveredDiagnostics.computeIfAbsent(filePath, k -> { |
||||
deliveredFilesOrder.addLast(filePath); |
||||
evictOldFiles(); |
||||
return ConcurrentHashMap.newKeySet(); |
||||
}); |
||||
|
||||
for (Diagnostic d : diagnostics) { |
||||
keys.add(d.key()); |
||||
} |
||||
} |
||||
|
||||
private void evictOldFiles() { |
||||
while (deliveredFilesOrder.size() > MAX_DELIVERED_FILES_CACHE) { |
||||
String oldest = deliveredFilesOrder.pollFirst(); |
||||
if (oldest != null) { |
||||
deliveredDiagnostics.remove(oldest); |
||||
} |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* 清除指定文件的已交付记录(文件编辑后应调用)。 |
||||
*/ |
||||
public void clearDeliveredForFile(String fileUri) { |
||||
deliveredDiagnostics.remove(fileUri); |
||||
} |
||||
|
||||
/** |
||||
* 清除所有诊断状态。 |
||||
*/ |
||||
public void clearAll() { |
||||
pendingBatches.clear(); |
||||
deliveredDiagnostics.clear(); |
||||
deliveredFilesOrder.clear(); |
||||
} |
||||
|
||||
/** |
||||
* 获取待处理诊断数量。 |
||||
*/ |
||||
public int getPendingCount() { |
||||
return pendingBatches.size(); |
||||
} |
||||
|
||||
// ==================== 格式化 ====================
|
||||
|
||||
/** |
||||
* 将诊断列表格式化为可注入 Agent 上下文的文本。 |
||||
*/ |
||||
public static String formatForContext(List<DiagnosticFile> files) { |
||||
if (files.isEmpty()) return ""; |
||||
|
||||
StringBuilder sb = new StringBuilder(); |
||||
sb.append("## LSP Diagnostics\n\n"); |
||||
|
||||
for (DiagnosticFile df : files) { |
||||
sb.append("### ").append(df.filePath).append("\n"); |
||||
for (Diagnostic d : df.diagnostics) { |
||||
sb.append("- [").append(d.severity).append("]"); |
||||
if (d.source != null) sb.append(" (").append(d.source).append(")"); |
||||
sb.append(" Line ").append(d.startLine + 1); |
||||
if (d.startChar > 0) sb.append(":").append(d.startChar); |
||||
sb.append(": ").append(d.message); |
||||
if (d.code != null) sb.append(" [").append(d.code).append("]"); |
||||
sb.append("\n"); |
||||
} |
||||
sb.append("\n"); |
||||
} |
||||
|
||||
return sb.toString(); |
||||
} |
||||
} |
||||
@ -0,0 +1,56 @@ |
||||
package com.claudecode.lsp; |
||||
|
||||
import java.util.List; |
||||
import java.util.Map; |
||||
|
||||
/** |
||||
* LSP 服务器配置 —— 对应 claude-code/src/services/lsp/config.ts 的 LspServerConfig。 |
||||
* |
||||
* @param name 服务器名称(唯一标识) |
||||
* @param command 可执行文件路径 |
||||
* @param args 命令行参数 |
||||
* @param extensionToLanguage 文件扩展名→语言ID映射 (e.g., {".ts": "typescript"}) |
||||
* @param transport 传输方式: "stdio" (默认) 或 "socket" |
||||
* @param env 环境变量 |
||||
* @param initializationOptions LSP 初始化选项 |
||||
* @param workspaceFolder 工作目录 |
||||
* @param startupTimeout 启动超时(毫秒) |
||||
* @param maxRestarts 最大重启次数(默认 3) |
||||
*/ |
||||
public record LSPServerConfig( |
||||
String name, |
||||
String command, |
||||
List<String> args, |
||||
Map<String, String> extensionToLanguage, |
||||
String transport, |
||||
Map<String, String> env, |
||||
Map<String, Object> initializationOptions, |
||||
String workspaceFolder, |
||||
long startupTimeout, |
||||
int maxRestarts |
||||
) { |
||||
public LSPServerConfig { |
||||
if (command == null || command.isBlank()) { |
||||
throw new IllegalArgumentException("LSP server command is required"); |
||||
} |
||||
if (extensionToLanguage == null || extensionToLanguage.isEmpty()) { |
||||
throw new IllegalArgumentException("At least one extensionToLanguage mapping is required"); |
||||
} |
||||
if (args == null) args = List.of(); |
||||
if (transport == null) transport = "stdio"; |
||||
if (env == null) env = Map.of(); |
||||
if (initializationOptions == null) initializationOptions = Map.of(); |
||||
if (startupTimeout <= 0) startupTimeout = 30_000; |
||||
if (maxRestarts <= 0) maxRestarts = 3; |
||||
} |
||||
|
||||
/** 获取此服务器支持的所有文件扩展名 */ |
||||
public List<String> supportedExtensions() { |
||||
return List.copyOf(extensionToLanguage.keySet()); |
||||
} |
||||
|
||||
/** 获取文件扩展名对应的语言ID */ |
||||
public String languageForExtension(String ext) { |
||||
return extensionToLanguage.get(ext); |
||||
} |
||||
} |
||||
@ -0,0 +1,277 @@ |
||||
package com.claudecode.lsp; |
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode; |
||||
import org.slf4j.Logger; |
||||
import org.slf4j.LoggerFactory; |
||||
|
||||
import java.io.IOException; |
||||
import java.nio.file.Path; |
||||
import java.util.*; |
||||
import java.util.concurrent.ConcurrentHashMap; |
||||
|
||||
/** |
||||
* LSP 服务器实例 —— 对应 claude-code/src/services/lsp/LSPServerInstance.ts。 |
||||
* <p> |
||||
* 管理单个 LSP 服务器的完整生命周期: |
||||
* <ul> |
||||
* <li>延迟启动(首次使用时才启动)</li> |
||||
* <li>初始化(发送 initialize + initialized)</li> |
||||
* <li>文件同步(didOpen/didChange/didSave/didClose)</li> |
||||
* <li>崩溃恢复(最多 maxRestarts 次)</li> |
||||
* </ul> |
||||
*/ |
||||
public class LSPServerInstance implements AutoCloseable { |
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LSPServerInstance.class); |
||||
|
||||
public enum State { STOPPED, STARTING, RUNNING, STOPPING, ERROR } |
||||
|
||||
private final String name; |
||||
private final LSPServerConfig config; |
||||
private final Path workspaceRoot; |
||||
|
||||
private LSPClient client; |
||||
private volatile State state = State.STOPPED; |
||||
private int crashCount = 0; |
||||
private JsonNode serverCapabilities; |
||||
|
||||
/** 已打开的文件:URI → 版本号 */ |
||||
private final ConcurrentHashMap<String, Integer> openFiles = new ConcurrentHashMap<>(); |
||||
|
||||
public LSPServerInstance(String name, LSPServerConfig config, Path workspaceRoot) { |
||||
this.name = name; |
||||
this.config = config; |
||||
this.workspaceRoot = workspaceRoot; |
||||
} |
||||
|
||||
/** |
||||
* 确保服务器已启动(延迟启动逻辑)。 |
||||
* |
||||
* @return true 如果服务器正在运行 |
||||
*/ |
||||
public synchronized boolean ensureStarted() { |
||||
if (state == State.RUNNING) return true; |
||||
if (state == State.STARTING) return false; |
||||
|
||||
if (state == State.ERROR && crashCount >= config.maxRestarts()) { |
||||
log.warn("[{}] Max restarts ({}) reached, not restarting", name, config.maxRestarts()); |
||||
return false; |
||||
} |
||||
|
||||
try { |
||||
start(); |
||||
return state == State.RUNNING; |
||||
} catch (Exception e) { |
||||
log.error("[{}] Failed to start", name, e); |
||||
state = State.ERROR; |
||||
return false; |
||||
} |
||||
} |
||||
|
||||
private void start() throws Exception { |
||||
state = State.STARTING; |
||||
log.info("[{}] Starting LSP server: {} {}", name, config.command(), config.args()); |
||||
|
||||
client = new LSPClient(name); |
||||
client.start(config.command(), config.args(), config.env(), |
||||
config.workspaceFolder() != null ? config.workspaceFolder() : workspaceRoot.toString()); |
||||
|
||||
// 注册诊断通知处理器(在 initialize 之前注册)
|
||||
// 具体的处理由 LSPServerManager 通过 onDiagnostics 回调完成
|
||||
|
||||
// 发送初始化请求
|
||||
Map<String, Object> initParams = buildInitializeParams(); |
||||
JsonNode result = client.initialize(initParams); |
||||
serverCapabilities = result != null ? result.get("capabilities") : null; |
||||
|
||||
state = State.RUNNING; |
||||
log.info("[{}] LSP server initialized (capabilities: {})", |
||||
name, serverCapabilities != null ? serverCapabilities.fieldNames() : "none"); |
||||
} |
||||
|
||||
private Map<String, Object> buildInitializeParams() { |
||||
String wsUri = workspaceRoot.toUri().toString(); |
||||
String wsName = workspaceRoot.getFileName().toString(); |
||||
|
||||
Map<String, Object> params = new LinkedHashMap<>(); |
||||
params.put("processId", ProcessHandle.current().pid()); |
||||
params.put("rootPath", workspaceRoot.toString()); |
||||
params.put("rootUri", wsUri); |
||||
params.put("initializationOptions", config.initializationOptions()); |
||||
params.put("workspaceFolders", List.of(Map.of("uri", wsUri, "name", wsName))); |
||||
|
||||
// 客户端能力声明
|
||||
Map<String, Object> capabilities = new LinkedHashMap<>(); |
||||
|
||||
// workspace
|
||||
capabilities.put("workspace", Map.of( |
||||
"configuration", false, |
||||
"workspaceFolders", false |
||||
)); |
||||
|
||||
// textDocument
|
||||
Map<String, Object> textDoc = new LinkedHashMap<>(); |
||||
textDoc.put("synchronization", Map.of("didSave", true, "willSave", false)); |
||||
textDoc.put("publishDiagnostics", Map.of("relatedInformation", true)); |
||||
textDoc.put("hover", Map.of("contentFormat", List.of("markdown", "plaintext"))); |
||||
textDoc.put("definition", Map.of("linkSupport", true)); |
||||
textDoc.put("references", Map.of()); |
||||
textDoc.put("documentSymbol", Map.of("hierarchicalDocumentSymbolSupport", true)); |
||||
textDoc.put("callHierarchy", Map.of()); |
||||
capabilities.put("textDocument", textDoc); |
||||
|
||||
// general
|
||||
capabilities.put("general", Map.of("positionEncodings", List.of("utf-16"))); |
||||
|
||||
params.put("capabilities", capabilities); |
||||
return params; |
||||
} |
||||
|
||||
// ==================== 文件同步 ====================
|
||||
|
||||
/** |
||||
* 通知服务器打开文件。 |
||||
*/ |
||||
public void openFile(String filePath, String content) throws IOException { |
||||
if (state != State.RUNNING) return; |
||||
|
||||
String uri = pathToUri(filePath); |
||||
String languageId = getLanguageId(filePath); |
||||
int version = 1; |
||||
openFiles.put(uri, version); |
||||
|
||||
client.sendNotification("textDocument/didOpen", Map.of( |
||||
"textDocument", Map.of( |
||||
"uri", uri, |
||||
"languageId", languageId, |
||||
"version", version, |
||||
"text", content |
||||
) |
||||
)); |
||||
} |
||||
|
||||
/** |
||||
* 通知服务器文件内容变更。 |
||||
*/ |
||||
public void changeFile(String filePath, String content) throws IOException { |
||||
if (state != State.RUNNING) return; |
||||
|
||||
String uri = pathToUri(filePath); |
||||
int version = openFiles.compute(uri, (k, v) -> v == null ? 1 : v + 1); |
||||
|
||||
client.sendNotification("textDocument/didChange", Map.of( |
||||
"textDocument", Map.of("uri", uri, "version", version), |
||||
"contentChanges", List.of(Map.of("text", content)) |
||||
)); |
||||
} |
||||
|
||||
/** |
||||
* 通知服务器文件已保存。 |
||||
*/ |
||||
public void saveFile(String filePath) throws IOException { |
||||
if (state != State.RUNNING) return; |
||||
String uri = pathToUri(filePath); |
||||
int version = openFiles.getOrDefault(uri, 1); |
||||
|
||||
client.sendNotification("textDocument/didSave", Map.of( |
||||
"textDocument", Map.of("uri", uri, "version", version) |
||||
)); |
||||
} |
||||
|
||||
/** |
||||
* 通知服务器关闭文件。 |
||||
*/ |
||||
public void closeFile(String filePath) throws IOException { |
||||
if (state != State.RUNNING) return; |
||||
String uri = pathToUri(filePath); |
||||
openFiles.remove(uri); |
||||
|
||||
client.sendNotification("textDocument/didClose", Map.of( |
||||
"textDocument", Map.of("uri", uri) |
||||
)); |
||||
} |
||||
|
||||
// ==================== 请求 ====================
|
||||
|
||||
/** |
||||
* 发送请求到 LSP 服务器,支持内容修改重试。 |
||||
*/ |
||||
public JsonNode sendRequest(String method, Object params) throws Exception { |
||||
if (state != State.RUNNING) { |
||||
throw new IOException("Server not running: " + name); |
||||
} |
||||
|
||||
int retries = 3; |
||||
long delay = 500; |
||||
|
||||
for (int i = 0; i < retries; i++) { |
||||
try { |
||||
return client.sendRequest(method, params); |
||||
} catch (LSPClient.LSPException e) { |
||||
if (e.isContentModified() && i < retries - 1) { |
||||
log.debug("[{}] Content modified, retrying in {}ms", name, delay); |
||||
Thread.sleep(delay); |
||||
delay *= 2; |
||||
} else { |
||||
throw e; |
||||
} |
||||
} |
||||
} |
||||
throw new IOException("Request failed after retries: " + method); |
||||
} |
||||
|
||||
/** |
||||
* 注册通知处理器(转发到 LSPClient)。 |
||||
*/ |
||||
public void onNotification(String method, java.util.function.Consumer<JsonNode> handler) { |
||||
if (client != null) { |
||||
client.onNotification(method, handler); |
||||
} |
||||
} |
||||
|
||||
// ==================== 生命周期 ====================
|
||||
|
||||
@Override |
||||
public void close() { |
||||
if (state == State.STOPPED || state == State.STOPPING) return; |
||||
state = State.STOPPING; |
||||
|
||||
if (client != null) { |
||||
client.close(); |
||||
} |
||||
openFiles.clear(); |
||||
|
||||
state = State.STOPPED; |
||||
log.info("[{}] LSP server stopped", name); |
||||
} |
||||
|
||||
public void onCrash() { |
||||
crashCount++; |
||||
state = State.ERROR; |
||||
log.warn("[{}] LSP server crashed (count: {}/{})", name, crashCount, config.maxRestarts()); |
||||
} |
||||
|
||||
// ==================== 工具方法 ====================
|
||||
|
||||
private String pathToUri(String filePath) { |
||||
return Path.of(filePath).toUri().toString(); |
||||
} |
||||
|
||||
private String getLanguageId(String filePath) { |
||||
int dot = filePath.lastIndexOf('.'); |
||||
if (dot < 0) return "plaintext"; |
||||
String ext = filePath.substring(dot); |
||||
String lang = config.languageForExtension(ext); |
||||
return lang != null ? lang : "plaintext"; |
||||
} |
||||
|
||||
public boolean isFileOpen(String filePath) { |
||||
return openFiles.containsKey(pathToUri(filePath)); |
||||
} |
||||
|
||||
public String getName() { return name; } |
||||
public State getState() { return state; } |
||||
public LSPServerConfig getConfig() { return config; } |
||||
public JsonNode getCapabilities() { return serverCapabilities; } |
||||
public int getCrashCount() { return crashCount; } |
||||
} |
||||
@ -0,0 +1,281 @@ |
||||
package com.claudecode.lsp; |
||||
|
||||
import org.slf4j.Logger; |
||||
import org.slf4j.LoggerFactory; |
||||
|
||||
import java.nio.file.Path; |
||||
import java.util.*; |
||||
import java.util.concurrent.ConcurrentHashMap; |
||||
|
||||
/** |
||||
* LSP 服务器管理器 —— 对应 claude-code/src/services/lsp/LSPServerManager.ts。 |
||||
* <p> |
||||
* 管理多个 LSP 服务器实例,按文件扩展名路由请求。 |
||||
* 支持延迟启动:只在首次使用某种文件类型时才启动对应服务器。 |
||||
*/ |
||||
public class LSPServerManager implements AutoCloseable { |
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LSPServerManager.class); |
||||
|
||||
private final Path workspaceRoot; |
||||
private final Map<String, LSPServerInstance> servers = new ConcurrentHashMap<>(); |
||||
private final Map<String, String> extensionToServerName = new ConcurrentHashMap<>(); |
||||
private final Map<String, String> openedFiles = new ConcurrentHashMap<>(); |
||||
|
||||
/** 诊断注册回调 */ |
||||
private LSPDiagnosticRegistry diagnosticRegistry; |
||||
|
||||
public LSPServerManager(Path workspaceRoot) { |
||||
this.workspaceRoot = workspaceRoot; |
||||
} |
||||
|
||||
public void setDiagnosticRegistry(LSPDiagnosticRegistry registry) { |
||||
this.diagnosticRegistry = registry; |
||||
} |
||||
|
||||
/** |
||||
* 注册一个 LSP 服务器配置。 |
||||
* <p> |
||||
* 服务器不会立即启动,而是在首次需要时延迟启动。 |
||||
*/ |
||||
public void registerServer(LSPServerConfig config) { |
||||
String name = config.name(); |
||||
LSPServerInstance instance = new LSPServerInstance(name, config, workspaceRoot); |
||||
servers.put(name, instance); |
||||
|
||||
// 建立扩展名→服务器名的映射
|
||||
for (String ext : config.supportedExtensions()) { |
||||
String existing = extensionToServerName.get(ext); |
||||
if (existing != null) { |
||||
log.warn("Extension {} already mapped to {}, overriding with {}", |
||||
ext, existing, name); |
||||
} |
||||
extensionToServerName.put(ext, name); |
||||
} |
||||
|
||||
log.info("Registered LSP server: {} (extensions: {})", name, config.supportedExtensions()); |
||||
} |
||||
|
||||
/** |
||||
* 获取处理指定文件的服务器(如果需要则延迟启动)。 |
||||
*/ |
||||
public LSPServerInstance ensureServerForFile(String filePath) { |
||||
String ext = getExtension(filePath); |
||||
String serverName = extensionToServerName.get(ext); |
||||
if (serverName == null) return null; |
||||
|
||||
LSPServerInstance instance = servers.get(serverName); |
||||
if (instance == null) return null; |
||||
|
||||
// 延迟启动
|
||||
if (instance.getState() != LSPServerInstance.State.RUNNING) { |
||||
if (!instance.ensureStarted()) { |
||||
return null; |
||||
} |
||||
// 启动后注册诊断处理器
|
||||
registerDiagnosticHandler(instance); |
||||
} |
||||
|
||||
return instance; |
||||
} |
||||
|
||||
/** |
||||
* 获取文件对应的服务器(不启动)。 |
||||
*/ |
||||
public LSPServerInstance getServerForFile(String filePath) { |
||||
String ext = getExtension(filePath); |
||||
String serverName = extensionToServerName.get(ext); |
||||
if (serverName == null) return null; |
||||
return servers.get(serverName); |
||||
} |
||||
|
||||
// ==================== 文件同步 ====================
|
||||
|
||||
public void openFile(String filePath, String content) { |
||||
LSPServerInstance server = ensureServerForFile(filePath); |
||||
if (server == null) return; |
||||
|
||||
try { |
||||
server.openFile(filePath, content); |
||||
openedFiles.put(filePath, server.getName()); |
||||
} catch (Exception e) { |
||||
log.error("Failed to open file in LSP: {}", filePath, e); |
||||
} |
||||
} |
||||
|
||||
public void changeFile(String filePath, String content) { |
||||
String serverName = openedFiles.get(filePath); |
||||
if (serverName == null) return; |
||||
|
||||
LSPServerInstance server = servers.get(serverName); |
||||
if (server == null) return; |
||||
|
||||
try { |
||||
server.changeFile(filePath, content); |
||||
} catch (Exception e) { |
||||
log.error("Failed to notify file change in LSP: {}", filePath, e); |
||||
} |
||||
} |
||||
|
||||
public void saveFile(String filePath) { |
||||
String serverName = openedFiles.get(filePath); |
||||
if (serverName == null) { |
||||
// 文件可能是第一次出现,尝试打开
|
||||
return; |
||||
} |
||||
|
||||
LSPServerInstance server = servers.get(serverName); |
||||
if (server == null) return; |
||||
|
||||
try { |
||||
server.saveFile(filePath); |
||||
} catch (Exception e) { |
||||
log.error("Failed to notify file save in LSP: {}", filePath, e); |
||||
} |
||||
|
||||
// 保存后清除该文件的已交付诊断(触发重新检查)
|
||||
if (diagnosticRegistry != null) { |
||||
diagnosticRegistry.clearDeliveredForFile(Path.of(filePath).toUri().toString()); |
||||
} |
||||
} |
||||
|
||||
public void closeFile(String filePath) { |
||||
String serverName = openedFiles.remove(filePath); |
||||
if (serverName == null) return; |
||||
|
||||
LSPServerInstance server = servers.get(serverName); |
||||
if (server == null) return; |
||||
|
||||
try { |
||||
server.closeFile(filePath); |
||||
} catch (Exception e) { |
||||
log.error("Failed to close file in LSP: {}", filePath, e); |
||||
} |
||||
} |
||||
|
||||
public boolean isFileOpen(String filePath) { |
||||
return openedFiles.containsKey(filePath); |
||||
} |
||||
|
||||
// ==================== 请求路由 ====================
|
||||
|
||||
/** |
||||
* 向文件对应的 LSP 服务器发送请求。 |
||||
*/ |
||||
public com.fasterxml.jackson.databind.JsonNode sendRequest( |
||||
String filePath, String method, Object params) throws Exception { |
||||
LSPServerInstance server = ensureServerForFile(filePath); |
||||
if (server == null) { |
||||
return null; |
||||
} |
||||
return server.sendRequest(method, params); |
||||
} |
||||
|
||||
// ==================== 诊断处理 ====================
|
||||
|
||||
private void registerDiagnosticHandler(LSPServerInstance instance) { |
||||
if (diagnosticRegistry == null) return; |
||||
|
||||
instance.onNotification("textDocument/publishDiagnostics", params -> { |
||||
try { |
||||
String uri = params.get("uri").asText(); |
||||
var diagnosticsNode = params.get("diagnostics"); |
||||
|
||||
List<LSPDiagnosticRegistry.Diagnostic> diagnostics = new ArrayList<>(); |
||||
if (diagnosticsNode != null && diagnosticsNode.isArray()) { |
||||
for (var diagNode : diagnosticsNode) { |
||||
String message = diagNode.path("message").asText(""); |
||||
int severity = diagNode.path("severity").asInt(1); |
||||
String source = diagNode.path("source").asText(null); |
||||
String code = diagNode.path("code").asText(null); |
||||
|
||||
var range = diagNode.get("range"); |
||||
int startLine = 0, startChar = 0, endLine = 0, endChar = 0; |
||||
if (range != null) { |
||||
var start = range.get("start"); |
||||
var end = range.get("end"); |
||||
if (start != null) { |
||||
startLine = start.path("line").asInt(); |
||||
startChar = start.path("character").asInt(); |
||||
} |
||||
if (end != null) { |
||||
endLine = end.path("line").asInt(); |
||||
endChar = end.path("character").asInt(); |
||||
} |
||||
} |
||||
|
||||
diagnostics.add(new LSPDiagnosticRegistry.Diagnostic( |
||||
message, mapSeverity(severity), |
||||
startLine, startChar, endLine, endChar, |
||||
source, code)); |
||||
} |
||||
} |
||||
|
||||
// 转换 URI 到文件路径
|
||||
String filePath = uriToPath(uri); |
||||
diagnosticRegistry.registerDiagnostics( |
||||
instance.getName(), filePath, diagnostics); |
||||
} catch (Exception e) { |
||||
log.error("[{}] Failed to process diagnostics", instance.getName(), e); |
||||
} |
||||
}); |
||||
} |
||||
|
||||
// ==================== 生命周期 ====================
|
||||
|
||||
@Override |
||||
public void close() { |
||||
log.info("Shutting down LSP server manager ({} servers)", servers.size()); |
||||
for (LSPServerInstance server : servers.values()) { |
||||
try { |
||||
server.close(); |
||||
} catch (Exception e) { |
||||
log.error("Failed to stop LSP server: {}", server.getName(), e); |
||||
} |
||||
} |
||||
servers.clear(); |
||||
extensionToServerName.clear(); |
||||
openedFiles.clear(); |
||||
} |
||||
|
||||
public boolean isConnected() { |
||||
return servers.values().stream() |
||||
.anyMatch(s -> s.getState() == LSPServerInstance.State.RUNNING); |
||||
} |
||||
|
||||
public Map<String, LSPServerInstance> getAllServers() { |
||||
return Map.copyOf(servers); |
||||
} |
||||
|
||||
// ==================== 工具方法 ====================
|
||||
|
||||
private static String getExtension(String filePath) { |
||||
int dot = filePath.lastIndexOf('.'); |
||||
return dot >= 0 ? filePath.substring(dot) : ""; |
||||
} |
||||
|
||||
private static String mapSeverity(int lspSeverity) { |
||||
return switch (lspSeverity) { |
||||
case 1 -> "Error"; |
||||
case 2 -> "Warning"; |
||||
case 3 -> "Info"; |
||||
case 4 -> "Hint"; |
||||
default -> "Error"; |
||||
}; |
||||
} |
||||
|
||||
private static String uriToPath(String uri) { |
||||
if (uri.startsWith("file:///")) { |
||||
String path = uri.substring("file:///".length()); |
||||
// Windows: file:///C:/path → C:/path
|
||||
if (path.length() > 1 && path.charAt(1) == ':') { |
||||
return path.replace('/', '\\'); |
||||
} |
||||
return "/" + path; |
||||
} |
||||
if (uri.startsWith("file://")) { |
||||
return uri.substring("file://".length()); |
||||
} |
||||
return uri; |
||||
} |
||||
} |
||||
Loading…
Reference in new issue