1package tools
2
3import (
4 "bufio"
5 "context"
6 _ "embed"
7 "encoding/json"
8 "fmt"
9 "io"
10 "os"
11 "path/filepath"
12 "strings"
13 "unicode/utf8"
14
15 "github.com/charmbracelet/crush/internal/csync"
16 "github.com/charmbracelet/crush/internal/lsp"
17 "github.com/charmbracelet/crush/internal/permission"
18)
19
20//go:embed view.md
21var viewDescription []byte
22
23type ViewParams struct {
24 FilePath string `json:"file_path"`
25 Offset int `json:"offset"`
26 Limit int `json:"limit"`
27}
28
29type ViewPermissionsParams struct {
30 FilePath string `json:"file_path"`
31 Offset int `json:"offset"`
32 Limit int `json:"limit"`
33}
34
35type viewTool struct {
36 lspClients *csync.Map[string, *lsp.Client]
37 workingDir string
38 permissions permission.Service
39}
40
41type ViewResponseMetadata struct {
42 FilePath string `json:"file_path"`
43 Content string `json:"content"`
44}
45
46const (
47 ViewToolName = "view"
48 MaxReadSize = 250 * 1024
49 DefaultReadLimit = 2000
50 MaxLineLength = 2000
51)
52
53func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, workingDir string) BaseTool {
54 return &viewTool{
55 lspClients: lspClients,
56 workingDir: workingDir,
57 permissions: permissions,
58 }
59}
60
61func (v *viewTool) Name() string {
62 return ViewToolName
63}
64
65func (v *viewTool) Info() ToolInfo {
66 return ToolInfo{
67 Name: ViewToolName,
68 Description: string(viewDescription),
69 Parameters: map[string]any{
70 "file_path": map[string]any{
71 "type": "string",
72 "description": "The path to the file to read",
73 },
74 "offset": map[string]any{
75 "type": "integer",
76 "description": "The line number to start reading from (0-based)",
77 },
78 "limit": map[string]any{
79 "type": "integer",
80 "description": "The number of lines to read (defaults to 2000)",
81 },
82 },
83 Required: []string{"file_path"},
84 }
85}
86
87// Run implements Tool.
88func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
89 var params ViewParams
90 if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
91 return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
92 }
93
94 if params.FilePath == "" {
95 return NewTextErrorResponse("file_path is required"), nil
96 }
97
98 // Handle relative paths
99 filePath := params.FilePath
100 if !filepath.IsAbs(filePath) {
101 filePath = filepath.Join(v.workingDir, filePath)
102 }
103
104 // Check if file is outside working directory and request permission if needed
105 absWorkingDir, err := filepath.Abs(v.workingDir)
106 if err != nil {
107 return ToolResponse{}, fmt.Errorf("error resolving working directory: %w", err)
108 }
109
110 absFilePath, err := filepath.Abs(filePath)
111 if err != nil {
112 return ToolResponse{}, fmt.Errorf("error resolving file path: %w", err)
113 }
114
115 relPath, err := filepath.Rel(absWorkingDir, absFilePath)
116 if err != nil || strings.HasPrefix(relPath, "..") {
117 // File is outside working directory, request permission
118 sessionID, messageID := GetContextValues(ctx)
119 if sessionID == "" || messageID == "" {
120 return ToolResponse{}, fmt.Errorf("session ID and message ID are required for accessing files outside working directory")
121 }
122
123 granted := v.permissions.Request(
124 permission.CreatePermissionRequest{
125 SessionID: sessionID,
126 Path: absFilePath,
127 ToolCallID: call.ID,
128 ToolName: ViewToolName,
129 Action: "read",
130 Description: fmt.Sprintf("Read file outside working directory: %s", absFilePath),
131 Params: ViewPermissionsParams(params),
132 },
133 )
134
135 if !granted {
136 return ToolResponse{}, permission.ErrorPermissionDenied
137 }
138 }
139
140 // Check if file exists
141 fileInfo, err := os.Stat(filePath)
142 if err != nil {
143 if os.IsNotExist(err) {
144 // Try to offer suggestions for similarly named files
145 dir := filepath.Dir(filePath)
146 base := filepath.Base(filePath)
147
148 dirEntries, dirErr := os.ReadDir(dir)
149 if dirErr == nil {
150 var suggestions []string
151 for _, entry := range dirEntries {
152 if strings.Contains(strings.ToLower(entry.Name()), strings.ToLower(base)) ||
153 strings.Contains(strings.ToLower(base), strings.ToLower(entry.Name())) {
154 suggestions = append(suggestions, filepath.Join(dir, entry.Name()))
155 if len(suggestions) >= 3 {
156 break
157 }
158 }
159 }
160
161 if len(suggestions) > 0 {
162 return NewTextErrorResponse(fmt.Sprintf("File not found: %s\n\nDid you mean one of these?\n%s",
163 filePath, strings.Join(suggestions, "\n"))), nil
164 }
165 }
166
167 return NewTextErrorResponse(fmt.Sprintf("File not found: %s", filePath)), nil
168 }
169 return ToolResponse{}, fmt.Errorf("error accessing file: %w", err)
170 }
171
172 // Check if it's a directory
173 if fileInfo.IsDir() {
174 return NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
175 }
176
177 // Check file size
178 if fileInfo.Size() > MaxReadSize {
179 return NewTextErrorResponse(fmt.Sprintf("File is too large (%d bytes). Maximum size is %d bytes",
180 fileInfo.Size(), MaxReadSize)), nil
181 }
182
183 // Set default limit if not provided
184 if params.Limit <= 0 {
185 params.Limit = DefaultReadLimit
186 }
187
188 // Check if it's an image file
189 isImage, imageType := isImageFile(filePath)
190 // TODO: handle images
191 if isImage {
192 return NewTextErrorResponse(fmt.Sprintf("This is an image file of type: %s\n", imageType)), nil
193 }
194
195 // Read the file content
196 content, lineCount, err := readTextFile(filePath, params.Offset, params.Limit)
197 isValidUt8 := utf8.ValidString(content)
198 if !isValidUt8 {
199 return NewTextErrorResponse("File content is not valid UTF-8"), nil
200 }
201 if err != nil {
202 return ToolResponse{}, fmt.Errorf("error reading file: %w", err)
203 }
204
205 notifyLSPs(ctx, v.lspClients, filePath)
206 output := "<file>\n"
207 // Format the output with line numbers
208 output += addLineNumbers(content, params.Offset+1)
209
210 // Add a note if the content was truncated
211 if lineCount > params.Offset+len(strings.Split(content, "\n")) {
212 output += fmt.Sprintf("\n\n(File has more lines. Use 'offset' parameter to read beyond line %d)",
213 params.Offset+len(strings.Split(content, "\n")))
214 }
215 output += "\n</file>\n"
216 output += getDiagnostics(filePath, v.lspClients)
217 recordFileRead(filePath)
218 return WithResponseMetadata(
219 NewTextResponse(output),
220 ViewResponseMetadata{
221 FilePath: filePath,
222 Content: content,
223 },
224 ), nil
225}
226
227func addLineNumbers(content string, startLine int) string {
228 if content == "" {
229 return ""
230 }
231
232 lines := strings.Split(content, "\n")
233
234 var result []string
235 for i, line := range lines {
236 line = strings.TrimSuffix(line, "\r")
237
238 lineNum := i + startLine
239 numStr := fmt.Sprintf("%d", lineNum)
240
241 if len(numStr) >= 6 {
242 result = append(result, fmt.Sprintf("%s|%s", numStr, line))
243 } else {
244 paddedNum := fmt.Sprintf("%6s", numStr)
245 result = append(result, fmt.Sprintf("%s|%s", paddedNum, line))
246 }
247 }
248
249 return strings.Join(result, "\n")
250}
251
252func readTextFile(filePath string, offset, limit int) (string, int, error) {
253 file, err := os.Open(filePath)
254 if err != nil {
255 return "", 0, err
256 }
257 defer file.Close()
258
259 lineCount := 0
260
261 scanner := NewLineScanner(file)
262 if offset > 0 {
263 for lineCount < offset && scanner.Scan() {
264 lineCount++
265 }
266 if err = scanner.Err(); err != nil {
267 return "", 0, err
268 }
269 }
270
271 if offset == 0 {
272 _, err = file.Seek(0, io.SeekStart)
273 if err != nil {
274 return "", 0, err
275 }
276 }
277
278 // Pre-allocate slice with expected capacity
279 lines := make([]string, 0, limit)
280 lineCount = offset
281
282 for scanner.Scan() && len(lines) < limit {
283 lineCount++
284 lineText := scanner.Text()
285 if len(lineText) > MaxLineLength {
286 lineText = lineText[:MaxLineLength] + "..."
287 }
288 lines = append(lines, lineText)
289 }
290
291 // Continue scanning to get total line count
292 for scanner.Scan() {
293 lineCount++
294 }
295
296 if err := scanner.Err(); err != nil {
297 return "", 0, err
298 }
299
300 return strings.Join(lines, "\n"), lineCount, nil
301}
302
303func isImageFile(filePath string) (bool, string) {
304 ext := strings.ToLower(filepath.Ext(filePath))
305 switch ext {
306 case ".jpg", ".jpeg":
307 return true, "JPEG"
308 case ".png":
309 return true, "PNG"
310 case ".gif":
311 return true, "GIF"
312 case ".bmp":
313 return true, "BMP"
314 case ".svg":
315 return true, "SVG"
316 case ".webp":
317 return true, "WebP"
318 default:
319 return false, ""
320 }
321}
322
323type LineScanner struct {
324 scanner *bufio.Scanner
325}
326
327func NewLineScanner(r io.Reader) *LineScanner {
328 return &LineScanner{
329 scanner: bufio.NewScanner(r),
330 }
331}
332
333func (s *LineScanner) Scan() bool {
334 return s.scanner.Scan()
335}
336
337func (s *LineScanner) Text() string {
338 return s.scanner.Text()
339}
340
341func (s *LineScanner) Err() error {
342 return s.scanner.Err()
343}