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