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