view.go

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