| 💾 Saved. d722711 k33g 7h ago | 1 | package fileedit |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "strings" |
| 6 | ) |
| 7 | |
| 8 | // Result is what a write or a replace reports — the fields the `edit` CLI |
| 9 | // prints and the Pi tool returns: a headline, the readable diff, the unified |
| 10 | // patch, the first changed line. Headline + Diff is what goes back to the |
| 11 | // model; the caller decides what the screen shows. |
| 12 | type Result struct { |
| 13 | Path string |
| 14 | Created bool |
| 15 | Changed bool |
| 16 | DryRun bool |
| 17 | Edits int |
| 18 | Added, Deleted int |
| 19 | FirstChangedLine int |
| 20 | Headline string |
| 21 | Diff string |
| 22 | Patch string |
| 23 | } |
| 24 | |
| 25 | // Read returns the file, or a slice of it (1-based, inclusive; 0 = from the |
| 26 | // start / to the end). It is the mandatory companion of Replace: an exact |
| 27 | // replacement assumes the exact text was READ, not remembered. |
| 28 | func Read(path string, start, end int, numbered bool) (string, error) { |
| 29 | f, err := load(path) |
| 30 | if err != nil { |
| 31 | return "", fmt.Errorf("cannot read %s: %v", path, err) |
| 32 | } |
| 33 | if f.isNew { |
| 34 | return "", fmt.Errorf("%s does not exist. Create it with write_file", path) |
| 35 | } |
| 36 | |
| 37 | lines, finalNewline := splitLines(f.body) |
| 38 | from, to, err := bounds(start, end, len(lines)) |
| 39 | if err != nil { |
| 40 | return "", fmt.Errorf("%s: %v", path, err) |
| 41 | } |
| 42 | slice := lines[from-1 : to] |
| 43 | |
| 44 | if numbered { |
| 45 | var b strings.Builder |
| 46 | for i, l := range slice { |
| 47 | fmt.Fprintf(&b, "%6d %s\n", from+i, l) |
| 48 | } |
| 49 | return b.String(), nil |
| 50 | } |
| 51 | body := strings.Join(slice, "\n") |
| 52 | // The final newline is only put back if it existed AND the end of the file |
| 53 | // is shown: otherwise a slice would invent a line the file does not have. |
| 54 | if finalNewline || to < len(lines) { |
| 55 | body += "\n" |
| 56 | } |
| 57 | return body, nil |
| 58 | } |
| 59 | |
| 60 | // bounds turns start/end into a valid slice, or says why it is not one. |
| 61 | func bounds(start, end, total int) (int, int, error) { |
| 62 | if total == 0 { |
| 63 | return 1, 0, nil // empty file: nothing to show, not an error |
| 64 | } |
| 65 | if start == 0 { |
| 66 | start = 1 |
| 67 | } |
| 68 | if end == 0 { |
| 69 | end = total |
| 70 | } |
| 71 | if start < 1 || end < 1 { |
| 72 | return 0, 0, fmt.Errorf("start and end are 1-based line numbers") |
| 73 | } |
| 74 | if start > total { |
| 75 | return 0, 0, fmt.Errorf("start %d is past the end of the file (%d lines)", start, total) |
| 76 | } |
| 77 | if end > total { |
| 78 | end = total |
| 79 | } |
| 80 | if start > end { |
| 81 | return 0, 0, fmt.Errorf("start %d comes after end %d", start, end) |
| 82 | } |
| 83 | return start, end, nil |
| 84 | } |
| 85 | |
| 86 | // Write writes the WHOLE file. The tool for a new file or a deliberate |
| 87 | // rewrite; to change three lines of an existing file, Replace is safer — it |
| 88 | // fails when the targeted text is not what one thought, where Write overwrites |
| 89 | // without checking. |
| 90 | func Write(path, content string) (Result, error) { |
| 91 | f, err := load(path) |
| 92 | if err != nil { |
| 93 | return Result{}, fmt.Errorf("cannot read %s: %v", path, err) |
| 94 | } |
| 95 | // A content without a final newline would give a file without one: on a |
| 96 | // text file that is almost always an accident, and every diff tool then |
| 97 | // flags it. |
| 98 | if content != "" && !strings.HasSuffix(content, "\n") { |
| 99 | content += "\n" |
| 100 | } |
| 101 | return finish(Result{Path: path, Created: f.isNew}, f, content) |
| 102 | } |
| 103 | |
| 104 | // Replace applies exact replacements to an existing file — the operation that |
| 105 | // matters: it changes a file without rewriting it, and refuses anything |
| 106 | // ambiguous. With dryRun the diff is computed and nothing is written. |
| 107 | func Replace(path string, edits []Edit, dryRun bool) (Result, error) { |
| 108 | f, err := load(path) |
| 109 | if err != nil { |
| 110 | return Result{}, fmt.Errorf("cannot read %s: %v", path, err) |
| 111 | } |
| 112 | if f.isNew { |
| 113 | return Result{}, fmt.Errorf("%s does not exist — nothing to replace. Create it with write_file", path) |
| 114 | } |
| 115 | body, err := apply(f.body, edits) |
| 116 | if err != nil { |
| 117 | // The error carries the file name: it is read out of context, in a |
| 118 | // tool result among others. |
| 119 | return Result{}, fmt.Errorf("%s: %v", path, err) |
| 120 | } |
| 121 | return finish(Result{Path: path, Edits: len(edits), DryRun: dryRun}, f, body) |
| 122 | } |
| 123 | |
| 124 | // finish compares, writes if needed, and reports. Write and Replace both end |
| 125 | // here: that is what gives them exactly the same output, hence one format for |
| 126 | // the model to learn. |
| 127 | func finish(r Result, f *file, body string) (Result, error) { |
| 128 | c := compare(f.body, body) |
| 129 | r.Changed = c.changed() |
| 130 | r.Added, r.Deleted = c.stat() |
| 131 | r.FirstChangedLine = c.firstChanged() |
| 132 | r.Diff = c.render() |
| 133 | r.Patch = c.unified(r.Path) |
| 134 | |
| 135 | // Nothing to write: SAY so rather than touch the file. An identical |
| 136 | // rewrite would bump the modification time and wake every watcher, for |
| 137 | // nothing. |
| 138 | if r.Changed && !r.DryRun { |
| 139 | if err := f.write(body); err != nil { |
| 140 | return Result{}, fmt.Errorf("cannot write %s: %v", r.Path, err) |
| 141 | } |
| 142 | } |
| 143 | r.Headline = headline(r) |
| 144 | return r, nil |
| 145 | } |
| 146 | |
| 147 | // headline sums the operation up in one line — the one the model re-reads in |
| 148 | // its tool result, and the one the screen shows under the display line. |
| 149 | func headline(r Result) string { |
| 150 | switch { |
| 151 | case !r.Changed: |
| 152 | return fmt.Sprintf("%s: unchanged", r.Path) |
| 153 | case r.Created && r.DryRun: |
| 154 | return fmt.Sprintf("%s: would be created, %d line(s)", r.Path, r.Added) |
| 155 | case r.Created: |
| 156 | return fmt.Sprintf("%s: created, %d line(s)", r.Path, r.Added) |
| 157 | } |
| 158 | what := fmt.Sprintf("+%d -%d, first change at line %d", r.Added, r.Deleted, r.FirstChangedLine) |
| 159 | if r.Edits > 0 { |
| 160 | what = fmt.Sprintf("%d edit(s) applied, %s", r.Edits, what) |
| 161 | } |
| 162 | if r.DryRun { |
| 163 | return fmt.Sprintf("%s: dry run — nothing written (%s)", r.Path, what) |
| 164 | } |
| 165 | return fmt.Sprintf("%s: %s", r.Path, what) |
| 166 | } |