feat: LSP integration - client, server manager, diagnostic registry (Phase 3C)
- 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>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user