ls.go

  1package tools
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"fmt"
  7	"os"
  8	"path/filepath"
  9	"strings"
 10
 11	"github.com/opencode-ai/opencode/internal/config"
 12)
 13
 14type LSParams struct {
 15	Path   string   `json:"path"`
 16	Ignore []string `json:"ignore"`
 17}
 18
 19type TreeNode struct {
 20	Name     string      `json:"name"`
 21	Path     string      `json:"path"`
 22	Type     string      `json:"type"` // "file" or "directory"
 23	Children []*TreeNode `json:"children,omitempty"`
 24}
 25
 26type LSResponseMetadata struct {
 27	NumberOfFiles int  `json:"number_of_files"`
 28	Truncated     bool `json:"truncated"`
 29}
 30
 31type lsTool struct{}
 32
 33const (
 34	LSToolName    = "ls"
 35	MaxLSFiles    = 1000
 36	lsDescription = `Directory listing tool that shows files and subdirectories in a tree structure, helping you explore and understand the project organization.
 37
 38WHEN TO USE THIS TOOL:
 39- Use when you need to explore the structure of a directory
 40- Helpful for understanding the organization of a project
 41- Good first step when getting familiar with a new codebase
 42
 43HOW TO USE:
 44- Provide a path to list (defaults to current working directory)
 45- Optionally specify glob patterns to ignore
 46- Results are displayed in a tree structure
 47
 48FEATURES:
 49- Displays a hierarchical view of files and directories
 50- Automatically skips hidden files/directories (starting with '.')
 51- Skips common system directories like __pycache__
 52- Can filter out files matching specific patterns
 53
 54LIMITATIONS:
 55- Results are limited to 1000 files
 56- Very large directories will be truncated
 57- Does not show file sizes or permissions
 58- Cannot recursively list all directories in a large project
 59
 60TIPS:
 61- Use Glob tool for finding files by name patterns instead of browsing
 62- Use Grep tool for searching file contents
 63- Combine with other tools for more effective exploration`
 64)
 65
 66func NewLsTool() BaseTool {
 67	return &lsTool{}
 68}
 69
 70func (l *lsTool) Info() ToolInfo {
 71	return ToolInfo{
 72		Name:        LSToolName,
 73		Description: lsDescription,
 74		Parameters: map[string]any{
 75			"path": map[string]any{
 76				"type":        "string",
 77				"description": "The path to the directory to list (defaults to current working directory)",
 78			},
 79			"ignore": map[string]any{
 80				"type":        "array",
 81				"description": "List of glob patterns to ignore",
 82				"items": map[string]any{
 83					"type": "string",
 84				},
 85			},
 86		},
 87		Required: []string{"path"},
 88	}
 89}
 90
 91func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
 92	var params LSParams
 93	if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
 94		return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
 95	}
 96
 97	searchPath := params.Path
 98	if searchPath == "" {
 99		searchPath = config.WorkingDirectory()
100	}
101
102	if !filepath.IsAbs(searchPath) {
103		searchPath = filepath.Join(config.WorkingDirectory(), searchPath)
104	}
105
106	if _, err := os.Stat(searchPath); os.IsNotExist(err) {
107		return NewTextErrorResponse(fmt.Sprintf("path does not exist: %s", searchPath)), nil
108	}
109
110	files, truncated, err := listDirectory(searchPath, params.Ignore, MaxLSFiles)
111	if err != nil {
112		return ToolResponse{}, fmt.Errorf("error listing directory: %w", err)
113	}
114
115	tree := createFileTree(files)
116	output := printTree(tree, searchPath)
117
118	if truncated {
119		output = fmt.Sprintf("There are more than %d files in the directory. Use a more specific path or use the Glob tool to find specific files. The first %d files and directories are included below:\n\n%s", MaxLSFiles, MaxLSFiles, output)
120	}
121
122	return WithResponseMetadata(
123		NewTextResponse(output),
124		LSResponseMetadata{
125			NumberOfFiles: len(files),
126			Truncated:     truncated,
127		},
128	), nil
129}
130
131func listDirectory(initialPath string, ignorePatterns []string, limit int) ([]string, bool, error) {
132	var results []string
133	truncated := false
134
135	err := filepath.Walk(initialPath, func(path string, info os.FileInfo, err error) error {
136		if err != nil {
137			return nil // Skip files we don't have permission to access
138		}
139
140		if shouldSkip(path, ignorePatterns) {
141			if info.IsDir() {
142				return filepath.SkipDir
143			}
144			return nil
145		}
146
147		if path != initialPath {
148			if info.IsDir() {
149				path = path + string(filepath.Separator)
150			}
151			results = append(results, path)
152		}
153
154		if len(results) >= limit {
155			truncated = true
156			return filepath.SkipAll
157		}
158
159		return nil
160	})
161	if err != nil {
162		return nil, truncated, err
163	}
164
165	return results, truncated, nil
166}
167
168func shouldSkip(path string, ignorePatterns []string) bool {
169	base := filepath.Base(path)
170
171	if base != "." && strings.HasPrefix(base, ".") {
172		return true
173	}
174
175	commonIgnored := []string{
176		"__pycache__",
177		"node_modules",
178		"dist",
179		"build",
180		"target",
181		"vendor",
182		"bin",
183		"obj",
184		".git",
185		".idea",
186		".vscode",
187		".DS_Store",
188		"*.pyc",
189		"*.pyo",
190		"*.pyd",
191		"*.so",
192		"*.dll",
193		"*.exe",
194	}
195
196	if strings.Contains(path, filepath.Join("__pycache__", "")) {
197		return true
198	}
199
200	for _, ignored := range commonIgnored {
201		if strings.HasSuffix(ignored, "/") {
202			if strings.Contains(path, filepath.Join(ignored[:len(ignored)-1], "")) {
203				return true
204			}
205		} else if strings.HasPrefix(ignored, "*.") {
206			if strings.HasSuffix(base, ignored[1:]) {
207				return true
208			}
209		} else {
210			if base == ignored {
211				return true
212			}
213		}
214	}
215
216	for _, pattern := range ignorePatterns {
217		matched, err := filepath.Match(pattern, base)
218		if err == nil && matched {
219			return true
220		}
221	}
222
223	return false
224}
225
226func createFileTree(sortedPaths []string) []*TreeNode {
227	root := []*TreeNode{}
228	pathMap := make(map[string]*TreeNode)
229
230	for _, path := range sortedPaths {
231		parts := strings.Split(path, string(filepath.Separator))
232		currentPath := ""
233		var parentPath string
234
235		var cleanParts []string
236		for _, part := range parts {
237			if part != "" {
238				cleanParts = append(cleanParts, part)
239			}
240		}
241		parts = cleanParts
242
243		if len(parts) == 0 {
244			continue
245		}
246
247		for i, part := range parts {
248			if currentPath == "" {
249				currentPath = part
250			} else {
251				currentPath = filepath.Join(currentPath, part)
252			}
253
254			if _, exists := pathMap[currentPath]; exists {
255				parentPath = currentPath
256				continue
257			}
258
259			isLastPart := i == len(parts)-1
260			isDir := !isLastPart || strings.HasSuffix(path, string(filepath.Separator))
261			nodeType := "file"
262			if isDir {
263				nodeType = "directory"
264			}
265			newNode := &TreeNode{
266				Name:     part,
267				Path:     currentPath,
268				Type:     nodeType,
269				Children: []*TreeNode{},
270			}
271
272			pathMap[currentPath] = newNode
273
274			if i > 0 && parentPath != "" {
275				if parent, ok := pathMap[parentPath]; ok {
276					parent.Children = append(parent.Children, newNode)
277				}
278			} else {
279				root = append(root, newNode)
280			}
281
282			parentPath = currentPath
283		}
284	}
285
286	return root
287}
288
289func printTree(tree []*TreeNode, rootPath string) string {
290	var result strings.Builder
291
292	result.WriteString(fmt.Sprintf("- %s%s\n", rootPath, string(filepath.Separator)))
293
294	for _, node := range tree {
295		printNode(&result, node, 1)
296	}
297
298	return result.String()
299}
300
301func printNode(builder *strings.Builder, node *TreeNode, level int) {
302	indent := strings.Repeat("  ", level)
303
304	nodeName := node.Name
305	if node.Type == "directory" {
306		nodeName += string(filepath.Separator)
307	}
308
309	fmt.Fprintf(builder, "%s- %s\n", indent, nodeName)
310
311	if node.Type == "directory" && len(node.Children) > 0 {
312		for _, child := range node.Children {
313			printNode(builder, child, level+1)
314		}
315	}
316}