glob.go

  1package tools
  2
  3import (
  4	"bytes"
  5	"cmp"
  6	"context"
  7	_ "embed"
  8	"fmt"
  9	"html/template"
 10	"log/slog"
 11	"os/exec"
 12	"path/filepath"
 13	"sort"
 14	"strings"
 15
 16	"charm.land/fantasy"
 17	"git.secluded.site/crush/internal/filepathext"
 18	"git.secluded.site/crush/internal/fsext"
 19)
 20
 21const GlobToolName = "glob"
 22
 23//go:embed glob.md.tpl
 24var globDescriptionTmpl []byte
 25
 26var globDescriptionTpl = template.Must(
 27	template.New("globDescription").
 28		Parse(string(globDescriptionTmpl)),
 29)
 30
 31type globDescriptionData struct {
 32	MaxResults int
 33}
 34
 35func globDescription() string {
 36	return renderTemplate(globDescriptionTpl, globDescriptionData{
 37		MaxResults: 100,
 38	})
 39}
 40
 41type GlobParams struct {
 42	Pattern string `json:"pattern" description:"The glob pattern to match files against"`
 43	Path    string `json:"path,omitempty" description:"The directory to search in. Defaults to the current working directory."`
 44}
 45
 46type GlobResponseMetadata struct {
 47	NumberOfFiles int  `json:"number_of_files"`
 48	Truncated     bool `json:"truncated"`
 49}
 50
 51func NewGlobTool(workingDir string) fantasy.AgentTool {
 52	return fantasy.NewAgentTool(
 53		GlobToolName,
 54		globDescription(),
 55		func(ctx context.Context, params GlobParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
 56			if params.Pattern == "" {
 57				return fantasy.NewTextErrorResponse("pattern is required"), nil
 58			}
 59
 60			searchPath := cmp.Or(params.Path, workingDir)
 61
 62			files, truncated, err := globFiles(ctx, params.Pattern, searchPath, 100)
 63			if err != nil {
 64				return fantasy.NewTextErrorResponse(fmt.Sprintf("error finding files: %v", err)), nil
 65			}
 66
 67			var output string
 68			if len(files) == 0 {
 69				output = "No files found"
 70			} else {
 71				normalizeFilePaths(files)
 72				output = strings.Join(files, "\n")
 73				if truncated {
 74					output += "\n\n(Results are truncated. Consider using a more specific path or pattern.)"
 75				}
 76			}
 77
 78			return fantasy.WithResponseMetadata(
 79				fantasy.NewTextResponse(output),
 80				GlobResponseMetadata{
 81					NumberOfFiles: len(files),
 82					Truncated:     truncated,
 83				},
 84			), nil
 85		},
 86	)
 87}
 88
 89func globFiles(ctx context.Context, pattern, searchPath string, limit int) ([]string, bool, error) {
 90	cmdRg := getRgCmd(ctx, pattern)
 91	if cmdRg != nil {
 92		cmdRg.Dir = searchPath
 93		matches, err := runRipgrep(cmdRg, searchPath, limit)
 94		if err == nil {
 95			return matches, len(matches) >= limit && limit > 0, nil
 96		}
 97		slog.Warn("Ripgrep execution failed, falling back to doublestar", "error", err)
 98	}
 99
100	return fsext.GlobGitignoreAware(pattern, searchPath, limit)
101}
102
103func runRipgrep(cmd *exec.Cmd, searchRoot string, limit int) ([]string, error) {
104	out, err := cmd.CombinedOutput()
105	if err != nil {
106		if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
107			return nil, nil
108		}
109		return nil, fmt.Errorf("ripgrep: %w\n%s", err, out)
110	}
111
112	var matches []string
113	for p := range bytes.SplitSeq(out, []byte{0}) {
114		if len(p) == 0 {
115			continue
116		}
117		absPath := filepathext.SmartJoin(searchRoot, string(p))
118		if fsext.SkipHidden(absPath) {
119			continue
120		}
121		matches = append(matches, absPath)
122	}
123
124	sort.SliceStable(matches, func(i, j int) bool {
125		return len(matches[i]) < len(matches[j])
126	})
127
128	if limit > 0 && len(matches) > limit {
129		matches = matches[:limit]
130	}
131	return matches, nil
132}
133
134func normalizeFilePaths(paths []string) {
135	for i, p := range paths {
136		paths[i] = filepath.ToSlash(p)
137	}
138}