using System.Diagnostics; using System.IO; using System.Text; using System.Text.Json; namespace AxCopilot.Services; /// /// Language Server Protocol 클라이언트. /// 외부 언어 서버 프로세스와 JSON-RPC 2.0으로 통신합니다. /// 지원: OmniSharp (C#), typescript-language-server, pyright, clangd /// public class LspClientService : IDisposable { private Process? _process; private StreamWriter? _writer; private StreamReader? _reader; private int _requestId; private bool _initialized; private readonly string _language; public bool IsConnected => _process is { HasExited: false } && _initialized; public LspClientService(string language) { _language = language.ToLowerInvariant(); } /// 언어에 맞는 LSP 서버를 시작하고 초기화합니다. public async Task StartAsync(string workspacePath, CancellationToken ct = default) { var (command, args) = GetServerCommand(); if (command == null) { LogService.Warn($"LSP: {_language}에 대한 서버를 찾을 수 없습니다."); return false; } try { var psi = new ProcessStartInfo { FileName = command, UseShellExecute = false, RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true, StandardOutputEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8, }; foreach (var arg in args) psi.ArgumentList.Add(arg); _process = Process.Start(psi); if (_process == null) return false; _writer = _process.StandardInput; _reader = _process.StandardOutput; // LSP initialize var initResult = await SendRequestAsync("initialize", new { processId = Environment.ProcessId, rootUri = $"file:///{workspacePath.Replace('\\', '/').TrimStart('/')}", capabilities = new { textDocument = new { definition = new { dynamicRegistration = false }, references = new { dynamicRegistration = false }, documentSymbol = new { dynamicRegistration = false }, } } }, ct); if (initResult == null) return false; // initialized notification await SendNotificationAsync("initialized", ct); _initialized = true; LogService.Info($"LSP [{_language}]: 서버 시작 완료 ({command})"); return true; } catch (Exception ex) { LogService.Warn($"LSP [{_language}] 시작 실패: {ex.Message}"); return false; } } /// 심볼 정의 위치를 찾습니다. public async Task GotoDefinitionAsync(string filePath, int line, int character, CancellationToken ct = default) { var result = await SendRequestAsync("textDocument/definition", new { textDocument = new { uri = FileToUri(filePath) }, position = new { line, character } }, ct); return ParseLocation(result); } /// 심볼의 모든 참조 위치를 찾습니다. public async Task> FindReferencesAsync(string filePath, int line, int character, CancellationToken ct = default) { var result = await SendRequestAsync("textDocument/references", new { textDocument = new { uri = FileToUri(filePath) }, position = new { line, character }, context = new { includeDeclaration = true } }, ct); return ParseLocations(result); } /// 파일의 심볼 목록을 가져옵니다 (클래스, 메서드, 필드 등). public async Task> GetDocumentSymbolsAsync(string filePath, CancellationToken ct = default) { var result = await SendRequestAsync("textDocument/documentSymbol", new { textDocument = new { uri = FileToUri(filePath) } }, ct); return ParseSymbols(result); } // ─── JSON-RPC 통신 ────────────────────────────────────────────────────── private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, }; private async Task SendRequestAsync(string method, object parameters, CancellationToken ct) { if (_writer == null || _reader == null) return null; var id = Interlocked.Increment(ref _requestId); var request = new { jsonrpc = "2.0", id, method, @params = parameters }; var json = JsonSerializer.Serialize(request, JsonOpts); var content = Encoding.UTF8.GetBytes(json); // LSP 프로토콜: Content-Length 헤더 + \r\n\r\n + 본문 var header = $"Content-Length: {content.Length}\r\n\r\n"; await _writer.BaseStream.WriteAsync(Encoding.ASCII.GetBytes(header), ct); await _writer.BaseStream.WriteAsync(content, ct); await _writer.BaseStream.FlushAsync(ct); // 응답 읽기 using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(TimeSpan.FromSeconds(15)); while (!cts.Token.IsCancellationRequested) { var headerLine = await ReadHeaderAsync(cts.Token); if (headerLine == null) continue; var bodyLength = ParseContentLength(headerLine); if (bodyLength <= 0) continue; var body = new byte[bodyLength]; int read = 0; while (read < bodyLength) { var n = await _reader.BaseStream.ReadAsync(body.AsMemory(read, bodyLength - read), cts.Token); if (n == 0) break; read += n; } var responseJson = Encoding.UTF8.GetString(body); try { var doc = JsonDocument.Parse(responseJson); var root = doc.RootElement; if (!root.TryGetProperty("id", out _)) continue; // notification skip if (root.TryGetProperty("result", out var result)) return result; if (root.TryGetProperty("error", out var error)) { var msg = error.TryGetProperty("message", out var m) ? m.GetString() : "Unknown"; LogService.Warn($"LSP RPC 오류: {msg}"); return null; } } catch { continue; } } return null; } private async Task SendNotificationAsync(string method, CancellationToken ct) { if (_writer == null) return; var notification = new { jsonrpc = "2.0", method, @params = new { } }; var json = JsonSerializer.Serialize(notification, JsonOpts); var content = Encoding.UTF8.GetBytes(json); var header = $"Content-Length: {content.Length}\r\n\r\n"; await _writer.BaseStream.WriteAsync(Encoding.ASCII.GetBytes(header), ct); await _writer.BaseStream.WriteAsync(content, ct); await _writer.BaseStream.FlushAsync(ct); } private async Task ReadHeaderAsync(CancellationToken ct) { var sb = new StringBuilder(); var buf = new byte[1]; var prevCr = false; int emptyLines = 0; while (!ct.IsCancellationRequested) { var n = await _reader!.BaseStream.ReadAsync(buf, ct); if (n == 0) return null; var ch = (char)buf[0]; if (ch == '\r') { prevCr = true; continue; } if (ch == '\n') { if (prevCr && sb.Length > 0) return sb.ToString(); if (prevCr) { emptyLines++; if (emptyLines >= 1 && sb.Length > 0) return sb.ToString(); } prevCr = false; continue; } prevCr = false; sb.Append(ch); } return null; } private static int ParseContentLength(string header) { const string prefix = "Content-Length:"; if (header.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) if (int.TryParse(header[prefix.Length..].Trim(), out var len)) return len; return 0; } // ─── 결과 파싱 ────────────────────────────────────────────────────────── private static LspLocation? ParseLocation(JsonElement? result) { if (result == null) return null; try { // 단일 Location 또는 배열의 첫 번째 var elem = result.Value; if (elem.ValueKind == JsonValueKind.Array && elem.GetArrayLength() > 0) elem = elem[0]; if (elem.TryGetProperty("uri", out var uri) && elem.TryGetProperty("range", out var range)) { var start = range.GetProperty("start"); return new LspLocation { FilePath = UriToFile(uri.GetString() ?? ""), Line = start.GetProperty("line").GetInt32(), Character = start.GetProperty("character").GetInt32(), }; } } catch { } return null; } private static List ParseLocations(JsonElement? result) { var list = new List(); if (result?.ValueKind != JsonValueKind.Array) return list; foreach (var elem in result.Value.EnumerateArray()) { if (elem.TryGetProperty("uri", out var uri) && elem.TryGetProperty("range", out var range)) { var start = range.GetProperty("start"); list.Add(new LspLocation { FilePath = UriToFile(uri.GetString() ?? ""), Line = start.GetProperty("line").GetInt32(), Character = start.GetProperty("character").GetInt32(), }); } } return list; } private static List ParseSymbols(JsonElement? result) { var list = new List(); if (result?.ValueKind != JsonValueKind.Array) return list; foreach (var elem in result.Value.EnumerateArray()) { var name = elem.TryGetProperty("name", out var n) ? n.GetString() ?? "" : ""; var kind = elem.TryGetProperty("kind", out var k) ? SymbolKindName(k.GetInt32()) : "unknown"; var line = 0; if (elem.TryGetProperty("range", out var range)) line = range.GetProperty("start").GetProperty("line").GetInt32(); else if (elem.TryGetProperty("location", out var loc)) line = loc.GetProperty("range").GetProperty("start").GetProperty("line").GetInt32(); list.Add(new LspSymbol { Name = name, Kind = kind, Line = line }); // 하위 심볼 (children) if (elem.TryGetProperty("children", out var children)) { var childList = ParseSymbols(children); foreach (var child in childList) list.Add(new LspSymbol { Name = $" {child.Name}", Kind = child.Kind, Line = child.Line }); } } return list; } private static string SymbolKindName(int kind) => kind switch { 1 => "file", 2 => "module", 3 => "namespace", 4 => "package", 5 => "class", 6 => "method", 7 => "property", 8 => "field", 9 => "constructor", 10 => "enum", 11 => "interface", 12 => "function", 13 => "variable", 14 => "constant", 23 => "struct", 24 => "event", _ => "symbol" }; // ─── 유틸 ──────────────────────────────────────────────────────────────── private static string FileToUri(string path) => $"file:///{path.Replace('\\', '/').TrimStart('/')}"; private static string UriToFile(string uri) { if (uri.StartsWith("file:///")) return uri[8..].Replace('/', '\\'); return uri; } private (string? Command, string[] Args) GetServerCommand() => _language switch { "csharp" or "c#" => FindCommand("OmniSharp", new[] { "omnisharp", "OmniSharp.exe" }, new[] { "--languageserver" }), "typescript" or "javascript" or "ts" or "js" => FindCommand("TypeScript", new[] { "typescript-language-server" }, new[] { "--stdio" }), "python" or "py" => FindCommand("Python", new[] { "pyright-langserver", "pylsp" }, new[] { "--stdio" }), "cpp" or "c++" or "c" => FindCommand("C/C++", new[] { "clangd" }, Array.Empty()), "java" => FindCommand("Java", new[] { "jdtls" }, Array.Empty()), _ => (null, Array.Empty()) }; private static (string? Command, string[] Args) FindCommand(string label, string[] candidates, string[] defaultArgs) { foreach (var cmd in candidates) { try { using var which = Process.Start(new ProcessStartInfo { FileName = Environment.OSVersion.Platform == PlatformID.Win32NT ? "where" : "which", Arguments = cmd, UseShellExecute = false, RedirectStandardOutput = true, CreateNoWindow = true, }); which?.WaitForExit(3000); if (which?.ExitCode == 0) { LogService.Info($"LSP [{label}]: {cmd} 발견"); return (cmd, defaultArgs); } } catch (Exception ex) { LogService.Debug($"LSP [{label}]: {cmd} 탐색 실패 — {ex.Message}"); } } return (null, Array.Empty()); } public void Dispose() { try { _writer?.Dispose(); _reader?.Dispose(); if (_process is { HasExited: false }) { _process.Kill(entireProcessTree: true); _process.Dispose(); } } catch { } } } /// LSP 위치 정보. public class LspLocation { public string FilePath { get; init; } = ""; public int Line { get; init; } public int Character { get; init; } public override string ToString() => $"{FilePath}:{Line + 1}:{Character + 1}"; } /// LSP 심볼 정보. public class LspSymbol { public string Name { get; init; } = ""; public string Kind { get; init; } = ""; public int Line { get; init; } public override string ToString() => $"[{Kind}] {Name} (line {Line + 1})"; }