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