1package tools
2
3import (
4 "bufio"
5 "context"
6 "encoding/json"
7 "fmt"
8 "io"
9 "os"
10 "path/filepath"
11 "strings"
12
13 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/lsp"
15)
16
17type ViewParams struct {
18 FilePath string `json:"file_path"`
19 Offset int `json:"offset"`
20 Limit int `json:"limit"`
21}
22
23type viewTool struct {
24 lspClients map[string]*lsp.Client
25}
26
27type ViewResponseMetadata struct {
28 FilePath string `json:"file_path"`
29 Content string `json:"content"`
30}
31
32const (
33 ViewToolName = "view"
34 MaxReadSize = 250 * 1024
35 DefaultReadLimit = 2000
36 MaxLineLength = 2000
37 viewDescription = `File viewing tool that reads and displays the contents of files with line numbers, allowing you to examine code, logs, or text data.
38
39WHEN TO USE THIS TOOL:
40- Use when you need to read the contents of a specific file
41- Helpful for examining source code, configuration files, or log files
42- Perfect for looking at text-based file formats
43
44HOW TO USE:
45- Provide the path to the file you want to view
46- Optionally specify an offset to start reading from a specific line
47- Optionally specify a limit to control how many lines are read
48
49FEATURES:
50- Displays file contents with line numbers for easy reference
51- Can read from any position in a file using the offset parameter
52- Handles large files by limiting the number of lines read
53- Automatically truncates very long lines for better display
54- Suggests similar file names when the requested file isn't found
55
56LIMITATIONS:
57- Maximum file size is 250KB
58- Default reading limit is 2000 lines
59- Lines longer than 2000 characters are truncated
60- Cannot display binary files or images
61- Images can be identified but not displayed
62
63WINDOWS NOTES:
64- Handles both Windows (CRLF) and Unix (LF) line endings automatically
65- File paths work with both forward slashes (/) and backslashes (\)
66- Text encoding is detected automatically for most common formats
67
68TIPS:
69- Use with Glob tool to first find files you want to view
70- For code exploration, first use Grep to find relevant files, then View to examine them
71- When viewing large files, use the offset parameter to read specific sections`
72)
73
74func NewViewTool(lspClients map[string]*lsp.Client) BaseTool {
75 return &viewTool{
76 lspClients,
77 }
78}
79
80func (v *viewTool) Name() string {
81 return ViewToolName
82}
83
84func (v *viewTool) Info() ToolInfo {
85 return ToolInfo{
86 Name: ViewToolName,
87 Description: viewDescription,
88 Parameters: map[string]any{
89 "file_path": map[string]any{
90 "type": "string",
91 "description": "The path to the file to read",
92 },
93 "offset": map[string]any{
94 "type": "integer",
95 "description": "The line number to start reading from (0-based)",
96 },
97 "limit": map[string]any{
98 "type": "integer",
99 "description": "The number of lines to read (defaults to 2000)",
100 },
101 },
102 Required: []string{"file_path"},
103 }
104}
105
106// Run implements Tool.
107func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
108 var params ViewParams
109 if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
110 return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
111 }
112
113 if params.FilePath == "" {
114 return NewTextErrorResponse("file_path is required"), nil
115 }
116
117 // Handle relative paths
118 filePath := params.FilePath
119 if !filepath.IsAbs(filePath) {
120 filePath = filepath.Join(config.Get().WorkingDir(), filePath)
121 }
122
123 // Check if file exists
124 fileInfo, err := os.Stat(filePath)
125 if err != nil {
126 if os.IsNotExist(err) {
127 // Try to offer suggestions for similarly named files
128 dir := filepath.Dir(filePath)
129 base := filepath.Base(filePath)
130
131 dirEntries, dirErr := os.ReadDir(dir)
132 if dirErr == nil {
133 var suggestions []string
134 for _, entry := range dirEntries {
135 if strings.Contains(strings.ToLower(entry.Name()), strings.ToLower(base)) ||
136 strings.Contains(strings.ToLower(base), strings.ToLower(entry.Name())) {
137 suggestions = append(suggestions, filepath.Join(dir, entry.Name()))
138 if len(suggestions) >= 3 {
139 break
140 }
141 }
142 }
143
144 if len(suggestions) > 0 {
145 return NewTextErrorResponse(fmt.Sprintf("File not found: %s\n\nDid you mean one of these?\n%s",
146 filePath, strings.Join(suggestions, "\n"))), nil
147 }
148 }
149
150 return NewTextErrorResponse(fmt.Sprintf("File not found: %s", filePath)), nil
151 }
152 return ToolResponse{}, fmt.Errorf("error accessing file: %w", err)
153 }
154
155 // Check if it's a directory
156 if fileInfo.IsDir() {
157 return NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
158 }
159
160 // Check file size
161 if fileInfo.Size() > MaxReadSize {
162 return NewTextErrorResponse(fmt.Sprintf("File is too large (%d bytes). Maximum size is %d bytes",
163 fileInfo.Size(), MaxReadSize)), nil
164 }
165
166 // Set default limit if not provided
167 if params.Limit <= 0 {
168 params.Limit = DefaultReadLimit
169 }
170
171 // Check if it's an image file
172 isImage, imageType := isImageFile(filePath)
173 // TODO: handle images
174 if isImage {
175 return NewTextErrorResponse(fmt.Sprintf("This is an image file of type: %s\nUse a different tool to process images", imageType)), nil
176 }
177
178 // Read the file content
179 content, lineCount, err := readTextFile(filePath, params.Offset, params.Limit)
180 if err != nil {
181 return ToolResponse{}, fmt.Errorf("error reading file: %w", err)
182 }
183
184 notifyLspOpenFile(ctx, filePath, v.lspClients)
185 output := "<file>\n"
186 // Format the output with line numbers
187 output += addLineNumbers(content, params.Offset+1)
188
189 // Add a note if the content was truncated
190 if lineCount > params.Offset+len(strings.Split(content, "\n")) {
191 output += fmt.Sprintf("\n\n(File has more lines. Use 'offset' parameter to read beyond line %d)",
192 params.Offset+len(strings.Split(content, "\n")))
193 }
194 output += "\n</file>\n"
195 output += getDiagnostics(filePath, v.lspClients)
196 recordFileRead(filePath)
197 return WithResponseMetadata(
198 NewTextResponse(output),
199 ViewResponseMetadata{
200 FilePath: filePath,
201 Content: content,
202 },
203 ), nil
204}
205
206func addLineNumbers(content string, startLine int) string {
207 if content == "" {
208 return ""
209 }
210
211 lines := strings.Split(content, "\n")
212
213 var result []string
214 for i, line := range lines {
215 line = strings.TrimSuffix(line, "\r")
216
217 lineNum := i + startLine
218 numStr := fmt.Sprintf("%d", lineNum)
219
220 if len(numStr) >= 6 {
221 result = append(result, fmt.Sprintf("%s|%s", numStr, line))
222 } else {
223 paddedNum := fmt.Sprintf("%6s", numStr)
224 result = append(result, fmt.Sprintf("%s|%s", paddedNum, line))
225 }
226 }
227
228 return strings.Join(result, "\n")
229}
230
231func readTextFile(filePath string, offset, limit int) (string, int, error) {
232 file, err := os.Open(filePath)
233 if err != nil {
234 return "", 0, err
235 }
236 defer file.Close()
237
238 lineCount := 0
239
240 scanner := NewLineScanner(file)
241 if offset > 0 {
242 for lineCount < offset && scanner.Scan() {
243 lineCount++
244 }
245 if err = scanner.Err(); err != nil {
246 return "", 0, err
247 }
248 }
249
250 if offset == 0 {
251 _, err = file.Seek(0, io.SeekStart)
252 if err != nil {
253 return "", 0, err
254 }
255 }
256
257 var lines []string
258 lineCount = offset
259
260 for scanner.Scan() && len(lines) < limit {
261 lineCount++
262 lineText := scanner.Text()
263 if len(lineText) > MaxLineLength {
264 lineText = lineText[:MaxLineLength] + "..."
265 }
266 lines = append(lines, lineText)
267 }
268
269 // Continue scanning to get total line count
270 for scanner.Scan() {
271 lineCount++
272 }
273
274 if err := scanner.Err(); err != nil {
275 return "", 0, err
276 }
277
278 return strings.Join(lines, "\n"), lineCount, nil
279}
280
281func isImageFile(filePath string) (bool, string) {
282 ext := strings.ToLower(filepath.Ext(filePath))
283 switch ext {
284 case ".jpg", ".jpeg":
285 return true, "JPEG"
286 case ".png":
287 return true, "PNG"
288 case ".gif":
289 return true, "GIF"
290 case ".bmp":
291 return true, "BMP"
292 case ".svg":
293 return true, "SVG"
294 case ".webp":
295 return true, "WebP"
296 default:
297 return false, ""
298 }
299}
300
301type LineScanner struct {
302 scanner *bufio.Scanner
303}
304
305func NewLineScanner(r io.Reader) *LineScanner {
306 return &LineScanner{
307 scanner: bufio.NewScanner(r),
308 }
309}
310
311func (s *LineScanner) Scan() bool {
312 return s.scanner.Scan()
313}
314
315func (s *LineScanner) Text() string {
316 return s.scanner.Text()
317}
318
319func (s *LineScanner) Err() error {
320 return s.scanner.Err()
321}