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