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>
pull/1/head
abel533 1 month ago
parent dcba6c86ae
commit 293dd5657c
  1. 308
      src/main/java/com/claudecode/lsp/LSPClient.java
  2. 251
      src/main/java/com/claudecode/lsp/LSPDiagnosticRegistry.java
  3. 56
      src/main/java/com/claudecode/lsp/LSPServerConfig.java
  4. 277
      src/main/java/com/claudecode/lsp/LSPServerInstance.java
  5. 281
      src/main/java/com/claudecode/lsp/LSPServerManager.java

@ -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…
Cancel
Save