369 lines
14 KiB
Markdown
369 lines
14 KiB
Markdown
# 基础设施设计 — LSP 集成
|
||
|
||
## 文档元数据
|
||
- 项目名称: free-code
|
||
- 文档类型: 基础设施设计
|
||
- 原始代码来源: `../../src/services/lsp/`(7个文件)
|
||
- 原始设计意图: 在 .NET 中封装语言服务器生命周期、文件同步与 9 种核心 LSP 操作,并支持按扩展名路由与诊断基线比较
|
||
- 交叉引用: [基础设施设计总览](基础设施设计.md) | [核心模块设计-工具系统](../核心模块设计/核心模块设计-工具系统.md)
|
||
|
||
## 设计目标
|
||
LSP 层用于承载代码补全、诊断、跳转与重命名等 IDE 能力,为编辑体验提供统一协议封装。它既要兼容多语言服务器,又要支持懒启动、文件同步和跨文件诊断聚合。
|
||
|
||
## 11.1 ILspClientManager 接口定义
|
||
|
||
```csharp
|
||
/// <summary>
|
||
/// LSP 客户端管理器 — 管理 LSP 服务器实例
|
||
/// 对应原始 LSPServerManager.ts
|
||
/// </summary>
|
||
public interface ILspClientManager
|
||
{
|
||
/// <summary>初始化 (加载配置, 懒启动)</summary>
|
||
Task InitializeAsync(CancellationToken ct = default);
|
||
|
||
/// <summary>关闭所有服务器</summary>
|
||
Task ShutdownAsync();
|
||
|
||
/// <summary>获取文件对应的 LSP 服务器</summary>
|
||
ILspServerInstance? GetServerForFile(string filePath);
|
||
|
||
/// <summary>确保文件对应的 LSP 服务器已启动</summary>
|
||
Task<ILspServerInstance?> EnsureServerStartedAsync(string filePath);
|
||
|
||
/// <summary>发送请求到文件对应的 LSP 服务器</summary>
|
||
Task<T?> SendRequestAsync<T>(string filePath, string method, object? parameters);
|
||
|
||
// LSP 操作 (对应原始 LSPTool 的 9 种操作)
|
||
Task<Location?> GoToDefinitionAsync(string filePath, int line, int character);
|
||
Task<Location[]> FindReferencesAsync(string filePath, int line, int character);
|
||
Task<Hover?> HoverAsync(string filePath, int line, int character);
|
||
Task<Symbol[]> DocumentSymbolsAsync(string filePath);
|
||
Task<Symbol[]> WorkspaceSymbolsAsync(string query);
|
||
Task<Diagnostic[]> GetDiagnosticsAsync(string filePath);
|
||
Task<PrepareRenameResult?> PrepareRenameAsync(string filePath, int line, int character);
|
||
Task<WorkspaceEdit?> RenameAsync(string filePath, int line, int character, string newName);
|
||
Task<CodeAction[]> GetCodeActionsAsync(string filePath, int line, int character);
|
||
|
||
// 文件同步
|
||
Task OpenFileAsync(string filePath, string content);
|
||
Task ChangeFileAsync(string filePath, string content);
|
||
Task SaveFileAsync(string filePath);
|
||
Task CloseFileAsync(string filePath);
|
||
|
||
/// <summary>所有运行中的服务器</summary>
|
||
IReadOnlyDictionary<string, ILspServerInstance> GetAllServers();
|
||
|
||
/// <summary>是否至少有一个服务器已连接</summary>
|
||
bool IsConnected { get; }
|
||
}
|
||
```
|
||
|
||
## 11.2 LspServerInstance 实现
|
||
|
||
```csharp
|
||
/// <summary>
|
||
/// 单个 LSP 服务器实例 — 管理子进程生命周期
|
||
/// 对应原始 LSPServerInstance.ts
|
||
/// </summary>
|
||
public interface ILspServerInstance
|
||
{
|
||
string Name { get; }
|
||
string Command { get; }
|
||
IReadOnlyDictionary<string, string> ExtensionToLanguage { get; }
|
||
LspServerState State { get; }
|
||
|
||
Task StartAsync();
|
||
Task StopAsync();
|
||
Task<T> SendRequestAsync<T>(string method, object? parameters);
|
||
Task SendNotificationAsync(string method, object? parameters);
|
||
void OnRequest(string method, Func<object, object?> handler);
|
||
}
|
||
|
||
public enum LspServerState { Stopped, Starting, Running, Error }
|
||
|
||
/// <summary>
|
||
/// LSP 服务器实例实现
|
||
/// </summary>
|
||
public sealed class LspServerInstance : ILspServerInstance, IAsyncDisposable
|
||
{
|
||
private Process? _process;
|
||
private JsonRpc? _rpc; // 使用 StreamJsonRpc 库
|
||
private readonly string _name;
|
||
private readonly ScopedLspServerConfig _config;
|
||
private readonly ILogger _logger;
|
||
private readonly Dictionary<string, Func<object, object?>> _requestHandlers = new();
|
||
|
||
public LspServerState State { get; private set; } = LspServerState.Stopped;
|
||
|
||
public async Task StartAsync()
|
||
{
|
||
if (State == LspServerState.Running) return;
|
||
State = LspServerState.Starting;
|
||
|
||
var psi = new ProcessStartInfo
|
||
{
|
||
FileName = _config.Command,
|
||
UseShellExecute = false,
|
||
RedirectStandardInput = true,
|
||
RedirectStandardOutput = true,
|
||
RedirectStandardError = true,
|
||
CreateNoWindow = true,
|
||
};
|
||
|
||
foreach (var arg in _config.Args)
|
||
psi.ArgumentList.Add(arg);
|
||
if (_config.Env != null)
|
||
foreach (var (k, v) in _config.Env)
|
||
psi.Environment[k] = v;
|
||
|
||
_process = Process.Start(psi)
|
||
?? throw new InvalidOperationException($"Failed to start LSP server {_name}");
|
||
|
||
// 使用 StreamJsonRpc 建立 LSP 连接
|
||
_rpc = JsonRpc.Attach(_process.StandardInput.BaseStream, _process.StandardOutput.BaseStream);
|
||
_rpc.Disconnected += (_, _) => State = LspServerState.Stopped;
|
||
|
||
// 发送 initialize 请求
|
||
var initResult = await _rpc.InvokeAsync<LspInitializeResult>("initialize", new
|
||
{
|
||
processId = Environment.ProcessId,
|
||
rootUri = PathToFileUri(Directory.GetCurrentDirectory()),
|
||
capabilities = new
|
||
{
|
||
textDocument = new
|
||
{
|
||
definition = new { dynamicRegistration = false },
|
||
references = new { dynamicRegistration = false },
|
||
hover = new { dynamicRegistration = false, contentFormat = new[] { "markdown", "plaintext" } },
|
||
publishDiagnostics = new { relatedInformation = true },
|
||
rename = new { prepareSupport = true }
|
||
}
|
||
}
|
||
});
|
||
|
||
// 发送 initialized 通知
|
||
await _rpc.NotifyAsync("initialized", new { });
|
||
State = LspServerState.Running;
|
||
}
|
||
|
||
public Task<T> SendRequestAsync<T>(string method, object? parameters)
|
||
=> _rpc!.InvokeAsync<T>(method, parameters);
|
||
|
||
public Task SendNotificationAsync(string method, object? parameters)
|
||
=> _rpc!.NotifyAsync(method, parameters);
|
||
|
||
public async ValueTask DisposeAsync()
|
||
{
|
||
if (_rpc != null)
|
||
{
|
||
try { await _rpc.NotifyAsync("shutdown", null); } catch { }
|
||
try { await _rpc.NotifyAsync("exit", null); } catch { }
|
||
}
|
||
if (_process?.HasExited == false)
|
||
{
|
||
_process.WaitForExit(5000);
|
||
if (!_process.HasExited) _process.Kill(entireProcessTree: true);
|
||
}
|
||
State = LspServerState.Stopped;
|
||
}
|
||
}
|
||
```
|
||
|
||
## 11.3 LspClientManager 实现
|
||
|
||
```csharp
|
||
public sealed class LspClientManager : ILspClientManager
|
||
{
|
||
private readonly ConcurrentDictionary<string, ILspServerInstance> _servers = new();
|
||
private readonly Dictionary<string, List<string>> _extensionMap = new(); // ext → [serverNames]
|
||
private readonly HashSet<string> _openedFiles = new(); // URI 跟踪
|
||
private readonly ILogger<LspClientManager> _logger;
|
||
|
||
public bool IsConnected => _servers.Values.Any(s => s.State == LspServerState.Running);
|
||
|
||
/// <summary>
|
||
/// 初始化: 加载 LSP 配置,构建扩展映射,不启动服务器(懒启动)
|
||
/// 对应原始 initializeLspServerManager()
|
||
/// </summary>
|
||
public async Task InitializeAsync(CancellationToken ct = default)
|
||
{
|
||
var configs = await LoadAllLspServerConfigsAsync();
|
||
|
||
foreach (var (name, config) in configs)
|
||
{
|
||
if (string.IsNullOrEmpty(config.Command))
|
||
{
|
||
_logger.LogWarning("LSP server {Name} missing command, skipping", name);
|
||
continue;
|
||
}
|
||
|
||
// 构建扩展 → 服务器映射
|
||
foreach (var ext in config.ExtensionToLanguage.Keys)
|
||
{
|
||
var normalized = ext.ToLowerInvariant();
|
||
if (!_extensionMap.ContainsKey(normalized))
|
||
_extensionMap[normalized] = new();
|
||
_extensionMap[normalized].Add(name);
|
||
}
|
||
|
||
_servers[name] = new LspServerInstance(name, config, _logger);
|
||
}
|
||
}
|
||
|
||
public ILspServerInstance? GetServerForFile(string filePath)
|
||
{
|
||
var ext = Path.GetExtension(filePath).ToLowerInvariant();
|
||
var serverNames = _extensionMap.GetValueOrDefault(ext);
|
||
return serverNames?.Count > 0 ? _servers.GetValueOrDefault(serverNames[0]) : null;
|
||
}
|
||
|
||
public async Task<ILspServerInstance?> EnsureServerStartedAsync(string filePath)
|
||
{
|
||
var server = GetServerForFile(filePath);
|
||
if (server == null) return null;
|
||
|
||
if (server.State is LspServerState.Stopped or LspServerState.Error)
|
||
await server.StartAsync();
|
||
|
||
return server;
|
||
}
|
||
|
||
public async Task<T?> SendRequestAsync<T>(string filePath, string method, object? parameters)
|
||
{
|
||
var server = await EnsureServerStartedAsync(filePath);
|
||
if (server == null) return default;
|
||
return await server.SendRequestAsync<T>(method, parameters);
|
||
}
|
||
|
||
// === LSP 操作实现 ===
|
||
|
||
public async Task<Location?> GoToDefinitionAsync(string filePath, int line, int character)
|
||
=> await SendRequestAsync<Location?>(filePath, "textDocument/definition",
|
||
new { textDocument = new { uri = PathToFileUri(filePath) },
|
||
position = new { line = line - 1, character } });
|
||
|
||
public async Task<Location[]> FindReferencesAsync(string filePath, int line, int character)
|
||
=> await SendRequestAsync<Location[]>(filePath, "textDocument/references",
|
||
new { textDocument = new { uri = PathToFileUri(filePath) },
|
||
position = new { line = line - 1, character },
|
||
context = new { includeDeclaration = true } }) ?? [];
|
||
|
||
public async Task<Hover?> HoverAsync(string filePath, int line, int character)
|
||
=> await SendRequestAsync<Hover?>(filePath, "textDocument/hover",
|
||
new { textDocument = new { uri = PathToFileUri(filePath) },
|
||
position = new { line = line - 1, character } });
|
||
|
||
public async Task<Symbol[]> DocumentSymbolsAsync(string filePath)
|
||
=> await SendRequestAsync<Symbol[]>(filePath, "textDocument/documentSymbol",
|
||
new { textDocument = new { uri = PathToFileUri(filePath) } }) ?? [];
|
||
|
||
public async Task<Symbol[]> WorkspaceSymbolsAsync(string query)
|
||
{
|
||
// 遍历所有已启动的服务器
|
||
var results = new List<Symbol>();
|
||
foreach (var server in _servers.Values.Where(s => s.State == LspServerState.Running))
|
||
{
|
||
try
|
||
{
|
||
var symbols = await server.SendRequestAsync<Symbol[]>(
|
||
"workspace/symbol", new { query });
|
||
if (symbols != null) results.AddRange(symbols);
|
||
}
|
||
catch { /* 继续尝试其他服务器 */ }
|
||
}
|
||
return results.ToArray();
|
||
}
|
||
|
||
// === 文件同步 ===
|
||
|
||
public async Task OpenFileAsync(string filePath, string content)
|
||
{
|
||
var server = await EnsureServerStartedAsync(filePath);
|
||
if (server == null) return;
|
||
|
||
var uri = PathToFileUri(filePath);
|
||
if (_openedFiles.Contains(uri)) return; // 已打开
|
||
|
||
var ext = Path.GetExtension(filePath).ToLowerInvariant();
|
||
var languageId = server.ExtensionToLanguage.GetValueOrDefault(ext) ?? "plaintext";
|
||
|
||
await server.SendNotificationAsync("textDocument/didOpen", new
|
||
{
|
||
textDocument = new { uri, languageId, version = 1, text = content }
|
||
});
|
||
_openedFiles.Add(uri);
|
||
}
|
||
|
||
public async Task ChangeFileAsync(string filePath, string content)
|
||
{
|
||
var server = GetServerForFile(filePath);
|
||
if (server?.State != LspServerState.Running)
|
||
return; // 先 OpenFile
|
||
|
||
var uri = PathToFileUri(filePath);
|
||
if (!_openedFiles.Contains(uri))
|
||
return; // 先 OpenFile
|
||
|
||
await server.SendNotificationAsync("textDocument/didChange", new
|
||
{
|
||
textDocument = new { uri, version = 1 },
|
||
contentChanges = new[] { new { text = content } }
|
||
});
|
||
}
|
||
|
||
public async Task ShutdownAsync()
|
||
{
|
||
var tasks = _servers.Values
|
||
.Where(s => s.State is LspServerState.Running or LspServerState.Error)
|
||
.Select(s => s.DisposeAsync().AsTask());
|
||
await Task.WhenAll(tasks);
|
||
_servers.Clear();
|
||
_extensionMap.Clear();
|
||
_openedFiles.Clear();
|
||
}
|
||
|
||
private static string PathToFileUri(string path) =>
|
||
new Uri(Path.GetFullPath(path)).AbsoluteUri;
|
||
}
|
||
```
|
||
|
||
## 11.4 LspDiagnosticRegistry 基线比较
|
||
|
||
```csharp
|
||
/// <summary>
|
||
/// LSP 诊断收集与基线对比
|
||
/// 对应原始 LSPDiagnosticRegistry.ts
|
||
/// </summary>
|
||
public sealed class LspDiagnosticRegistry
|
||
{
|
||
private readonly Dictionary<string, List<Diagnostic>> _baseline = new();
|
||
private readonly Dictionary<string, List<Diagnostic>> _current = new();
|
||
|
||
/// <summary>保存当前诊断为基线(编辑前快照)</summary>
|
||
public void SaveBaseline(string filePath)
|
||
{
|
||
if (_current.TryGetValue(filePath, out var diagnostics))
|
||
_baseline[filePath] = new(diagnostics);
|
||
}
|
||
|
||
/// <summary>更新诊断(来自 textDocument/publishDiagnostics 通知)</summary>
|
||
public void UpdateDiagnostics(string filePath, IReadOnlyList<Diagnostic> diagnostics)
|
||
{
|
||
_current[filePath] = diagnostics.ToList();
|
||
}
|
||
|
||
/// <summary>获取新增的诊断(相对于基线)</summary>
|
||
public IReadOnlyList<Diagnostic> GetNewDiagnostics(string filePath)
|
||
{
|
||
var baseline = _baseline.GetValueOrDefault(filePath) ?? [];
|
||
var current = _current.GetValueOrDefault(filePath) ?? [];
|
||
var baselineSet = new HashSet<Diagnostic>(baseline);
|
||
return current.Where(d => !baselineSet.Contains(d)).ToList();
|
||
}
|
||
|
||
/// <summary>清除文件基线</summary>
|
||
public void ClearBaseline(string filePath) => _baseline.Remove(filePath);
|
||
}
|
||
```
|