Forge
csharpfc256dbb
1using System.Diagnostics;
2using System.Text.Json;
3using CascadeIDE.Contracts;
4
5namespace CascadeIDE.Features.Search.DataAcquisition;
6
7/// <summary>Одно совпадение <c>rg --json</c> (полный путь к файлу, 1-based строка).</summary>
8public 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]
18public 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
View only · write via MCP/CIDE