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