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