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