返回 DeepSeek-Reasonix
readfile.go
根目录 / internal / tool / builtin / readfile.go
1 // Package builtin provides Reasonix's compile-time built-in tools. Each tool
2 // self-registers via init(); main blank-imports this package to wire them in.
3 package builtin
4
5 import (
6 "bufio"
7 "bytes"
8 "context"
9 "encoding/json"
10 "fmt"
11 "io"
12 "os"
13 "path/filepath"
14 "strings"
15
16 "golang.org/x/text/transform"
17
18 fileenc "reasonix/internal/fileutil/encoding"
19 "reasonix/internal/tool"
20 )
21
22 const (
23 readFileBinaryPeek = 8 * 1024 // bytes scanned for NUL before reading further
24 readFileDetectSample = 256 * 1024 // bytes sampled for encoding detection before streaming
25 )
26
27 func init() { tool.RegisterBuiltin(readFile{}) }
28
29 // readFile reads a text file. workDir, when non-empty, is the directory a
30 // relative path is resolved against (see resolveIn). paths maps session-scoped
31 // external read aliases to local roots without changing the model-visible tool
32 // schema. forbidRoots lists directories the tool may not read from (resolved,
33 // absolute paths).
34 type readFile struct {
35 workDir string
36 paths *PathResolver
37 forbidRoots []string
38 // overlay, when non-nil, serves content from the host transport (unsaved
39 // editor buffers) before falling back to disk. Consulted only after path
40 // resolution and read confinement, and never for external alias paths.
41 overlay FileOverlay
42 }
43
44 const (
45 readFileDefaultLimit = 2000 // lines returned when limit is unset
46 )
47
48 func (readFile) Name() string { return "read_file" }
49
50 func (readFile) Description() string {
51 return "Read a text file with optional line offset/limit. Output prefixes each line with its 1-based number (e.g. ` 42→...`) so subsequent edit_file calls can target exact lines. Use `offset` and `limit` to page through large files; the tool reports total length and pagination hints in a trailer."
52 }
53
54 func (readFile) Schema() json.RawMessage {
55 return json.RawMessage(`{
56 "type":"object",
57 "properties":{
58 "path":{"type":"string","description":"File path"},
59 "offset":{"type":"integer","description":"0-based line offset to start reading from (default 0)","minimum":0},
60 "limit":{"type":"integer","description":"Maximum lines to return (default 2000)","minimum":1}
61 },
62 "required":["path"]
63 }`)
64 }
65
66 func (readFile) ReadOnly() bool { return true }
67
68 // SnipHint front-loads file content: the most relevant lines are near the top,
69 // so keep a generous head and a short tail when an old read is shortened.
70 func (readFile) SnipHint() tool.SnipHint {
71 return tool.SnipHint{Head: 120, Tail: 12, HeadChars: 12000, TailChars: 2000}
72 }
73
74 func (r readFile) Execute(ctx context.Context, args json.RawMessage) (string, error) {
75 var p struct {
76 Path string `json:"path"`
77 Offset int `json:"offset,omitempty"`
78 Limit int `json:"limit,omitempty"`
79 }
80 if err := json.Unmarshal(args, &p); err != nil {
81 return "", fmt.Errorf("invalid args: %w", err)
82 }
83 if p.Path == "" {
84 return "", fmt.Errorf("path is required")
85 }
86 rp := resolveReadablePath(r.workDir, p.Path, r.paths)
87 p.Path = rp.Path
88 displayPath := rp.DisplayPath
89 if confineRead(r.forbidRoots, p.Path) {
90 err := &os.PathError{Op: "open", Path: p.Path, Err: os.ErrNotExist}
91 if rp.External {
92 return "", fmt.Errorf("read %s: %s", displayPath, rp.ErrorText(err))
93 }
94 return "", err
95 }
96 if p.Offset < 0 {
97 p.Offset = 0
98 }
99 if p.Limit <= 0 {
100 p.Limit = readFileDefaultLimit
101 }
102
103 // The host overlay (unsaved editor buffers) wins over the disk when it can
104 // serve the path. Content arrives already decoded as text, so the encoding
105 // and binary-detection pipeline below applies to the disk fallback only.
106 if r.overlay != nil && !rp.External && filepath.IsAbs(p.Path) {
107 if content, ok := r.overlay.ReadTextFile(ctx, p.Path); ok {
108 return r.scan(strings.NewReader(content), p.Offset, p.Limit)
109 }
110 }
111
112 // A directory can be os.Open'd but not read as text — catch it up front with
113 // an actionable message (and avoid the doubled "read X: read X:" the scanner's
114 // error would otherwise produce) so the model switches to the ls tool.
115 if info, err := os.Stat(p.Path); err == nil && info.IsDir() {
116 return "", fmt.Errorf("%s is a directory, not a file — use the ls tool to list it, or read a specific file inside it", displayPath)
117 }
118
119 f, err := os.Open(p.Path)
120 if err != nil {
121 if rp.External {
122 return "", fmt.Errorf("read %s: %s", displayPath, rp.ErrorText(err))
123 }
124 return "", fmt.Errorf("read %s: %w", displayPath, err)
125 }
126 defer f.Close()
127
128 // Peek the first 8 KiB to reject binary files cheaply (a NUL byte) before
129 // reading further — keeps a multi-GB archive from being slurped just to be
130 // discarded.
131 peek := make([]byte, readFileBinaryPeek)
132 pn, perr := io.ReadFull(f, peek)
133 peek = peek[:pn]
134 peekEOF := perr != nil // whole file fit in the peek (EOF / ErrUnexpectedEOF)
135
136 // BOM check first: UTF-16 files contain 0x00 for every ASCII character, so a
137 // naive NUL check would misidentify them as binary.
138 switch fileenc.DetectQuick(peek) {
139 case fileenc.UTF16LE, fileenc.UTF16BE:
140 // UTF-16 is not self-synchronising and can't be streamed line-by-line, so
141 // buffer it fully (these files are rare and usually small).
142 rest, rerr := io.ReadAll(f)
143 if rerr != nil {
144 if rp.External {
145 return "", fmt.Errorf("read %s: %s", displayPath, rp.ErrorText(rerr))
146 }
147 return "", fmt.Errorf("read %s: %w", displayPath, rerr)
148 }
149 all := append(peek, rest...)
150 bom := fileenc.DetectQuick(all)
151 return r.scan(bytes.NewReader(fileenc.Decode(all, bom)), p.Offset, p.Limit)
152 case fileenc.UTF8BOM:
153 // Strip the 3-byte BOM; the content is valid UTF-8 and streams directly.
154 body := peek
155 if len(body) >= 3 {
156 body = body[3:]
157 }
158 return r.scan(io.MultiReader(bytes.NewReader(body), f), p.Offset, p.Limit)
159 }
160
161 // BOM-less UTF-16 (Windows source files) has a NUL for every ASCII char but
162 // no BOM, so it reaches here; recognise it by its NUL pattern and decode it
163 // rather than rejecting it as binary.
164 if k, ok := fileenc.DetectUTF16NoBOM(peek); ok {
165 rest, rerr := io.ReadAll(f)
166 if rerr != nil {
167 if rp.External {
168 return "", fmt.Errorf("read %s: %s", displayPath, rp.ErrorText(rerr))
169 }
170 return "", fmt.Errorf("read %s: %w", displayPath, rerr)
171 }
172 all := append(peek, rest...)
173 return r.scan(bytes.NewReader(fileenc.Decode(all, k)), p.Offset, p.Limit)
174 }
175
176 if bytes.IndexByte(peek, 0) >= 0 {
177 if rp.External {
178 return "", fmt.Errorf("binary file %s (NUL byte detected); not shown by read_file", displayPath)
179 }
180 return "", fmt.Errorf("binary file %s (NUL byte detected); use `bash hexdump` or another tool", displayPath)
181 }
182
183 // Read up to a bounded sample for encoding detection, then stream the rest —
184 // so a large text file isn't slurped whole just to return a few lines.
185 head := peek
186 if !peekEOF {
187 more := make([]byte, readFileDetectSample-len(peek))
188 mn, merr := io.ReadFull(f, more)
189 head = append(peek, more[:mn]...)
190 peekEOF = merr != nil
191 }
192
193 // Detect from a char-safe slice: when more file follows, trim to the last
194 // newline so the sample never ends mid multi-byte sequence (UTF-8 and GB18030
195 // are ASCII-transparent, so '\n' is always a clean boundary).
196 sample := head
197 if !peekEOF {
198 if i := bytes.LastIndexByte(head, '\n'); i >= 0 {
199 sample = head[:i+1]
200 }
201 }
202 enc, _ := fileenc.Detect(sample)
203
204 src := io.MultiReader(bytes.NewReader(head), f)
205 if dec := fileenc.Decoder(enc); dec != nil {
206 return r.scan(transform.NewReader(src, dec), p.Offset, p.Limit)
207 }
208 return r.scan(src, p.Offset, p.Limit)
209 }
210
211 // scan reads lines from src and returns the formatted output with line numbers.
212 func (r readFile) scan(src io.Reader, offset, limit int) (string, error) {
213 scanner := bufio.NewScanner(src)
214 scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
215
216 var collected []string
217 lineNo := 0
218 hasMore := false
219 for scanner.Scan() {
220 lineNo++
221 if lineNo <= offset {
222 continue
223 }
224 if len(collected) < limit {
225 collected = append(collected, scanner.Text())
226 continue
227 }
228 // A line past the requested window exists — stop here rather than reading
229 // the rest of the file just to count the remainder.
230 hasMore = true
231 break
232 }
233 if err := scanner.Err(); err != nil {
234 return "", fmt.Errorf("scan: %w", err)
235 }
236
237 if lineNo == 0 {
238 return "(empty file)", nil
239 }
240 if len(collected) == 0 {
241 return fmt.Sprintf("(offset %d is past EOF — file has %d lines)", offset, lineNo), nil
242 }
243
244 maxShown := offset + len(collected)
245 w := len(fmt.Sprint(maxShown))
246
247 var b strings.Builder
248 for i, line := range collected {
249 fmt.Fprintf(&b, "%*d→%s\n", w, offset+i+1, line)
250 }
251 if hasMore {
252 fmt.Fprintf(&b, "\n[more lines below; pass offset=%d to continue]\n", offset+len(collected))
253 }
254 return b.String(), nil
255 }
256
256 lines GO