glob.go

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