| 1 | using System.Diagnostics; |
| 2 | using System.Text.Json; |
| 3 | using CascadeIDE.Contracts; |
| 4 | |
| 5 | namespace CascadeIDE.Features.Search.DataAcquisition; |
| 6 | |
| 7 | /// <summary>Одно совпадение <c>rg --json</c> (полный путь к файлу, 1-based строка).</summary> |
| 8 | public readonly record struct RipgrepWorkspaceMatch(string Path, int LineNumber, string LineText); |
| 9 | |
| 10 | /// <summary> |
| 11 | /// DAL: поиск по файлам в каталоге workspace через внешний <see href="https://github.com/BurntSushi/ripgrep">ripgrep</see> (<c>rg</c>). |
| 12 | /// </summary> |
| 13 | /// <remarks> |
| 14 | /// По умолчанию запускается имя <c>rg</c> (разрешение через PATH ОС). Отдельный бинарь в составе IDE не поставляется — |
| 15 | /// на любой платформе достаточно установить ripgrep и убедиться, что <c>rg</c> находится в PATH; иначе передай полный путь в <c>rg_path</c>. |
| 16 | /// </remarks> |
| 17 | [IoBoundary] |
| 18 | public static class RipgrepWorkspaceSearchService |
| 19 | { |
| 20 | private const int DefaultMaxMatches = 200; |
| 21 | private const int AbsoluteMaxMatches = 50_000; |
| 22 | |
| 23 | /// <summary> |
| 24 | /// Запускает <c>rg --json</c> с рабочим каталогом <paramref name="workspaceRoot"/>; возвращает JSON с массивом совпадений или полем <c>error</c>. |
| 25 | /// </summary> |
| 26 | public static async Task<string> SearchAsync( |
| 27 | string workspaceRoot, |
| 28 | string pattern, |
| 29 | string? subPath, |
| 30 | bool fixedString, |
| 31 | string? glob, |
| 32 | int maxMatches, |
| 33 | string? rgExecutable, |
| 34 | CancellationToken cancellationToken = default) |
| 35 | { |
| 36 | if (string.IsNullOrWhiteSpace(pattern)) |
| 37 | return JsonSerializer.Serialize(new { error = "Missing pattern." }); |
| 38 | |
| 39 | workspaceRoot = CanonicalFilePath.Normalize(workspaceRoot.Trim()); |
| 40 | if (!Directory.Exists(workspaceRoot)) |
| 41 | return JsonSerializer.Serialize(new { error = "Workspace root is not a directory: " + workspaceRoot }); |
| 42 | |
| 43 | var relSearch = ResolveSafeRelativeSearchPath(workspaceRoot, subPath); |
| 44 | if (relSearch is null) |
| 45 | return JsonSerializer.Serialize(new { error = "subpath escapes workspace root." }); |
| 46 | |
| 47 | maxMatches = Math.Clamp(maxMatches <= 0 ? DefaultMaxMatches : maxMatches, 1, AbsoluteMaxMatches); |
| 48 | |
| 49 | var rg = string.IsNullOrWhiteSpace(rgExecutable) ? "rg" : rgExecutable.Trim(); |
| 50 | try |
| 51 | { |
| 52 | var (matches, error, truncated, stderr) = await RunRipgrepCoreAsync( |
| 53 | workspaceRoot, rg, pattern, relSearch, fixedString, glob, maxMatches, cancellationToken) |
| 54 | .ConfigureAwait(false); |
| 55 | if (error is not null) |
| 56 | return JsonSerializer.Serialize(new { error = error }); |
| 57 | |
| 58 | return JsonSerializer.Serialize(new |
| 59 | { |
| 60 | workspace_root = workspaceRoot, |
| 61 | pattern, |
| 62 | matches = matches.Select(x => new { path = x.path, line_number = x.line_number, line_text = x.line_text }).ToList(), |
| 63 | match_count = matches.Count, |
| 64 | truncated, |
| 65 | stderr = string.IsNullOrEmpty(stderr) ? null : stderr |
| 66 | }); |
| 67 | } |
| 68 | catch (Exception ex) |
| 69 | { |
| 70 | return JsonSerializer.Serialize(new { error = ex.Message }); |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | /// <summary>Совпадения без сериализации в JSON (палитра «Перейти к…»).</summary> |
| 75 | public static async Task<(IReadOnlyList<RipgrepWorkspaceMatch> Matches, string? Error)> SearchMatchesAsync( |
| 76 | string workspaceRoot, |
| 77 | string pattern, |
| 78 | string? subPath, |
| 79 | bool fixedString, |
| 80 | string? glob, |
| 81 | int maxMatches, |
| 82 | string? rgExecutable, |
| 83 | CancellationToken cancellationToken = default) |
| 84 | { |
| 85 | if (string.IsNullOrWhiteSpace(pattern)) |
| 86 | return (Array.Empty<RipgrepWorkspaceMatch>(), "Missing pattern."); |
| 87 | |
| 88 | workspaceRoot = CanonicalFilePath.Normalize(workspaceRoot.Trim()); |
| 89 | if (!Directory.Exists(workspaceRoot)) |
| 90 | return (Array.Empty<RipgrepWorkspaceMatch>(), "Workspace root is not a directory: " + workspaceRoot); |
| 91 | |
| 92 | var relSearch = ResolveSafeRelativeSearchPath(workspaceRoot, subPath); |
| 93 | if (relSearch is null) |
| 94 | return (Array.Empty<RipgrepWorkspaceMatch>(), "subpath escapes workspace root."); |
| 95 | |
| 96 | maxMatches = Math.Clamp(maxMatches <= 0 ? DefaultMaxMatches : maxMatches, 1, AbsoluteMaxMatches); |
| 97 | |
| 98 | var rg = string.IsNullOrWhiteSpace(rgExecutable) ? "rg" : rgExecutable.Trim(); |
| 99 | try |
| 100 | { |
| 101 | var (raw, error, _, _) = await RunRipgrepCoreAsync( |
| 102 | workspaceRoot, rg, pattern, relSearch, fixedString, glob, maxMatches, cancellationToken) |
| 103 | .ConfigureAwait(false); |
| 104 | if (error is not null) |
| 105 | return (Array.Empty<RipgrepWorkspaceMatch>(), error); |
| 106 | |
| 107 | var list = new RipgrepWorkspaceMatch[raw.Count]; |
| 108 | for (var i = 0; i < raw.Count; i++) |
| 109 | { |
| 110 | var m = raw[i]; |
| 111 | list[i] = new RipgrepWorkspaceMatch(m.path, m.line_number, m.line_text); |
| 112 | } |
| 113 | |
| 114 | return (list, null); |
| 115 | } |
| 116 | catch (Exception ex) |
| 117 | { |
| 118 | return (Array.Empty<RipgrepWorkspaceMatch>(), ex.Message); |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | /// <summary>Возвращает относительный путь поиска от <paramref name="workspaceRoot"/> или null при обходе вверх.</summary> |
| 123 | private static string? ResolveSafeRelativeSearchPath(string workspaceRoot, string? subPath) |
| 124 | { |
| 125 | if (string.IsNullOrWhiteSpace(subPath)) |
| 126 | return "."; |
| 127 | |
| 128 | var combined = CanonicalFilePath.Normalize(Path.Combine(workspaceRoot, subPath.Trim())); |
| 129 | var root = CanonicalFilePath.Normalize(workspaceRoot); |
| 130 | if (!combined.StartsWith(root, StringComparison.OrdinalIgnoreCase)) |
| 131 | return null; |
| 132 | if (string.Equals(combined, root, StringComparison.OrdinalIgnoreCase)) |
| 133 | return "."; |
| 134 | |
| 135 | var rel = Path.GetRelativePath(root, combined); |
| 136 | if (rel.StartsWith("..", StringComparison.Ordinal) || Path.IsPathRooted(rel)) |
| 137 | return null; |
| 138 | return rel; |
| 139 | } |
| 140 | |
| 141 | private static async Task<(List<(string path, int line_number, string line_text)> matches, string? error, bool truncated, string stderr)> RunRipgrepCoreAsync( |
| 142 | string workspaceRoot, |
| 143 | string rgExecutable, |
| 144 | string pattern, |
| 145 | string relativeSearchPath, |
| 146 | bool fixedString, |
| 147 | string? glob, |
| 148 | int maxMatches, |
| 149 | CancellationToken cancellationToken) |
| 150 | { |
| 151 | var psi = new ProcessStartInfo |
| 152 | { |
| 153 | FileName = rgExecutable, |
| 154 | WorkingDirectory = workspaceRoot, |
| 155 | RedirectStandardOutput = true, |
| 156 | RedirectStandardError = true, |
| 157 | UseShellExecute = false, |
| 158 | CreateNoWindow = true |
| 159 | }; |
| 160 | |
| 161 | psi.ArgumentList.Add("--json"); |
| 162 | if (fixedString) |
| 163 | psi.ArgumentList.Add("-F"); |
| 164 | psi.ArgumentList.Add("-e"); |
| 165 | psi.ArgumentList.Add(pattern); |
| 166 | if (!string.IsNullOrWhiteSpace(glob)) |
| 167 | { |
| 168 | psi.ArgumentList.Add("--glob"); |
| 169 | psi.ArgumentList.Add(glob.Trim()); |
| 170 | } |
| 171 | |
| 172 | psi.ArgumentList.Add(relativeSearchPath); |
| 173 | |
| 174 | using var process = new Process { StartInfo = psi, EnableRaisingEvents = true }; |
| 175 | if (!process.Start()) |
| 176 | return ([], "Failed to start rg process.", false, ""); |
| 177 | |
| 178 | var matches = new List<(string path, int line_number, string line_text)>(Math.Min(maxMatches, 1024)); |
| 179 | var truncated = false; |
| 180 | |
| 181 | var stderrTask = process.StandardError.ReadToEndAsync(cancellationToken); |
| 182 | |
| 183 | try |
| 184 | { |
| 185 | var reader = process.StandardOutput; |
| 186 | while (matches.Count < maxMatches) |
| 187 | { |
| 188 | var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false); |
| 189 | if (line is null) |
| 190 | break; |
| 191 | if (line.Length == 0) |
| 192 | continue; |
| 193 | if (TryParseMatchLine(line, workspaceRoot, out var m)) |
| 194 | matches.Add(m); |
| 195 | } |
| 196 | |
| 197 | if (matches.Count >= maxMatches) |
| 198 | { |
| 199 | truncated = true; |
| 200 | try |
| 201 | { |
| 202 | process.Kill(entireProcessTree: true); |
| 203 | } |
| 204 | catch |
| 205 | { |
| 206 | // ignore |
| 207 | } |
| 208 | } |
| 209 | |
| 210 | await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); |
| 211 | } |
| 212 | catch (OperationCanceledException) |
| 213 | { |
| 214 | try |
| 215 | { |
| 216 | process.Kill(entireProcessTree: true); |
| 217 | } |
| 218 | catch |
| 219 | { |
| 220 | // ignore |
| 221 | } |
| 222 | |
| 223 | throw; |
| 224 | } |
| 225 | |
| 226 | var errText = (await stderrTask.ConfigureAwait(false)).Trim(); |
| 227 | |
| 228 | var exit = process.ExitCode; |
| 229 | if (exit == 2 && matches.Count == 0) |
| 230 | { |
| 231 | return ([], string.IsNullOrEmpty(errText) |
| 232 | ? "rg exited with code 2. Is ripgrep installed and on PATH? See https://github.com/BurntSushi/ripgrep/releases" |
| 233 | : errText, false, errText); |
| 234 | } |
| 235 | |
| 236 | return (matches, null, truncated, errText); |
| 237 | } |
| 238 | |
| 239 | private static bool TryParseMatchLine(string line, string workspaceRoot, out (string path, int line_number, string line_text) match) |
| 240 | { |
| 241 | match = default; |
| 242 | try |
| 243 | { |
| 244 | using var doc = JsonDocument.Parse(line); |
| 245 | var root = doc.RootElement; |
| 246 | if (!root.TryGetProperty("type", out var typeEl) || typeEl.GetString() != "match") |
| 247 | return false; |
| 248 | if (!root.TryGetProperty("data", out var data)) |
| 249 | return false; |
| 250 | |
| 251 | string? pathText = null; |
| 252 | if (data.TryGetProperty("path", out var pathObj) && pathObj.TryGetProperty("text", out var pathInner)) |
| 253 | pathText = pathInner.GetString(); |
| 254 | |
| 255 | var lineNumber = 0; |
| 256 | if (data.TryGetProperty("line_number", out var ln) && ln.TryGetInt32(out var lnv)) |
| 257 | lineNumber = lnv; |
| 258 | |
| 259 | string? lineText = null; |
| 260 | if (data.TryGetProperty("lines", out var linesObj) && linesObj.TryGetProperty("text", out var linesInner)) |
| 261 | lineText = linesInner.GetString()?.TrimEnd('\r', '\n'); |
| 262 | |
| 263 | if (string.IsNullOrEmpty(pathText)) |
| 264 | return false; |
| 265 | |
| 266 | var fullPath = CanonicalFilePath.Normalize(Path.Combine(workspaceRoot, pathText.Replace('/', Path.DirectorySeparatorChar))); |
| 267 | match = (fullPath, lineNumber, lineText ?? ""); |
| 268 | return true; |
| 269 | } |
| 270 | catch |
| 271 | { |
| 272 | return false; |
| 273 | } |
| 274 | } |
| 275 | } |
| 276 | |