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
63TIPS:
64- Use with Glob tool to first find files you want to view
65- For code exploration, first use Grep to find relevant files, then View to examine them
66- When viewing large files, use the offset parameter to read specific sections`
67)
68
69func NewViewTool(lspClients map[string]*lsp.Client) BaseTool {
70 return &viewTool{
71 lspClients,
72 }
73}
74
75func (v *viewTool) Info() ToolInfo {
76 return ToolInfo{
77 Name: ViewToolName,
78 Description: viewDescription,
79 Parameters: map[string]any{
80 "file_path": map[string]any{
81 "type": "string",
82 "description": "The path to the file to read",
83 },
84 "offset": map[string]any{
85 "type": "integer",
86 "description": "The line number to start reading from (0-based)",
87 },
88 "limit": map[string]any{
89 "type": "integer",
90 "description": "The number of lines to read (defaults to 2000)",
91 },
92 },
93 Required: []string{"file_path"},
94 }
95}
96
97// Run implements Tool.
98func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
99 var params ViewParams
100 if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
101 return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
102 }
103
104 if params.FilePath == "" {
105 return NewTextErrorResponse("file_path is required"), nil
106 }
107
108 // Handle relative paths
109 filePath := params.FilePath
110 if !filepath.IsAbs(filePath) {
111 filePath = filepath.Join(config.WorkingDirectory(), filePath)
112 }
113
114 // Check if file exists
115 fileInfo, err := os.Stat(filePath)
116 if err != nil {
117 if os.IsNotExist(err) {
118 // Try to offer suggestions for similarly named files
119 dir := filepath.Dir(filePath)
120 base := filepath.Base(filePath)
121
122 dirEntries, dirErr := os.ReadDir(dir)
123 if dirErr == nil {
124 var suggestions []string
125 for _, entry := range dirEntries {
126 if strings.Contains(strings.ToLower(entry.Name()), strings.ToLower(base)) ||
127 strings.Contains(strings.ToLower(base), strings.ToLower(entry.Name())) {
128 suggestions = append(suggestions, filepath.Join(dir, entry.Name()))
129 if len(suggestions) >= 3 {
130 break
131 }
132 }
133 }
134
135 if len(suggestions) > 0 {
136 return NewTextErrorResponse(fmt.Sprintf("File not found: %s\n\nDid you mean one of these?\n%s",
137 filePath, strings.Join(suggestions, "\n"))), nil
138 }
139 }
140
141 return NewTextErrorResponse(fmt.Sprintf("File not found: %s", filePath)), nil
142 }
143 return ToolResponse{}, fmt.Errorf("error accessing file: %w", err)
144 }
145
146 // Check if it's a directory
147 if fileInfo.IsDir() {
148 return NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
149 }
150
151 // Check file size
152 if fileInfo.Size() > MaxReadSize {
153 return NewTextErrorResponse(fmt.Sprintf("File is too large (%d bytes). Maximum size is %d bytes",
154 fileInfo.Size(), MaxReadSize)), nil
155 }
156
157 // Set default limit if not provided
158 if params.Limit <= 0 {
159 params.Limit = DefaultReadLimit
160 }
161
162 // Check if it's an image file
163 isImage, imageType := isImageFile(filePath)
164 // TODO: handle images
165 if isImage {
166 return NewTextErrorResponse(fmt.Sprintf("This is an image file of type: %s\nUse a different tool to process images", imageType)), nil
167 }
168
169 // Read the file content
170 content, lineCount, err := readTextFile(filePath, params.Offset, params.Limit)
171 if err != nil {
172 return ToolResponse{}, fmt.Errorf("error reading file: %w", err)
173 }
174
175 notifyLspOpenFile(ctx, filePath, v.lspClients)
176 output := "<file>\n"
177 // Format the output with line numbers
178 output += addLineNumbers(content, params.Offset+1)
179
180 // Add a note if the content was truncated
181 if lineCount > params.Offset+len(strings.Split(content, "\n")) {
182 output += fmt.Sprintf("\n\n(File has more lines. Use 'offset' parameter to read beyond line %d)",
183 params.Offset+len(strings.Split(content, "\n")))
184 }
185 output += "\n</file>\n"
186 output += getDiagnostics(filePath, v.lspClients)
187 recordFileRead(filePath)
188 return WithResponseMetadata(
189 NewTextResponse(output),
190 ViewResponseMetadata{
191 FilePath: filePath,
192 Content: content,
193 },
194 ), nil
195}
196
197func addLineNumbers(content string, startLine int) string {
198 if content == "" {
199 return ""
200 }
201
202 lines := strings.Split(content, "\n")
203
204 var result []string
205 for i, line := range lines {
206 line = strings.TrimSuffix(line, "\r")
207
208 lineNum := i + startLine
209 numStr := fmt.Sprintf("%d", lineNum)
210
211 if len(numStr) >= 6 {
212 result = append(result, fmt.Sprintf("%s|%s", numStr, line))
213 } else {
214 paddedNum := fmt.Sprintf("%6s", numStr)
215 result = append(result, fmt.Sprintf("%s|%s", paddedNum, line))
216 }
217 }
218
219 return strings.Join(result, "\n")
220}
221
222func readTextFile(filePath string, offset, limit int) (string, int, error) {
223 file, err := os.Open(filePath)
224 if err != nil {
225 return "", 0, err
226 }
227 defer file.Close()
228
229 lineCount := 0
230
231 scanner := NewLineScanner(file)
232 if offset > 0 {
233 for lineCount < offset && scanner.Scan() {
234 lineCount++
235 }
236 if err = scanner.Err(); err != nil {
237 return "", 0, err
238 }
239 }
240
241 if offset == 0 {
242 _, err = file.Seek(0, io.SeekStart)
243 if err != nil {
244 return "", 0, err
245 }
246 }
247
248 var lines []string
249 lineCount = offset
250
251 for scanner.Scan() && len(lines) < limit {
252 lineCount++
253 lineText := scanner.Text()
254 if len(lineText) > MaxLineLength {
255 lineText = lineText[:MaxLineLength] + "..."
256 }
257 lines = append(lines, lineText)
258 }
259
260 // Continue scanning to get total line count
261 for scanner.Scan() {
262 lineCount++
263 }
264
265 if err := scanner.Err(); err != nil {
266 return "", 0, err
267 }
268
269 return strings.Join(lines, "\n"), lineCount, nil
270}
271
272func isImageFile(filePath string) (bool, string) {
273 ext := strings.ToLower(filepath.Ext(filePath))
274 switch ext {
275 case ".jpg", ".jpeg":
276 return true, "JPEG"
277 case ".png":
278 return true, "PNG"
279 case ".gif":
280 return true, "GIF"
281 case ".bmp":
282 return true, "BMP"
283 case ".svg":
284 return true, "SVG"
285 case ".webp":
286 return true, "WebP"
287 default:
288 return false, ""
289 }
290}
291
292type LineScanner struct {
293 scanner *bufio.Scanner
294}
295
296func NewLineScanner(r io.Reader) *LineScanner {
297 return &LineScanner{
298 scanner: bufio.NewScanner(r),
299 }
300}
301
302func (s *LineScanner) Scan() bool {
303 return s.scanner.Scan()
304}
305
306func (s *LineScanner) Text() string {
307 return s.scanner.Text()
308}
309
310func (s *LineScanner) Err() error {
311 return s.scanner.Err()
312}