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