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