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) Name() string {
93 return ViewToolName
94}
95
96func (v *viewTool) Info() ToolInfo {
97 return ToolInfo{
98 Name: ViewToolName,
99 Description: viewDescription,
100 Parameters: map[string]any{
101 "file_path": map[string]any{
102 "type": "string",
103 "description": "The path to the file to read",
104 },
105 "offset": map[string]any{
106 "type": "integer",
107 "description": "The line number to start reading from (0-based)",
108 },
109 "limit": map[string]any{
110 "type": "integer",
111 "description": "The number of lines to read (defaults to 2000)",
112 },
113 },
114 Required: []string{"file_path"},
115 }
116}
117
118// Run implements Tool.
119func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
120 var params ViewParams
121 if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
122 return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
123 }
124
125 if params.FilePath == "" {
126 return NewTextErrorResponse("file_path is required"), nil
127 }
128
129 // Handle relative paths
130 filePath := params.FilePath
131 if !filepath.IsAbs(filePath) {
132 filePath = filepath.Join(v.workingDir, filePath)
133 }
134
135 // Check if file is outside working directory and request permission if needed
136 absWorkingDir, err := filepath.Abs(v.workingDir)
137 if err != nil {
138 return ToolResponse{}, fmt.Errorf("error resolving working directory: %w", err)
139 }
140
141 absFilePath, err := filepath.Abs(filePath)
142 if err != nil {
143 return ToolResponse{}, fmt.Errorf("error resolving file path: %w", err)
144 }
145
146 relPath, err := filepath.Rel(absWorkingDir, absFilePath)
147 if err != nil || strings.HasPrefix(relPath, "..") {
148 // File is outside working directory, request permission
149 sessionID, messageID := GetContextValues(ctx)
150 if sessionID == "" || messageID == "" {
151 return ToolResponse{}, fmt.Errorf("session ID and message ID are required for accessing files outside working directory")
152 }
153
154 granted := v.permissions.Request(
155 permission.CreatePermissionRequest{
156 SessionID: sessionID,
157 Path: absFilePath,
158 ToolCallID: call.ID,
159 ToolName: ViewToolName,
160 Action: "read",
161 Description: fmt.Sprintf("Read file outside working directory: %s", absFilePath),
162 Params: ViewPermissionsParams(params),
163 },
164 )
165
166 if !granted {
167 return ToolResponse{}, permission.ErrorPermissionDenied
168 }
169 }
170
171 // Check if file exists
172 fileInfo, err := os.Stat(filePath)
173 if err != nil {
174 if os.IsNotExist(err) {
175 // Try to offer suggestions for similarly named files
176 dir := filepath.Dir(filePath)
177 base := filepath.Base(filePath)
178
179 dirEntries, dirErr := os.ReadDir(dir)
180 if dirErr == nil {
181 var suggestions []string
182 for _, entry := range dirEntries {
183 if strings.Contains(strings.ToLower(entry.Name()), strings.ToLower(base)) ||
184 strings.Contains(strings.ToLower(base), strings.ToLower(entry.Name())) {
185 suggestions = append(suggestions, filepath.Join(dir, entry.Name()))
186 if len(suggestions) >= 3 {
187 break
188 }
189 }
190 }
191
192 if len(suggestions) > 0 {
193 return NewTextErrorResponse(fmt.Sprintf("File not found: %s\n\nDid you mean one of these?\n%s",
194 filePath, strings.Join(suggestions, "\n"))), nil
195 }
196 }
197
198 return NewTextErrorResponse(fmt.Sprintf("File not found: %s", filePath)), nil
199 }
200 return ToolResponse{}, fmt.Errorf("error accessing file: %w", err)
201 }
202
203 // Check if it's a directory
204 if fileInfo.IsDir() {
205 return NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
206 }
207
208 // Check file size
209 if fileInfo.Size() > MaxReadSize {
210 return NewTextErrorResponse(fmt.Sprintf("File is too large (%d bytes). Maximum size is %d bytes",
211 fileInfo.Size(), MaxReadSize)), nil
212 }
213
214 // Set default limit if not provided
215 if params.Limit <= 0 {
216 params.Limit = DefaultReadLimit
217 }
218
219 // Check if it's an image file
220 isImage, imageType := isImageFile(filePath)
221 // TODO: handle images
222 if isImage {
223 return NewTextErrorResponse(fmt.Sprintf("This is an image file of type: %s\n", imageType)), nil
224 }
225
226 // Read the file content
227 content, lineCount, err := readTextFile(filePath, params.Offset, params.Limit)
228 isValidUt8 := utf8.ValidString(content)
229 if !isValidUt8 {
230 return NewTextErrorResponse("File content is not valid UTF-8"), nil
231 }
232 if err != nil {
233 return ToolResponse{}, fmt.Errorf("error reading file: %w", err)
234 }
235
236 notifyLspOpenFile(ctx, filePath, v.lspClients)
237 output := "<file>\n"
238 // Format the output with line numbers
239 output += addLineNumbers(content, params.Offset+1)
240
241 // Add a note if the content was truncated
242 if lineCount > params.Offset+len(strings.Split(content, "\n")) {
243 output += fmt.Sprintf("\n\n(File has more lines. Use 'offset' parameter to read beyond line %d)",
244 params.Offset+len(strings.Split(content, "\n")))
245 }
246 output += "\n</file>\n"
247 output += getDiagnostics(filePath, v.lspClients)
248 recordFileRead(filePath)
249 return WithResponseMetadata(
250 NewTextResponse(output),
251 ViewResponseMetadata{
252 FilePath: filePath,
253 Content: content,
254 },
255 ), nil
256}
257
258func addLineNumbers(content string, startLine int) string {
259 if content == "" {
260 return ""
261 }
262
263 lines := strings.Split(content, "\n")
264
265 var result []string
266 for i, line := range lines {
267 line = strings.TrimSuffix(line, "\r")
268
269 lineNum := i + startLine
270 numStr := fmt.Sprintf("%d", lineNum)
271
272 if len(numStr) >= 6 {
273 result = append(result, fmt.Sprintf("%s|%s", numStr, line))
274 } else {
275 paddedNum := fmt.Sprintf("%6s", numStr)
276 result = append(result, fmt.Sprintf("%s|%s", paddedNum, line))
277 }
278 }
279
280 return strings.Join(result, "\n")
281}
282
283func readTextFile(filePath string, offset, limit int) (string, int, error) {
284 file, err := os.Open(filePath)
285 if err != nil {
286 return "", 0, err
287 }
288 defer file.Close()
289
290 lineCount := 0
291
292 scanner := NewLineScanner(file)
293 if offset > 0 {
294 for lineCount < offset && scanner.Scan() {
295 lineCount++
296 }
297 if err = scanner.Err(); err != nil {
298 return "", 0, err
299 }
300 }
301
302 if offset == 0 {
303 _, err = file.Seek(0, io.SeekStart)
304 if err != nil {
305 return "", 0, err
306 }
307 }
308
309 // Pre-allocate slice with expected capacity
310 lines := make([]string, 0, limit)
311 lineCount = offset
312
313 for scanner.Scan() && len(lines) < limit {
314 lineCount++
315 lineText := scanner.Text()
316 if len(lineText) > MaxLineLength {
317 lineText = lineText[:MaxLineLength] + "..."
318 }
319 lines = append(lines, lineText)
320 }
321
322 // Continue scanning to get total line count
323 for scanner.Scan() {
324 lineCount++
325 }
326
327 if err := scanner.Err(); err != nil {
328 return "", 0, err
329 }
330
331 return strings.Join(lines, "\n"), lineCount, nil
332}
333
334func isImageFile(filePath string) (bool, string) {
335 ext := strings.ToLower(filepath.Ext(filePath))
336 switch ext {
337 case ".jpg", ".jpeg":
338 return true, "JPEG"
339 case ".png":
340 return true, "PNG"
341 case ".gif":
342 return true, "GIF"
343 case ".bmp":
344 return true, "BMP"
345 case ".svg":
346 return true, "SVG"
347 case ".webp":
348 return true, "WebP"
349 default:
350 return false, ""
351 }
352}
353
354type LineScanner struct {
355 scanner *bufio.Scanner
356}
357
358func NewLineScanner(r io.Reader) *LineScanner {
359 return &LineScanner{
360 scanner: bufio.NewScanner(r),
361 }
362}
363
364func (s *LineScanner) Scan() bool {
365 return s.scanner.Scan()
366}
367
368func (s *LineScanner) Text() string {
369 return s.scanner.Text()
370}
371
372func (s *LineScanner) Err() error {
373 return s.scanner.Err()
374}