135 lines
6.1 KiB
C#
135 lines
6.1 KiB
C#
using System.IO;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.RegularExpressions;
|
|
|
|
namespace AxCopilot.Services.Agent;
|
|
|
|
/// <summary>파일 내용 텍스트 검색 도구. 정규식을 지원합니다.</summary>
|
|
public class GrepTool : IAgentTool
|
|
{
|
|
public string Name => "grep";
|
|
public string Description => "Search file contents for a pattern (regex supported). Returns matching lines with file paths and line numbers.";
|
|
|
|
public ToolParameterSchema Parameters => new()
|
|
{
|
|
Properties = new()
|
|
{
|
|
["pattern"] = new() { Type = "string", Description = "Search pattern (regex supported)" },
|
|
["path"] = new() { Type = "string", Description = "File or directory to search in. Optional, defaults to work folder." },
|
|
["glob"] = new() { Type = "string", Description = "File pattern filter (e.g. '*.cs', '*.json'). Optional." },
|
|
["context_lines"] = new() { Type = "integer", Description = "Number of context lines before/after each match (0-5). Default 0." },
|
|
["case_sensitive"] = new() { Type = "boolean", Description = "Case-sensitive search. Default false (case-insensitive)." },
|
|
},
|
|
Required = ["pattern"]
|
|
};
|
|
|
|
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
|
{
|
|
var pattern = args.GetProperty("pattern").GetString() ?? "";
|
|
var searchPath = args.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "";
|
|
var globFilter = args.TryGetProperty("glob", out var g) ? g.GetString() ?? "" : "";
|
|
var contextLines = args.TryGetProperty("context_lines", out var cl) ? Math.Clamp(cl.GetInt32(), 0, 5) : 0;
|
|
var caseSensitive = args.TryGetProperty("case_sensitive", out var cs) && cs.GetBoolean();
|
|
|
|
var baseDir = string.IsNullOrEmpty(searchPath)
|
|
? context.WorkFolder
|
|
: FileReadTool.ResolvePath(searchPath, context.WorkFolder);
|
|
|
|
if (string.IsNullOrEmpty(baseDir))
|
|
return Task.FromResult(ToolResult.Fail("작업 폴더가 설정되지 않았습니다."));
|
|
|
|
try
|
|
{
|
|
var regexOpts = RegexOptions.Compiled | (caseSensitive ? RegexOptions.None : RegexOptions.IgnoreCase);
|
|
var regex = new Regex(pattern, regexOpts, TimeSpan.FromSeconds(5));
|
|
|
|
var filePattern = string.IsNullOrEmpty(globFilter) ? "*" : globFilter;
|
|
|
|
IEnumerable<string> files;
|
|
if (File.Exists(baseDir))
|
|
files = [baseDir];
|
|
else if (Directory.Exists(baseDir))
|
|
files = Directory.EnumerateFiles(baseDir, filePattern, SearchOption.AllDirectories);
|
|
else
|
|
return Task.FromResult(ToolResult.Fail($"경로가 존재하지 않습니다: {baseDir}"));
|
|
|
|
var sb = new StringBuilder();
|
|
int matchCount = 0;
|
|
int fileCount = 0;
|
|
const int maxMatches = 100;
|
|
|
|
foreach (var file in files)
|
|
{
|
|
if (ct.IsCancellationRequested) break;
|
|
if (!context.IsPathAllowed(file)) continue;
|
|
if (IsBinaryFile(file)) continue;
|
|
|
|
try
|
|
{
|
|
var read = TextFileCodec.ReadAllText(file);
|
|
var lines = TextFileCodec.SplitLines(read.Text);
|
|
bool fileHit = false;
|
|
for (int i = 0; i < lines.Length && matchCount < maxMatches; i++)
|
|
{
|
|
if (regex.IsMatch(lines[i]))
|
|
{
|
|
if (!fileHit)
|
|
{
|
|
var rel = Directory.Exists(context.WorkFolder)
|
|
? Path.GetRelativePath(context.WorkFolder, file)
|
|
: file;
|
|
sb.AppendLine($"\n{rel}:");
|
|
fileHit = true;
|
|
fileCount++;
|
|
}
|
|
// 컨텍스트 라인 (before)
|
|
if (contextLines > 0)
|
|
{
|
|
for (int c = Math.Max(0, i - contextLines); c < i; c++)
|
|
sb.AppendLine($" {c + 1} {lines[c].TrimEnd()}");
|
|
}
|
|
sb.AppendLine($" {i + 1}: {lines[i].TrimEnd()}");
|
|
// 컨텍스트 라인 (after)
|
|
if (contextLines > 0)
|
|
{
|
|
for (int c = i + 1; c <= Math.Min(lines.Length - 1, i + contextLines); c++)
|
|
sb.AppendLine($" {c + 1} {lines[c].TrimEnd()}");
|
|
sb.AppendLine(" ---");
|
|
}
|
|
matchCount++;
|
|
}
|
|
}
|
|
}
|
|
catch { /* 읽기 실패 파일 무시 */ }
|
|
|
|
if (matchCount >= maxMatches) break;
|
|
}
|
|
|
|
if (matchCount == 0)
|
|
return Task.FromResult(ToolResult.Ok($"패턴 '{pattern}'에 일치하는 결과가 없습니다."));
|
|
|
|
var header = $"{fileCount}개 파일에서 {matchCount}개 일치{(matchCount >= maxMatches ? " (제한 도달)" : "")}:";
|
|
return Task.FromResult(ToolResult.Ok(header + sb));
|
|
}
|
|
catch (RegexParseException)
|
|
{
|
|
return Task.FromResult(ToolResult.Fail($"잘못된 정규식 패턴: {pattern}"));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return Task.FromResult(ToolResult.Fail($"검색 실패: {ex.Message}"));
|
|
}
|
|
}
|
|
|
|
private static bool IsBinaryFile(string path)
|
|
{
|
|
var ext = Path.GetExtension(path).ToLowerInvariant();
|
|
return ext is ".exe" or ".dll" or ".zip" or ".7z" or ".rar" or ".tar" or ".gz"
|
|
or ".png" or ".jpg" or ".jpeg" or ".gif" or ".bmp" or ".ico" or ".webp"
|
|
or ".pdf" or ".docx" or ".xlsx" or ".pptx"
|
|
or ".mp3" or ".mp4" or ".avi" or ".mov" or ".mkv"
|
|
or ".psd" or ".msi" or ".iso" or ".bin" or ".dat" or ".db";
|
|
}
|
|
}
|