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