| 💾 Saved. d722711 k33g 7h ago | 1 | // Package fileedit reads, writes and edits ONE text file by exact replacement. |
| 2 | // It is a port of the core of the `edit` CLI (tools/edit: internal/edits, |
| 3 | // internal/diff, internal/textfile) with the same rules and, as far as the |
| 4 | // tool names allow, the same messages — so that the built-in tools of this part |
| 5 | // and the CLI of part 05 refuse the same things, in the same words, and the |
| 6 | // A/B comparison between them measures the MODEL, not the tool. |
| 7 | // |
| 8 | // Copied rather than imported: the CLI keeps its core under internal/, which Go |
| 9 | // does not let another module import, and moving it out would change a module |
| 10 | // this talk shows on stage. Four hundred lines of stdlib are cheaper than that. |
| 11 | package fileedit |
| 12 | |
| 13 | import ( |
| 14 | "fmt" |
| 15 | "io/fs" |
| 16 | "os" |
| 17 | "path/filepath" |
| 18 | "strings" |
| 19 | ) |
| 20 | |
| 21 | // bom is the UTF-8 byte order mark, as it appears at the head of a file. |
| 22 | const bom = "\uFEFF" |
| 23 | |
| 24 | // file is a text file loaded in memory, ready to be edited. |
| 25 | // |
| 26 | // Two details invisible on screen would not survive a naive read/replace/write |
| 27 | // round trip: the UTF-8 BOM some Windows editors put first — left in the body |
| 28 | // it sticks to the first word and defeats an `old` that starts at the top of |
| 29 | // the file — and CRLF line endings, which would defeat EVERY multi-line `old` |
| 30 | // written with \n, that is, every one a model produces. Both are removed on |
| 31 | // read and restored on write. |
| 32 | type file struct { |
| 33 | path string |
| 34 | // body is the NORMALISED content: no BOM, \n line endings. Searches and |
| 35 | // diffs work on it. |
| 36 | body string |
| 37 | mode fs.FileMode |
| 38 | // isNew says the file did not exist yet. |
| 39 | isNew bool |
| 40 | hadBOM bool |
| 41 | crlf bool |
| 42 | } |
| 43 | |
| 44 | // load reads a file. A missing file is not an error: it comes back empty and |
| 45 | // marked isNew, which lets Write create it and Replace refuse with its own |
| 46 | // message. |
| 47 | func load(path string) (*file, error) { |
| 48 | f := &file{path: path, mode: 0o644, isNew: true} |
| 49 | |
| 50 | data, err := os.ReadFile(path) |
| 51 | if err != nil { |
| 52 | if os.IsNotExist(err) { |
| 53 | return f, nil |
| 54 | } |
| 55 | return nil, err |
| 56 | } |
| 57 | if info, err := os.Stat(path); err == nil { |
| 58 | f.mode = info.Mode().Perm() |
| 59 | } |
| 60 | f.isNew = false |
| 61 | |
| 62 | body := string(data) |
| 63 | if strings.HasPrefix(body, bom) { |
| 64 | f.hadBOM = true |
| 65 | body = strings.TrimPrefix(body, bom) |
| 66 | } |
| 67 | // The file's line-ending style is that of its FIRST line ending. A mixed |
| 68 | // file — it happens, usually by accident — is thus rewritten in that style: |
| 69 | // better a consistent file than a diff where every line seems changed. |
| 70 | if i := strings.IndexByte(body, '\n'); i > 0 && body[i-1] == '\r' { |
| 71 | f.crlf = true |
| 72 | } |
| 73 | f.body = strings.ReplaceAll(body, "\r\n", "\n") |
| 74 | return f, nil |
| 75 | } |
| 76 | |
| 77 | // render puts the original shape (BOM, CRLF) back around a normalised body. |
| 78 | func (f *file) render(body string) string { |
| 79 | if f.crlf { |
| 80 | body = strings.ReplaceAll(body, "\n", "\r\n") |
| 81 | } |
| 82 | if f.hadBOM { |
| 83 | body = bom + body |
| 84 | } |
| 85 | return body |
| 86 | } |
| 87 | |
| 88 | // write replaces the file with a normalised body. |
| 89 | // |
| 90 | // It goes through a temporary file in the SAME directory, then a rename: a |
| 91 | // full disk or a process killed at the wrong moment leaves the original |
| 92 | // intact, where a direct os.WriteFile would have truncated it. The rename is |
| 93 | // only atomic on one file system, hence the same directory. |
| 94 | func (f *file) write(body string) error { |
| 95 | dir := filepath.Dir(f.path) |
| 96 | if err := os.MkdirAll(dir, 0o755); err != nil { |
| 97 | return err |
| 98 | } |
| 99 | tmp, err := os.CreateTemp(dir, ".edit-*.tmp") |
| 100 | if err != nil { |
| 101 | return err |
| 102 | } |
| 103 | defer os.Remove(tmp.Name()) // no effect once the rename succeeded |
| 104 | |
| 105 | if _, err := tmp.WriteString(f.render(body)); err != nil { |
| 106 | tmp.Close() |
| 107 | return err |
| 108 | } |
| 109 | if err := tmp.Close(); err != nil { |
| 110 | return err |
| 111 | } |
| 112 | if err := os.Chmod(tmp.Name(), f.mode); err != nil { |
| 113 | return err |
| 114 | } |
| 115 | if err := os.Rename(tmp.Name(), f.path); err != nil { |
| 116 | return fmt.Errorf("%s: %w", f.path, err) |
| 117 | } |
| 118 | f.body, f.isNew = body, false |
| 119 | return nil |
| 120 | } |