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