540 lines
13 KiB
C#
540 lines
13 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.Text;
|
|
using System.Text.Encodings.Web;
|
|
using System.Text.Json;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
|
|
namespace AxCopilot.Services;
|
|
|
|
public class LspClientService : IDisposable
|
|
{
|
|
private Process? _process;
|
|
|
|
private StreamWriter? _writer;
|
|
|
|
private StreamReader? _reader;
|
|
|
|
private int _requestId;
|
|
|
|
private bool _initialized;
|
|
|
|
private readonly string _language;
|
|
|
|
private static readonly JsonSerializerOptions JsonOpts = new JsonSerializerOptions
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
|
};
|
|
|
|
public bool IsConnected
|
|
{
|
|
get
|
|
{
|
|
Process process = _process;
|
|
return process != null && !process.HasExited && _initialized;
|
|
}
|
|
}
|
|
|
|
public LspClientService(string language)
|
|
{
|
|
_language = language.ToLowerInvariant();
|
|
}
|
|
|
|
public async Task<bool> StartAsync(string workspacePath, CancellationToken ct = default(CancellationToken))
|
|
{
|
|
string command;
|
|
string[] args;
|
|
(command, args) = GetServerCommand();
|
|
if (command == null)
|
|
{
|
|
LogService.Warn("LSP: " + _language + "에 대한 서버를 찾을 수 없습니다.");
|
|
return false;
|
|
}
|
|
try
|
|
{
|
|
ProcessStartInfo psi = new ProcessStartInfo
|
|
{
|
|
FileName = command,
|
|
UseShellExecute = false,
|
|
RedirectStandardInput = true,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
CreateNoWindow = true,
|
|
StandardOutputEncoding = Encoding.UTF8,
|
|
StandardErrorEncoding = Encoding.UTF8
|
|
};
|
|
string[] array = args;
|
|
foreach (string arg in array)
|
|
{
|
|
psi.ArgumentList.Add(arg);
|
|
}
|
|
_process = Process.Start(psi);
|
|
if (_process == null)
|
|
{
|
|
return false;
|
|
}
|
|
_writer = _process.StandardInput;
|
|
_reader = _process.StandardOutput;
|
|
if (!(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)).HasValue)
|
|
{
|
|
return false;
|
|
}
|
|
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<LspLocation?> GotoDefinitionAsync(string filePath, int line, int character, CancellationToken ct = default(CancellationToken))
|
|
{
|
|
return ParseLocation(await SendRequestAsync("textDocument/definition", new
|
|
{
|
|
textDocument = new
|
|
{
|
|
uri = FileToUri(filePath)
|
|
},
|
|
position = new { line, character }
|
|
}, ct));
|
|
}
|
|
|
|
public async Task<List<LspLocation>> FindReferencesAsync(string filePath, int line, int character, CancellationToken ct = default(CancellationToken))
|
|
{
|
|
return ParseLocations(await SendRequestAsync("textDocument/references", new
|
|
{
|
|
textDocument = new
|
|
{
|
|
uri = FileToUri(filePath)
|
|
},
|
|
position = new { line, character },
|
|
context = new
|
|
{
|
|
includeDeclaration = true
|
|
}
|
|
}, ct));
|
|
}
|
|
|
|
public async Task<List<LspSymbol>> GetDocumentSymbolsAsync(string filePath, CancellationToken ct = default(CancellationToken))
|
|
{
|
|
return ParseSymbols(await SendRequestAsync("textDocument/documentSymbol", new
|
|
{
|
|
textDocument = new
|
|
{
|
|
uri = FileToUri(filePath)
|
|
}
|
|
}, ct));
|
|
}
|
|
|
|
private async Task<JsonElement?> SendRequestAsync(string method, object parameters, CancellationToken ct)
|
|
{
|
|
if (_writer == null || _reader == null)
|
|
{
|
|
return null;
|
|
}
|
|
int id = Interlocked.Increment(ref _requestId);
|
|
var request = new
|
|
{
|
|
jsonrpc = "2.0",
|
|
id = id,
|
|
method = method,
|
|
@params = parameters
|
|
};
|
|
string json = JsonSerializer.Serialize(request, JsonOpts);
|
|
byte[] content = Encoding.UTF8.GetBytes(json);
|
|
string 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 CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
cts.CancelAfter(TimeSpan.FromSeconds(15.0));
|
|
while (!cts.Token.IsCancellationRequested)
|
|
{
|
|
string headerLine = await ReadHeaderAsync(cts.Token);
|
|
if (headerLine == null)
|
|
{
|
|
continue;
|
|
}
|
|
int bodyLength = ParseContentLength(headerLine);
|
|
if (bodyLength <= 0)
|
|
{
|
|
continue;
|
|
}
|
|
byte[] body = new byte[bodyLength];
|
|
int n;
|
|
for (int read = 0; read < bodyLength; read += n)
|
|
{
|
|
n = await _reader.BaseStream.ReadAsync(body.AsMemory(read, bodyLength - read), cts.Token);
|
|
if (n == 0)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
string responseJson = Encoding.UTF8.GetString(body);
|
|
try
|
|
{
|
|
JsonDocument doc = JsonDocument.Parse(responseJson);
|
|
JsonElement root = doc.RootElement;
|
|
if (root.TryGetProperty("id", out var _))
|
|
{
|
|
if (root.TryGetProperty("result", out var result))
|
|
{
|
|
return result;
|
|
}
|
|
if (root.TryGetProperty("error", out var error))
|
|
{
|
|
JsonElement m;
|
|
string msg = (error.TryGetProperty("message", out m) ? m.GetString() : "Unknown");
|
|
LogService.Warn("LSP RPC 오류: " + msg);
|
|
return null;
|
|
}
|
|
result = default(JsonElement);
|
|
error = default(JsonElement);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private async Task SendNotificationAsync(string method, CancellationToken ct)
|
|
{
|
|
if (_writer != null)
|
|
{
|
|
var notification = new
|
|
{
|
|
jsonrpc = "2.0",
|
|
method = method,
|
|
@params = new { }
|
|
};
|
|
string json = JsonSerializer.Serialize(notification, JsonOpts);
|
|
byte[] content = Encoding.UTF8.GetBytes(json);
|
|
string 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)
|
|
{
|
|
StringBuilder sb = new StringBuilder();
|
|
byte[] buf = new byte[1];
|
|
bool prevCr = false;
|
|
int emptyLines = 0;
|
|
while (!ct.IsCancellationRequested)
|
|
{
|
|
if (await _reader.BaseStream.ReadAsync(buf, ct) == 0)
|
|
{
|
|
return null;
|
|
}
|
|
char ch = (char)buf[0];
|
|
switch (ch)
|
|
{
|
|
case '\r':
|
|
prevCr = true;
|
|
break;
|
|
case '\n':
|
|
if (prevCr && sb.Length > 0)
|
|
{
|
|
return sb.ToString();
|
|
}
|
|
if (prevCr)
|
|
{
|
|
emptyLines++;
|
|
if (emptyLines >= 1 && sb.Length > 0)
|
|
{
|
|
return sb.ToString();
|
|
}
|
|
}
|
|
prevCr = false;
|
|
break;
|
|
default:
|
|
prevCr = false;
|
|
sb.Append(ch);
|
|
break;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private static int ParseContentLength(string header)
|
|
{
|
|
if (header.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
int length = "Content-Length:".Length;
|
|
if (int.TryParse(header.Substring(length, header.Length - length).Trim(), out var result))
|
|
{
|
|
return result;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
private static LspLocation? ParseLocation(JsonElement? result)
|
|
{
|
|
if (!result.HasValue)
|
|
{
|
|
return null;
|
|
}
|
|
try
|
|
{
|
|
JsonElement jsonElement = result.Value;
|
|
if (jsonElement.ValueKind == JsonValueKind.Array && jsonElement.GetArrayLength() > 0)
|
|
{
|
|
jsonElement = jsonElement[0];
|
|
}
|
|
if (jsonElement.TryGetProperty("uri", out var value) && jsonElement.TryGetProperty("range", out var value2))
|
|
{
|
|
JsonElement property = value2.GetProperty("start");
|
|
return new LspLocation
|
|
{
|
|
FilePath = UriToFile(value.GetString() ?? ""),
|
|
Line = property.GetProperty("line").GetInt32(),
|
|
Character = property.GetProperty("character").GetInt32()
|
|
};
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private static List<LspLocation> ParseLocations(JsonElement? result)
|
|
{
|
|
List<LspLocation> list = new List<LspLocation>();
|
|
if (!result.HasValue || result.GetValueOrDefault().ValueKind != JsonValueKind.Array)
|
|
{
|
|
return list;
|
|
}
|
|
foreach (JsonElement item in result.Value.EnumerateArray())
|
|
{
|
|
if (item.TryGetProperty("uri", out var value) && item.TryGetProperty("range", out var value2))
|
|
{
|
|
JsonElement property = value2.GetProperty("start");
|
|
list.Add(new LspLocation
|
|
{
|
|
FilePath = UriToFile(value.GetString() ?? ""),
|
|
Line = property.GetProperty("line").GetInt32(),
|
|
Character = property.GetProperty("character").GetInt32()
|
|
});
|
|
}
|
|
}
|
|
return list;
|
|
}
|
|
|
|
private static List<LspSymbol> ParseSymbols(JsonElement? result)
|
|
{
|
|
List<LspSymbol> list = new List<LspSymbol>();
|
|
if (!result.HasValue || result.GetValueOrDefault().ValueKind != JsonValueKind.Array)
|
|
{
|
|
return list;
|
|
}
|
|
foreach (JsonElement item in result.Value.EnumerateArray())
|
|
{
|
|
JsonElement value;
|
|
string name = (item.TryGetProperty("name", out value) ? (value.GetString() ?? "") : "");
|
|
JsonElement value2;
|
|
string kind = (item.TryGetProperty("kind", out value2) ? SymbolKindName(value2.GetInt32()) : "unknown");
|
|
int line = 0;
|
|
JsonElement value4;
|
|
if (item.TryGetProperty("range", out var value3))
|
|
{
|
|
line = value3.GetProperty("start").GetProperty("line").GetInt32();
|
|
}
|
|
else if (item.TryGetProperty("location", out value4))
|
|
{
|
|
line = value4.GetProperty("range").GetProperty("start").GetProperty("line")
|
|
.GetInt32();
|
|
}
|
|
list.Add(new LspSymbol
|
|
{
|
|
Name = name,
|
|
Kind = kind,
|
|
Line = line
|
|
});
|
|
if (!item.TryGetProperty("children", out var value5))
|
|
{
|
|
continue;
|
|
}
|
|
List<LspSymbol> list2 = ParseSymbols(value5);
|
|
foreach (LspSymbol item2 in list2)
|
|
{
|
|
list.Add(new LspSymbol
|
|
{
|
|
Name = " " + item2.Name,
|
|
Kind = item2.Kind,
|
|
Line = item2.Line
|
|
});
|
|
}
|
|
}
|
|
return list;
|
|
}
|
|
|
|
private static string SymbolKindName(int kind)
|
|
{
|
|
if (1 == 0)
|
|
{
|
|
}
|
|
string result = 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",
|
|
};
|
|
if (1 == 0)
|
|
{
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private static string FileToUri(string path)
|
|
{
|
|
return "file:///" + path.Replace('\\', '/').TrimStart('/');
|
|
}
|
|
|
|
private static string UriToFile(string uri)
|
|
{
|
|
if (uri.StartsWith("file:///"))
|
|
{
|
|
return uri.Substring(8, uri.Length - 8).Replace('/', '\\');
|
|
}
|
|
return uri;
|
|
}
|
|
|
|
private (string? Command, string[] Args) GetServerCommand()
|
|
{
|
|
string language = _language;
|
|
if (1 == 0)
|
|
{
|
|
}
|
|
(string, string[]) result;
|
|
switch (language)
|
|
{
|
|
case "csharp":
|
|
case "c#":
|
|
result = FindCommand("OmniSharp", new string[2] { "omnisharp", "OmniSharp.exe" }, new string[1] { "--languageserver" });
|
|
break;
|
|
case "typescript":
|
|
case "javascript":
|
|
case "ts":
|
|
case "js":
|
|
result = FindCommand("TypeScript", new string[1] { "typescript-language-server" }, new string[1] { "--stdio" });
|
|
break;
|
|
case "python":
|
|
case "py":
|
|
result = FindCommand("Python", new string[2] { "pyright-langserver", "pylsp" }, new string[1] { "--stdio" });
|
|
break;
|
|
case "cpp":
|
|
case "c++":
|
|
case "c":
|
|
result = FindCommand("C/C++", new string[1] { "clangd" }, Array.Empty<string>());
|
|
break;
|
|
case "java":
|
|
result = FindCommand("Java", new string[1] { "jdtls" }, Array.Empty<string>());
|
|
break;
|
|
default:
|
|
result = (null, Array.Empty<string>());
|
|
break;
|
|
}
|
|
if (1 == 0)
|
|
{
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private static (string? Command, string[] Args) FindCommand(string label, string[] candidates, string[] defaultArgs)
|
|
{
|
|
foreach (string text in candidates)
|
|
{
|
|
try
|
|
{
|
|
using Process process = Process.Start(new ProcessStartInfo
|
|
{
|
|
FileName = ((Environment.OSVersion.Platform == PlatformID.Win32NT) ? "where" : "which"),
|
|
Arguments = text,
|
|
UseShellExecute = false,
|
|
RedirectStandardOutput = true,
|
|
CreateNoWindow = true
|
|
});
|
|
process?.WaitForExit(3000);
|
|
if (process != null && process.ExitCode == 0)
|
|
{
|
|
LogService.Info($"LSP [{label}]: {text} 발견");
|
|
return (Command: text, Args: defaultArgs);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
LogService.Debug($"LSP [{label}]: {text} 탐색 실패 — {ex.Message}");
|
|
}
|
|
}
|
|
return (Command: null, Args: Array.Empty<string>());
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
try
|
|
{
|
|
_writer?.Dispose();
|
|
_reader?.Dispose();
|
|
Process process = _process;
|
|
if (process != null && !process.HasExited)
|
|
{
|
|
_process.Kill(entireProcessTree: true);
|
|
_process.Dispose();
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
}
|
|
}
|