Files
AX-Copilot-Codex/src/AxCopilot/Services/LspClientService.cs

411 lines
15 KiB
C#

using System.Diagnostics;
using System.IO;
using System.Text;
using System.Text.Json;
namespace AxCopilot.Services;
/// <summary>
/// Language Server Protocol 클라이언트.
/// 외부 언어 서버 프로세스와 JSON-RPC 2.0으로 통신합니다.
/// 지원: OmniSharp (C#), typescript-language-server, pyright, clangd
/// </summary>
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();
}
/// <summary>언어에 맞는 LSP 서버를 시작하고 초기화합니다.</summary>
public async Task<bool> 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;
}
}
/// <summary>심볼 정의 위치를 찾습니다.</summary>
public async Task<LspLocation?> 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);
}
/// <summary>심볼의 모든 참조 위치를 찾습니다.</summary>
public async Task<List<LspLocation>> 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);
}
/// <summary>파일의 심볼 목록을 가져옵니다 (클래스, 메서드, 필드 등).</summary>
public async Task<List<LspSymbol>> 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<JsonElement?> 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<string?> 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<LspLocation> ParseLocations(JsonElement? result)
{
var list = new List<LspLocation>();
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<LspSymbol> ParseSymbols(JsonElement? result)
{
var list = new List<LspSymbol>();
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<string>()),
"java" =>
FindCommand("Java", new[] { "jdtls" }, Array.Empty<string>()),
_ => (null, Array.Empty<string>())
};
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<string>());
}
public void Dispose()
{
try
{
_writer?.Dispose();
_reader?.Dispose();
if (_process is { HasExited: false })
{
_process.Kill(entireProcessTree: true);
_process.Dispose();
}
}
catch { }
}
}
/// <summary>LSP 위치 정보.</summary>
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}";
}
/// <summary>LSP 심볼 정보.</summary>
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})";
}