Initial commit to new repository
This commit is contained in:
134
src/AxCopilot/Services/Agent/GrepTool.cs
Normal file
134
src/AxCopilot/Services/Agent/GrepTool.cs
Normal file
@@ -0,0 +1,134 @@
|
||||
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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user