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/config"
 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}
 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) BaseTool {
 75	return &viewTool{
 76		lspClients,
 77	}
 78}
 79
 80func (v *viewTool) Name() string {
 81	return ViewToolName
 82}
 83
 84func (v *viewTool) Info() ToolInfo {
 85	return ToolInfo{
 86		Name:        ViewToolName,
 87		Description: viewDescription,
 88		Parameters: map[string]any{
 89			"file_path": map[string]any{
 90				"type":        "string",
 91				"description": "The path to the file to read",
 92			},
 93			"offset": map[string]any{
 94				"type":        "integer",
 95				"description": "The line number to start reading from (0-based)",
 96			},
 97			"limit": map[string]any{
 98				"type":        "integer",
 99				"description": "The number of lines to read (defaults to 2000)",
100			},
101		},
102		Required: []string{"file_path"},
103	}
104}
105
106// Run implements Tool.
107func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
108	var params ViewParams
109	if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
110		return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
111	}
112
113	if params.FilePath == "" {
114		return NewTextErrorResponse("file_path is required"), nil
115	}
116
117	// Handle relative paths
118	filePath := params.FilePath
119	if !filepath.IsAbs(filePath) {
120		filePath = filepath.Join(config.WorkingDirectory(), filePath)
121	}
122
123	// Check if file exists
124	fileInfo, err := os.Stat(filePath)
125	if err != nil {
126		if os.IsNotExist(err) {
127			// Try to offer suggestions for similarly named files
128			dir := filepath.Dir(filePath)
129			base := filepath.Base(filePath)
130
131			dirEntries, dirErr := os.ReadDir(dir)
132			if dirErr == nil {
133				var suggestions []string
134				for _, entry := range dirEntries {
135					if strings.Contains(strings.ToLower(entry.Name()), strings.ToLower(base)) ||
136						strings.Contains(strings.ToLower(base), strings.ToLower(entry.Name())) {
137						suggestions = append(suggestions, filepath.Join(dir, entry.Name()))
138						if len(suggestions) >= 3 {
139							break
140						}
141					}
142				}
143
144				if len(suggestions) > 0 {
145					return NewTextErrorResponse(fmt.Sprintf("File not found: %s\n\nDid you mean one of these?\n%s",
146						filePath, strings.Join(suggestions, "\n"))), nil
147				}
148			}
149
150			return NewTextErrorResponse(fmt.Sprintf("File not found: %s", filePath)), nil
151		}
152		return ToolResponse{}, fmt.Errorf("error accessing file: %w", err)
153	}
154
155	// Check if it's a directory
156	if fileInfo.IsDir() {
157		return NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
158	}
159
160	// Check file size
161	if fileInfo.Size() > MaxReadSize {
162		return NewTextErrorResponse(fmt.Sprintf("File is too large (%d bytes). Maximum size is %d bytes",
163			fileInfo.Size(), MaxReadSize)), nil
164	}
165
166	// Set default limit if not provided
167	if params.Limit <= 0 {
168		params.Limit = DefaultReadLimit
169	}
170
171	// Check if it's an image file
172	isImage, imageType := isImageFile(filePath)
173	// TODO: handle images
174	if isImage {
175		return NewTextErrorResponse(fmt.Sprintf("This is an image file of type: %s\nUse a different tool to process images", imageType)), nil
176	}
177
178	// Read the file content
179	content, lineCount, err := readTextFile(filePath, params.Offset, params.Limit)
180	if err != nil {
181		return ToolResponse{}, fmt.Errorf("error reading file: %w", err)
182	}
183
184	notifyLspOpenFile(ctx, filePath, v.lspClients)
185	output := "<file>\n"
186	// Format the output with line numbers
187	output += addLineNumbers(content, params.Offset+1)
188
189	// Add a note if the content was truncated
190	if lineCount > params.Offset+len(strings.Split(content, "\n")) {
191		output += fmt.Sprintf("\n\n(File has more lines. Use 'offset' parameter to read beyond line %d)",
192			params.Offset+len(strings.Split(content, "\n")))
193	}
194	output += "\n</file>\n"
195	output += getDiagnostics(filePath, v.lspClients)
196	recordFileRead(filePath)
197	return WithResponseMetadata(
198		NewTextResponse(output),
199		ViewResponseMetadata{
200			FilePath: filePath,
201			Content:  content,
202		},
203	), nil
204}
205
206func addLineNumbers(content string, startLine int) string {
207	if content == "" {
208		return ""
209	}
210
211	lines := strings.Split(content, "\n")
212
213	var result []string
214	for i, line := range lines {
215		line = strings.TrimSuffix(line, "\r")
216
217		lineNum := i + startLine
218		numStr := fmt.Sprintf("%d", lineNum)
219
220		if len(numStr) >= 6 {
221			result = append(result, fmt.Sprintf("%s|%s", numStr, line))
222		} else {
223			paddedNum := fmt.Sprintf("%6s", numStr)
224			result = append(result, fmt.Sprintf("%s|%s", paddedNum, line))
225		}
226	}
227
228	return strings.Join(result, "\n")
229}
230
231func readTextFile(filePath string, offset, limit int) (string, int, error) {
232	file, err := os.Open(filePath)
233	if err != nil {
234		return "", 0, err
235	}
236	defer file.Close()
237
238	lineCount := 0
239
240	scanner := NewLineScanner(file)
241	if offset > 0 {
242		for lineCount < offset && scanner.Scan() {
243			lineCount++
244		}
245		if err = scanner.Err(); err != nil {
246			return "", 0, err
247		}
248	}
249
250	if offset == 0 {
251		_, err = file.Seek(0, io.SeekStart)
252		if err != nil {
253			return "", 0, err
254		}
255	}
256
257	var lines []string
258	lineCount = offset
259
260	for scanner.Scan() && len(lines) < limit {
261		lineCount++
262		lineText := scanner.Text()
263		if len(lineText) > MaxLineLength {
264			lineText = lineText[:MaxLineLength] + "..."
265		}
266		lines = append(lines, lineText)
267	}
268
269	// Continue scanning to get total line count
270	for scanner.Scan() {
271		lineCount++
272	}
273
274	if err := scanner.Err(); err != nil {
275		return "", 0, err
276	}
277
278	return strings.Join(lines, "\n"), lineCount, nil
279}
280
281func isImageFile(filePath string) (bool, string) {
282	ext := strings.ToLower(filepath.Ext(filePath))
283	switch ext {
284	case ".jpg", ".jpeg":
285		return true, "JPEG"
286	case ".png":
287		return true, "PNG"
288	case ".gif":
289		return true, "GIF"
290	case ".bmp":
291		return true, "BMP"
292	case ".svg":
293		return true, "SVG"
294	case ".webp":
295		return true, "WebP"
296	default:
297		return false, ""
298	}
299}
300
301type LineScanner struct {
302	scanner *bufio.Scanner
303}
304
305func NewLineScanner(r io.Reader) *LineScanner {
306	return &LineScanner{
307		scanner: bufio.NewScanner(r),
308	}
309}
310
311func (s *LineScanner) Scan() bool {
312	return s.scanner.Scan()
313}
314
315func (s *LineScanner) Text() string {
316	return s.scanner.Text()
317}
318
319func (s *LineScanner) Err() error {
320	return s.scanner.Err()
321}