??: - ?? ?? ???? ?? ?? ??, ?? ? ?? ?? ??, ?????? ???? ??? ? ?? ?????. - ?? ?? ??? ??? ?? PPT? ?? ??? ?? ???? ?? ????? ????. ?? ????: - AgentCommandQueue? steering, permission continuation, resume, user decision ? ??? ???? AgentLoopService?? ?? ???? ????? ?? - CodeLanguageCatalog? LspClientService? ??? Go, Rust, PHP, Ruby, Kotlin, Swift? ?? LSP ?? ???? ?? - SettingsWindow? SettingsViewModel?? ?? ? ?? ??? ?? ?? / LSP / ?? ???? ????? ?? - WorkspaceContextGenerator? Language Snapshot, Agent Context, Key Manifests ??? ???? .claude/skills, .ax/rules, AXMEMORY.md ??? ?? - DeckRepairGuideService? ???? PptxSkill ??? Deck repair guide? ?? ?? - ?? ?? ???? ?? ???? ?? ? ?? ??: - dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_master_batch\\ -p:IntermediateOutputPath=obj\\verify_master_batch\\ - dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter AgentCommandQueueTests,CodeLanguageCatalogTests,WorkspaceContextGeneratorTests,PptxSkillConsultingDeckTests,DeckRepairGuideServiceTests -p:OutputPath=bin\\verify_master_batch_tests\\ -p:IntermediateOutputPath=obj\\verify_master_batch_tests\\
749 lines
27 KiB
C#
749 lines
27 KiB
C#
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using AxCopilot.Services.Agent;
|
|
|
|
namespace AxCopilot.Services;
|
|
|
|
/// <summary>
|
|
/// Language Server Protocol 클라이언트.
|
|
/// 외부 언어 서버 프로세스와 JSON-RPC 2.0으로 통신합니다.
|
|
/// 지원: OmniSharp (C#), typescript-language-server, pyright/pylsp, clangd, jdtls,
|
|
/// gopls, rust-analyzer, intelephense, solargraph, kotlin-language-server, sourcekit-lsp
|
|
/// </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 },
|
|
implementation = new { dynamicRegistration = false },
|
|
references = new { dynamicRegistration = false },
|
|
documentSymbol = new { dynamicRegistration = false },
|
|
hover = new { dynamicRegistration = false },
|
|
callHierarchy = new { dynamicRegistration = false },
|
|
},
|
|
workspace = new
|
|
{
|
|
symbol = 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);
|
|
}
|
|
|
|
/// <summary>심볼의 hover 정보를 가져옵니다.</summary>
|
|
public async Task<string?> HoverAsync(string filePath, int line, int character, CancellationToken ct = default)
|
|
{
|
|
var result = await SendRequestAsync("textDocument/hover", new
|
|
{
|
|
textDocument = new { uri = FileToUri(filePath) },
|
|
position = new { line, character }
|
|
}, ct);
|
|
|
|
return ParseHover(result);
|
|
}
|
|
|
|
/// <summary>인터페이스/추상 메서드 등의 구현 위치를 찾습니다.</summary>
|
|
public async Task<List<LspLocation>> GotoImplementationAsync(string filePath, int line, int character, CancellationToken ct = default)
|
|
{
|
|
var result = await SendRequestAsync("textDocument/implementation", new
|
|
{
|
|
textDocument = new { uri = FileToUri(filePath) },
|
|
position = new { line, character }
|
|
}, ct);
|
|
|
|
return ParseLocations(result);
|
|
}
|
|
|
|
/// <summary>워크스페이스 전체 심볼을 검색합니다.</summary>
|
|
public async Task<List<LspWorkspaceSymbol>> SearchWorkspaceSymbolsAsync(string query, CancellationToken ct = default)
|
|
{
|
|
var result = await SendRequestAsync("workspace/symbol", new { query }, ct);
|
|
return ParseWorkspaceSymbols(result);
|
|
}
|
|
|
|
/// <summary>호출 계층의 기준 아이템을 준비합니다.</summary>
|
|
public async Task<List<LspCallHierarchyItem>> PrepareCallHierarchyAsync(string filePath, int line, int character, CancellationToken ct = default)
|
|
{
|
|
var result = await SendRequestAsync("textDocument/prepareCallHierarchy", new
|
|
{
|
|
textDocument = new { uri = FileToUri(filePath) },
|
|
position = new { line, character }
|
|
}, ct);
|
|
|
|
return ParseCallHierarchyItems(result);
|
|
}
|
|
|
|
/// <summary>해당 심볼을 호출하는 상위 호출자를 찾습니다.</summary>
|
|
public async Task<List<LspCallHierarchyEntry>> GetIncomingCallsAsync(string filePath, int line, int character, CancellationToken ct = default)
|
|
{
|
|
var items = await PrepareCallHierarchyAsync(filePath, line, character, ct);
|
|
if (items.Count == 0)
|
|
return new List<LspCallHierarchyEntry>();
|
|
|
|
var result = await SendRequestAsync("callHierarchy/incomingCalls", new
|
|
{
|
|
item = items[0].RawItem
|
|
}, ct);
|
|
|
|
return ParseIncomingCalls(result);
|
|
}
|
|
|
|
/// <summary>해당 심볼이 호출하는 하위 호출 대상을 찾습니다.</summary>
|
|
public async Task<List<LspCallHierarchyEntry>> GetOutgoingCallsAsync(string filePath, int line, int character, CancellationToken ct = default)
|
|
{
|
|
var items = await PrepareCallHierarchyAsync(filePath, line, character, ct);
|
|
if (items.Count == 0)
|
|
return new List<LspCallHierarchyEntry>();
|
|
|
|
var result = await SendRequestAsync("callHierarchy/outgoingCalls", new
|
|
{
|
|
item = items[0].RawItem
|
|
}, ct);
|
|
|
|
return ParseOutgoingCalls(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];
|
|
return ParseLocationElement(elem);
|
|
}
|
|
catch { }
|
|
return null;
|
|
}
|
|
|
|
private static List<LspLocation> ParseLocations(JsonElement? result)
|
|
{
|
|
var list = new List<LspLocation>();
|
|
if (result == null)
|
|
return list;
|
|
|
|
if (result.Value.ValueKind == JsonValueKind.Array)
|
|
{
|
|
foreach (var elem in result.Value.EnumerateArray())
|
|
{
|
|
var parsed = ParseLocationElement(elem);
|
|
if (parsed != null)
|
|
list.Add(parsed);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
var parsed = ParseLocationElement(result.Value);
|
|
if (parsed != null)
|
|
list.Add(parsed);
|
|
}
|
|
return list;
|
|
}
|
|
|
|
private static LspLocation? ParseLocationElement(JsonElement elem)
|
|
{
|
|
try
|
|
{
|
|
if (elem.TryGetProperty("targetUri", out var targetUri) && elem.TryGetProperty("targetSelectionRange", out var targetSelectionRange))
|
|
{
|
|
var start = targetSelectionRange.GetProperty("start");
|
|
return new LspLocation
|
|
{
|
|
FilePath = UriToFile(targetUri.SafeGetString() ?? ""),
|
|
Line = start.GetProperty("line").GetInt32(),
|
|
Character = start.GetProperty("character").GetInt32(),
|
|
};
|
|
}
|
|
|
|
if (elem.TryGetProperty("uri", out var uri) && elem.TryGetProperty("range", out var range))
|
|
{
|
|
var start = range.GetProperty("start");
|
|
return new LspLocation
|
|
{
|
|
FilePath = UriToFile(uri.SafeGetString() ?? ""),
|
|
Line = start.GetProperty("line").GetInt32(),
|
|
Character = start.GetProperty("character").GetInt32(),
|
|
};
|
|
}
|
|
}
|
|
catch { }
|
|
|
|
return null;
|
|
}
|
|
|
|
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? ParseHover(JsonElement? result)
|
|
{
|
|
if (result == null || result.Value.ValueKind != JsonValueKind.Object)
|
|
return null;
|
|
|
|
if (!result.Value.TryGetProperty("contents", out var contents))
|
|
return null;
|
|
|
|
try
|
|
{
|
|
return contents.ValueKind switch
|
|
{
|
|
JsonValueKind.String => contents.SafeGetString(),
|
|
JsonValueKind.Object => contents.TryGetProperty("value", out var value)
|
|
? value.SafeGetString()
|
|
: contents.GetRawText(),
|
|
JsonValueKind.Array => string.Join(
|
|
"\n\n",
|
|
contents.EnumerateArray()
|
|
.Select(item => item.ValueKind == JsonValueKind.Object && item.TryGetProperty("value", out var v)
|
|
? v.SafeGetString()
|
|
: item.SafeGetString())
|
|
.Where(text => !string.IsNullOrWhiteSpace(text))),
|
|
_ => contents.SafeGetString()
|
|
};
|
|
}
|
|
catch
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static List<LspWorkspaceSymbol> ParseWorkspaceSymbols(JsonElement? result)
|
|
{
|
|
var list = new List<LspWorkspaceSymbol>();
|
|
if (result?.ValueKind != JsonValueKind.Array)
|
|
return list;
|
|
|
|
foreach (var elem in result.Value.EnumerateArray())
|
|
{
|
|
try
|
|
{
|
|
var name = elem.TryGetProperty("name", out var nameEl) ? nameEl.SafeGetString() ?? "" : "";
|
|
var kind = elem.TryGetProperty("kind", out var kindEl) ? SymbolKindName(kindEl.GetInt32()) : "symbol";
|
|
var location = elem.TryGetProperty("location", out var locationEl)
|
|
? ParseLocationElement(locationEl)
|
|
: null;
|
|
|
|
list.Add(new LspWorkspaceSymbol
|
|
{
|
|
Name = name,
|
|
Kind = kind,
|
|
Location = location,
|
|
});
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
return list;
|
|
}
|
|
|
|
private static List<LspCallHierarchyItem> ParseCallHierarchyItems(JsonElement? result)
|
|
{
|
|
var list = new List<LspCallHierarchyItem>();
|
|
if (result == null)
|
|
return list;
|
|
|
|
if (result.Value.ValueKind == JsonValueKind.Array)
|
|
{
|
|
foreach (var elem in result.Value.EnumerateArray())
|
|
{
|
|
var item = ParseCallHierarchyItem(elem);
|
|
if (item != null)
|
|
list.Add(item);
|
|
}
|
|
}
|
|
else if (result.Value.ValueKind == JsonValueKind.Object)
|
|
{
|
|
var item = ParseCallHierarchyItem(result.Value);
|
|
if (item != null)
|
|
list.Add(item);
|
|
}
|
|
|
|
return list;
|
|
}
|
|
|
|
private static LspCallHierarchyItem? ParseCallHierarchyItem(JsonElement elem)
|
|
{
|
|
try
|
|
{
|
|
var uri = elem.TryGetProperty("uri", out var uriEl) ? uriEl.SafeGetString() ?? "" : "";
|
|
var name = elem.TryGetProperty("name", out var nameEl) ? nameEl.SafeGetString() ?? "" : "";
|
|
var kind = elem.TryGetProperty("kind", out var kindEl) ? SymbolKindName(kindEl.GetInt32()) : "symbol";
|
|
var selectionRange = elem.TryGetProperty("selectionRange", out var selectionRangeEl)
|
|
? selectionRangeEl
|
|
: elem.GetProperty("range");
|
|
var start = selectionRange.GetProperty("start");
|
|
|
|
return new LspCallHierarchyItem
|
|
{
|
|
Name = name,
|
|
Kind = kind,
|
|
Location = new LspLocation
|
|
{
|
|
FilePath = UriToFile(uri),
|
|
Line = start.GetProperty("line").GetInt32(),
|
|
Character = start.GetProperty("character").GetInt32(),
|
|
},
|
|
RawItem = elem.Clone(),
|
|
};
|
|
}
|
|
catch
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static List<LspCallHierarchyEntry> ParseIncomingCalls(JsonElement? result)
|
|
{
|
|
var list = new List<LspCallHierarchyEntry>();
|
|
if (result?.ValueKind != JsonValueKind.Array)
|
|
return list;
|
|
|
|
foreach (var elem in result.Value.EnumerateArray())
|
|
{
|
|
try
|
|
{
|
|
if (!elem.TryGetProperty("from", out var fromEl))
|
|
continue;
|
|
|
|
var item = ParseCallHierarchyItem(fromEl);
|
|
if (item == null)
|
|
continue;
|
|
|
|
var count = elem.TryGetProperty("fromRanges", out var ranges) && ranges.ValueKind == JsonValueKind.Array
|
|
? ranges.GetArrayLength()
|
|
: 0;
|
|
|
|
list.Add(new LspCallHierarchyEntry
|
|
{
|
|
Name = item.Name,
|
|
Kind = item.Kind,
|
|
Location = item.Location,
|
|
RangeCount = count,
|
|
});
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
return list;
|
|
}
|
|
|
|
private static List<LspCallHierarchyEntry> ParseOutgoingCalls(JsonElement? result)
|
|
{
|
|
var list = new List<LspCallHierarchyEntry>();
|
|
if (result?.ValueKind != JsonValueKind.Array)
|
|
return list;
|
|
|
|
foreach (var elem in result.Value.EnumerateArray())
|
|
{
|
|
try
|
|
{
|
|
if (!elem.TryGetProperty("to", out var toEl))
|
|
continue;
|
|
|
|
var item = ParseCallHierarchyItem(toEl);
|
|
if (item == null)
|
|
continue;
|
|
|
|
var count = elem.TryGetProperty("fromRanges", out var ranges) && ranges.ValueKind == JsonValueKind.Array
|
|
? ranges.GetArrayLength()
|
|
: 0;
|
|
|
|
list.Add(new LspCallHierarchyEntry
|
|
{
|
|
Name = item.Name,
|
|
Kind = item.Kind,
|
|
Location = item.Location,
|
|
RangeCount = count,
|
|
});
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
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>()),
|
|
"go" =>
|
|
FindCommand("Go", new[] { "gopls" }, Array.Empty<string>()),
|
|
"rust" =>
|
|
FindCommand("Rust", new[] { "rust-analyzer" }, Array.Empty<string>()),
|
|
"php" =>
|
|
FindCommand("PHP", new[] { "intelephense" }, new[] { "--stdio" }),
|
|
"ruby" =>
|
|
FindCommand("Ruby", new[] { "solargraph" }, new[] { "stdio" }),
|
|
"kotlin" =>
|
|
FindCommand("Kotlin", new[] { "kotlin-language-server" }, Array.Empty<string>()),
|
|
"swift" =>
|
|
FindCommand("Swift", new[] { "sourcekit-lsp" }, 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})";
|
|
}
|
|
|
|
/// <summary>워크스페이스 심볼 검색 결과.</summary>
|
|
public class LspWorkspaceSymbol
|
|
{
|
|
public string Name { get; init; } = "";
|
|
public string Kind { get; init; } = "";
|
|
public LspLocation? Location { get; init; }
|
|
public override string ToString() => Location == null
|
|
? $"[{Kind}] {Name}"
|
|
: $"[{Kind}] {Name} @ {Location}";
|
|
}
|
|
|
|
/// <summary>호출 계층 기준 아이템.</summary>
|
|
public class LspCallHierarchyItem
|
|
{
|
|
public string Name { get; init; } = "";
|
|
public string Kind { get; init; } = "";
|
|
public LspLocation Location { get; init; } = new();
|
|
public JsonElement RawItem { get; init; }
|
|
public override string ToString() => $"[{Kind}] {Name} @ {Location}";
|
|
}
|
|
|
|
/// <summary>호출 계층의 incoming/outgoing 엔트리.</summary>
|
|
public class LspCallHierarchyEntry
|
|
{
|
|
public string Name { get; init; } = "";
|
|
public string Kind { get; init; } = "";
|
|
public LspLocation Location { get; init; } = new();
|
|
public int RangeCount { get; init; }
|
|
public override string ToString() => $"[{Kind}] {Name} @ {Location} (matches: {RangeCount})";
|
|
}
|