From 293dd5657c8b5f98db3f8b737314e5d95352c5d5 Mon Sep 17 00:00:00 2001 From: abel533 Date: Sun, 5 Apr 2026 10:30:25 +0800 Subject: [PATCH] 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> --- .../java/com/claudecode/lsp/LSPClient.java | 308 ++++++++++++++++++ .../claudecode/lsp/LSPDiagnosticRegistry.java | 251 ++++++++++++++ .../com/claudecode/lsp/LSPServerConfig.java | 56 ++++ .../com/claudecode/lsp/LSPServerInstance.java | 277 ++++++++++++++++ .../com/claudecode/lsp/LSPServerManager.java | 281 ++++++++++++++++ 5 files changed, 1173 insertions(+) create mode 100644 src/main/java/com/claudecode/lsp/LSPClient.java create mode 100644 src/main/java/com/claudecode/lsp/LSPDiagnosticRegistry.java create mode 100644 src/main/java/com/claudecode/lsp/LSPServerConfig.java create mode 100644 src/main/java/com/claudecode/lsp/LSPServerInstance.java create mode 100644 src/main/java/com/claudecode/lsp/LSPServerManager.java diff --git a/src/main/java/com/claudecode/lsp/LSPClient.java b/src/main/java/com/claudecode/lsp/LSPClient.java new file mode 100644 index 0000000..fb9afc4 --- /dev/null +++ b/src/main/java/com/claudecode/lsp/LSPClient.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。 + *

+ * 通过 stdio 管道与 LSP 服务器进行 JSON-RPC 2.0 通信。 + *

+ * 协议格式: + *

+ * Content-Length: {length}\r\n
+ * \r\n
+ * {JSON-RPC body}
+ * 
+ */ +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> pendingRequests + = new ConcurrentHashMap<>(); + + /** 通知处理器:method → handler */ + private final ConcurrentHashMap> 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 args, + Map env, String cwd) throws IOException { + var cmd = new java.util.ArrayList(); + 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 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 handler) { + notificationHandlers.put(method, handler); + } + + /** + * 发送初始化请求。 + */ + public JsonNode initialize(Map 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 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 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; } + } +} diff --git a/src/main/java/com/claudecode/lsp/LSPDiagnosticRegistry.java b/src/main/java/com/claudecode/lsp/LSPDiagnosticRegistry.java new file mode 100644 index 0000000..e5789e1 --- /dev/null +++ b/src/main/java/com/claudecode/lsp/LSPDiagnosticRegistry.java @@ -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。 + *

+ * 管理从 LSP 服务器收到的诊断信息: + *

    + *
  • 批内去重(同一轮次内的重复诊断)
  • + *
  • 跨轮去重(已交付过的诊断不再重复)
  • + *
  • 数量限制(每文件 10 条,总计 30 条)
  • + *
  • 严重性排序(Error > Warning > Info > Hint)
  • + *
+ */ +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 diagnostics + ) {} + + public record PendingBatch( + String serverName, + List files, + long timestamp + ) {} + + // ==================== 状态 ==================== + + /** 待处理的诊断批次 */ + private final ConcurrentLinkedDeque pendingBatches = new ConcurrentLinkedDeque<>(); + + /** 已交付的诊断键(跨轮去重):filePath → Set */ + private final ConcurrentHashMap> deliveredDiagnostics = new ConcurrentHashMap<>(); + + /** LRU 顺序追踪(配合 deliveredDiagnostics 做容量限制) */ + private final ConcurrentLinkedDeque deliveredFilesOrder = new ConcurrentLinkedDeque<>(); + + // ==================== 注册诊断 ==================== + + /** + * 注册一批诊断信息(由 LSP 服务器通知触发)。 + */ + public void registerDiagnostics(String serverName, String filePath, List diagnostics) { + if (diagnostics == null || diagnostics.isEmpty()) return; + + var file = new DiagnosticFile(filePath, diagnostics); + pendingBatches.addLast(new PendingBatch(serverName, List.of(file), System.currentTimeMillis())); + } + + // ==================== 检查和提取 ==================== + + /** + * 检查并提取待处理的诊断信息。 + *

+ * 执行去重和数量限制后返回。已交付的诊断会被记录,不再重复返回。 + * + * @return 去重后的诊断文件列表(为空时返回空列表) + */ + public List checkAndExtract() { + if (pendingBatches.isEmpty()) return List.of(); + + // 收集所有待处理批次 + List batches = new ArrayList<>(); + PendingBatch batch; + while ((batch = pendingBatches.pollFirst()) != null) { + batches.add(batch); + } + + if (batches.isEmpty()) return List.of(); + + // 按文件分组并合并 + Map> fileGroups = new LinkedHashMap<>(); + for (PendingBatch b : batches) { + for (DiagnosticFile df : b.files) { + fileGroups.computeIfAbsent(df.filePath, k -> new ArrayList<>()) + .addAll(df.diagnostics); + } + } + + // 去重 + 限制 + List result = new ArrayList<>(); + int totalCount = 0; + + for (var entry : fileGroups.entrySet()) { + String filePath = entry.getKey(); + List diagnostics = entry.getValue(); + + // 批内去重 + Set seenKeys = new HashSet<>(); + List deduped = new ArrayList<>(); + for (Diagnostic d : diagnostics) { + if (seenKeys.add(d.key())) { + deduped.add(d); + } + } + + // 跨轮去重 + Set 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 diagnostics) { + Set 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 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(); + } +} diff --git a/src/main/java/com/claudecode/lsp/LSPServerConfig.java b/src/main/java/com/claudecode/lsp/LSPServerConfig.java new file mode 100644 index 0000000..ebb639a --- /dev/null +++ b/src/main/java/com/claudecode/lsp/LSPServerConfig.java @@ -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 args, + Map extensionToLanguage, + String transport, + Map env, + Map 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 supportedExtensions() { + return List.copyOf(extensionToLanguage.keySet()); + } + + /** 获取文件扩展名对应的语言ID */ + public String languageForExtension(String ext) { + return extensionToLanguage.get(ext); + } +} diff --git a/src/main/java/com/claudecode/lsp/LSPServerInstance.java b/src/main/java/com/claudecode/lsp/LSPServerInstance.java new file mode 100644 index 0000000..2284374 --- /dev/null +++ b/src/main/java/com/claudecode/lsp/LSPServerInstance.java @@ -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。 + *

+ * 管理单个 LSP 服务器的完整生命周期: + *

    + *
  • 延迟启动(首次使用时才启动)
  • + *
  • 初始化(发送 initialize + initialized)
  • + *
  • 文件同步(didOpen/didChange/didSave/didClose)
  • + *
  • 崩溃恢复(最多 maxRestarts 次)
  • + *
+ */ +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 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 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 buildInitializeParams() { + String wsUri = workspaceRoot.toUri().toString(); + String wsName = workspaceRoot.getFileName().toString(); + + Map 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 capabilities = new LinkedHashMap<>(); + + // workspace + capabilities.put("workspace", Map.of( + "configuration", false, + "workspaceFolders", false + )); + + // textDocument + Map 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 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; } +} diff --git a/src/main/java/com/claudecode/lsp/LSPServerManager.java b/src/main/java/com/claudecode/lsp/LSPServerManager.java new file mode 100644 index 0000000..6942aea --- /dev/null +++ b/src/main/java/com/claudecode/lsp/LSPServerManager.java @@ -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。 + *

+ * 管理多个 LSP 服务器实例,按文件扩展名路由请求。 + * 支持延迟启动:只在首次使用某种文件类型时才启动对应服务器。 + */ +public class LSPServerManager implements AutoCloseable { + + private static final Logger log = LoggerFactory.getLogger(LSPServerManager.class); + + private final Path workspaceRoot; + private final Map servers = new ConcurrentHashMap<>(); + private final Map extensionToServerName = new ConcurrentHashMap<>(); + private final Map openedFiles = new ConcurrentHashMap<>(); + + /** 诊断注册回调 */ + private LSPDiagnosticRegistry diagnosticRegistry; + + public LSPServerManager(Path workspaceRoot) { + this.workspaceRoot = workspaceRoot; + } + + public void setDiagnosticRegistry(LSPDiagnosticRegistry registry) { + this.diagnosticRegistry = registry; + } + + /** + * 注册一个 LSP 服务器配置。 + *

+ * 服务器不会立即启动,而是在首次需要时延迟启动。 + */ + 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 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 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; + } +}