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