1package tools
  2
  3import (
  4	"bufio"
  5	"context"
  6	_ "embed"
  7	"encoding/json"
  8	"fmt"
  9	"io"
 10	"os"
 11	"path/filepath"
 12	"strings"
 13	"unicode/utf8"
 14
 15	"github.com/charmbracelet/crush/internal/csync"
 16	"github.com/charmbracelet/crush/internal/lsp"
 17	"github.com/charmbracelet/crush/internal/permission"
 18	"github.com/charmbracelet/crush/internal/proto"
 19)
 20
 21//go:embed view.md
 22var viewDescription []byte
 23
 24type (
 25	ViewParams            = proto.ViewParams
 26	ViewPermissionsParams = proto.ViewPermissionsParams
 27	ViewResponseMetadata  = proto.ViewResponseMetadata
 28)
 29
 30type viewTool struct {
 31	lspClients  *csync.Map[string, *lsp.Client]
 32	workingDir  string
 33	permissions permission.Service
 34}
 35
 36const (
 37	ViewToolName     = proto.ViewToolName
 38	MaxReadSize      = 250 * 1024
 39	DefaultReadLimit = 2000
 40	MaxLineLength    = 2000
 41)
 42
 43func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, workingDir string) BaseTool {
 44	return &viewTool{
 45		lspClients:  lspClients,
 46		workingDir:  workingDir,
 47		permissions: permissions,
 48	}
 49}
 50
 51func (v *viewTool) Name() string {
 52	return ViewToolName
 53}
 54
 55func (v *viewTool) Info() ToolInfo {
 56	return ToolInfo{
 57		Name:        ViewToolName,
 58		Description: string(viewDescription),
 59		Parameters: map[string]any{
 60			"file_path": map[string]any{
 61				"type":        "string",
 62				"description": "The path to the file to read",
 63			},
 64			"offset": map[string]any{
 65				"type":        "integer",
 66				"description": "The line number to start reading from (0-based)",
 67			},
 68			"limit": map[string]any{
 69				"type":        "integer",
 70				"description": "The number of lines to read (defaults to 2000)",
 71			},
 72		},
 73		Required: []string{"file_path"},
 74	}
 75}
 76
 77// Run implements Tool.
 78func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
 79	var params ViewParams
 80	if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
 81		return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
 82	}
 83
 84	if params.FilePath == "" {
 85		return NewTextErrorResponse("file_path is required"), nil
 86	}
 87
 88	// Handle relative paths
 89	filePath := params.FilePath
 90	if !filepath.IsAbs(filePath) {
 91		filePath = filepath.Join(v.workingDir, filePath)
 92	}
 93
 94	// Check if file is outside working directory and request permission if needed
 95	absWorkingDir, err := filepath.Abs(v.workingDir)
 96	if err != nil {
 97		return ToolResponse{}, fmt.Errorf("error resolving working directory: %w", err)
 98	}
 99
100	absFilePath, err := filepath.Abs(filePath)
101	if err != nil {
102		return ToolResponse{}, fmt.Errorf("error resolving file path: %w", err)
103	}
104
105	relPath, err := filepath.Rel(absWorkingDir, absFilePath)
106	if err != nil || strings.HasPrefix(relPath, "..") {
107		// File is outside working directory, request permission
108		sessionID, messageID := GetContextValues(ctx)
109		if sessionID == "" || messageID == "" {
110			return ToolResponse{}, fmt.Errorf("session ID and message ID are required for accessing files outside working directory")
111		}
112
113		granted := v.permissions.Request(
114			permission.CreatePermissionRequest{
115				SessionID:   sessionID,
116				Path:        absFilePath,
117				ToolCallID:  call.ID,
118				ToolName:    ViewToolName,
119				Action:      "read",
120				Description: fmt.Sprintf("Read file outside working directory: %s", absFilePath),
121				Params:      ViewPermissionsParams(params),
122			},
123		)
124
125		if !granted {
126			return ToolResponse{}, permission.ErrorPermissionDenied
127		}
128	}
129
130	// Check if file exists
131	fileInfo, err := os.Stat(filePath)
132	if err != nil {
133		if os.IsNotExist(err) {
134			// Try to offer suggestions for similarly named files
135			dir := filepath.Dir(filePath)
136			base := filepath.Base(filePath)
137
138			dirEntries, dirErr := os.ReadDir(dir)
139			if dirErr == nil {
140				var suggestions []string
141				for _, entry := range dirEntries {
142					if strings.Contains(strings.ToLower(entry.Name()), strings.ToLower(base)) ||
143						strings.Contains(strings.ToLower(base), strings.ToLower(entry.Name())) {
144						suggestions = append(suggestions, filepath.Join(dir, entry.Name()))
145						if len(suggestions) >= 3 {
146							break
147						}
148					}
149				}
150
151				if len(suggestions) > 0 {
152					return NewTextErrorResponse(fmt.Sprintf("File not found: %s\n\nDid you mean one of these?\n%s",
153						filePath, strings.Join(suggestions, "\n"))), nil
154				}
155			}
156
157			return NewTextErrorResponse(fmt.Sprintf("File not found: %s", filePath)), nil
158		}
159		return ToolResponse{}, fmt.Errorf("error accessing file: %w", err)
160	}
161
162	// Check if it's a directory
163	if fileInfo.IsDir() {
164		return NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
165	}
166
167	// Check file size
168	if fileInfo.Size() > MaxReadSize {
169		return NewTextErrorResponse(fmt.Sprintf("File is too large (%d bytes). Maximum size is %d bytes",
170			fileInfo.Size(), MaxReadSize)), nil
171	}
172
173	// Set default limit if not provided
174	if params.Limit <= 0 {
175		params.Limit = DefaultReadLimit
176	}
177
178	// Check if it's an image file
179	isImage, imageType := isImageFile(filePath)
180	// TODO: handle images
181	if isImage {
182		return NewTextErrorResponse(fmt.Sprintf("This is an image file of type: %s\n", imageType)), nil
183	}
184
185	// Read the file content
186	content, lineCount, err := readTextFile(filePath, params.Offset, params.Limit)
187	isValidUt8 := utf8.ValidString(content)
188	if !isValidUt8 {
189		return NewTextErrorResponse("File content is not valid UTF-8"), nil
190	}
191	if err != nil {
192		return ToolResponse{}, fmt.Errorf("error reading file: %w", err)
193	}
194
195	notifyLSPs(ctx, v.lspClients, filePath)
196	output := "<file>\n"
197	// Format the output with line numbers
198	output += addLineNumbers(content, params.Offset+1)
199
200	// Add a note if the content was truncated
201	if lineCount > params.Offset+len(strings.Split(content, "\n")) {
202		output += fmt.Sprintf("\n\n(File has more lines. Use 'offset' parameter to read beyond line %d)",
203			params.Offset+len(strings.Split(content, "\n")))
204	}
205	output += "\n</file>\n"
206	output += getDiagnostics(filePath, v.lspClients)
207	recordFileRead(filePath)
208	return WithResponseMetadata(
209		NewTextResponse(output),
210		ViewResponseMetadata{
211			FilePath: filePath,
212			Content:  content,
213		},
214	), nil
215}
216
217func addLineNumbers(content string, startLine int) string {
218	if content == "" {
219		return ""
220	}
221
222	lines := strings.Split(content, "\n")
223
224	var result []string
225	for i, line := range lines {
226		line = strings.TrimSuffix(line, "\r")
227
228		lineNum := i + startLine
229		numStr := fmt.Sprintf("%d", lineNum)
230
231		if len(numStr) >= 6 {
232			result = append(result, fmt.Sprintf("%s|%s", numStr, line))
233		} else {
234			paddedNum := fmt.Sprintf("%6s", numStr)
235			result = append(result, fmt.Sprintf("%s|%s", paddedNum, line))
236		}
237	}
238
239	return strings.Join(result, "\n")
240}
241
242func readTextFile(filePath string, offset, limit int) (string, int, error) {
243	file, err := os.Open(filePath)
244	if err != nil {
245		return "", 0, err
246	}
247	defer file.Close()
248
249	lineCount := 0
250
251	scanner := NewLineScanner(file)
252	if offset > 0 {
253		for lineCount < offset && scanner.Scan() {
254			lineCount++
255		}
256		if err = scanner.Err(); err != nil {
257			return "", 0, err
258		}
259	}
260
261	if offset == 0 {
262		_, err = file.Seek(0, io.SeekStart)
263		if err != nil {
264			return "", 0, err
265		}
266	}
267
268	// Pre-allocate slice with expected capacity
269	lines := make([]string, 0, limit)
270	lineCount = offset
271
272	for scanner.Scan() && len(lines) < limit {
273		lineCount++
274		lineText := scanner.Text()
275		if len(lineText) > MaxLineLength {
276			lineText = lineText[:MaxLineLength] + "..."
277		}
278		lines = append(lines, lineText)
279	}
280
281	// Continue scanning to get total line count
282	for scanner.Scan() {
283		lineCount++
284	}
285
286	if err := scanner.Err(); err != nil {
287		return "", 0, err
288	}
289
290	return strings.Join(lines, "\n"), lineCount, nil
291}
292
293func isImageFile(filePath string) (bool, string) {
294	ext := strings.ToLower(filepath.Ext(filePath))
295	switch ext {
296	case ".jpg", ".jpeg":
297		return true, "JPEG"
298	case ".png":
299		return true, "PNG"
300	case ".gif":
301		return true, "GIF"
302	case ".bmp":
303		return true, "BMP"
304	case ".svg":
305		return true, "SVG"
306	case ".webp":
307		return true, "WebP"
308	default:
309		return false, ""
310	}
311}
312
313type LineScanner struct {
314	scanner *bufio.Scanner
315}
316
317func NewLineScanner(r io.Reader) *LineScanner {
318	return &LineScanner{
319		scanner: bufio.NewScanner(r),
320	}
321}
322
323func (s *LineScanner) Scan() bool {
324	return s.scanner.Scan()
325}
326
327func (s *LineScanner) Text() string {
328	return s.scanner.Text()
329}
330
331func (s *LineScanner) Err() error {
332	return s.scanner.Err()
333}