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;
+ }
+}