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
 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), &params); 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}