// Package fileedit reads, writes and edits ONE text file by exact replacement. // It is a port of the core of the `edit` CLI (tools/edit: internal/edits, // internal/diff, internal/textfile) with the same rules and, as far as the // tool names allow, the same messages — so that the built-in tools of this part // and the CLI of part 05 refuse the same things, in the same words, and the // A/B comparison between them measures the MODEL, not the tool. // // Copied rather than imported: the CLI keeps its core under internal/, which Go // does not let another module import, and moving it out would change a module // this talk shows on stage. Four hundred lines of stdlib are cheaper than that. package fileedit import ( "fmt" "io/fs" "os" "path/filepath" "strings" ) // bom is the UTF-8 byte order mark, as it appears at the head of a file. const bom = "\uFEFF" // file is a text file loaded in memory, ready to be edited. // // Two details invisible on screen would not survive a naive read/replace/write // round trip: the UTF-8 BOM some Windows editors put first — left in the body // it sticks to the first word and defeats an `old` that starts at the top of // the file — and CRLF line endings, which would defeat EVERY multi-line `old` // written with \n, that is, every one a model produces. Both are removed on // read and restored on write. type file struct { path string // body is the NORMALISED content: no BOM, \n line endings. Searches and // diffs work on it. body string mode fs.FileMode // isNew says the file did not exist yet. isNew bool hadBOM bool crlf bool } // load reads a file. A missing file is not an error: it comes back empty and // marked isNew, which lets Write create it and Replace refuse with its own // message. func load(path string) (*file, error) { f := &file{path: path, mode: 0o644, isNew: true} data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return f, nil } return nil, err } if info, err := os.Stat(path); err == nil { f.mode = info.Mode().Perm() } f.isNew = false body := string(data) if strings.HasPrefix(body, bom) { f.hadBOM = true body = strings.TrimPrefix(body, bom) } // The file's line-ending style is that of its FIRST line ending. A mixed // file — it happens, usually by accident — is thus rewritten in that style: // better a consistent file than a diff where every line seems changed. if i := strings.IndexByte(body, '\n'); i > 0 && body[i-1] == '\r' { f.crlf = true } f.body = strings.ReplaceAll(body, "\r\n", "\n") return f, nil } // render puts the original shape (BOM, CRLF) back around a normalised body. func (f *file) render(body string) string { if f.crlf { body = strings.ReplaceAll(body, "\n", "\r\n") } if f.hadBOM { body = bom + body } return body } // write replaces the file with a normalised body. // // It goes through a temporary file in the SAME directory, then a rename: a // full disk or a process killed at the wrong moment leaves the original // intact, where a direct os.WriteFile would have truncated it. The rename is // only atomic on one file system, hence the same directory. func (f *file) write(body string) error { dir := filepath.Dir(f.path) if err := os.MkdirAll(dir, 0o755); err != nil { return err } tmp, err := os.CreateTemp(dir, ".edit-*.tmp") if err != nil { return err } defer os.Remove(tmp.Name()) // no effect once the rename succeeded if _, err := tmp.WriteString(f.render(body)); err != nil { tmp.Close() return err } if err := tmp.Close(); err != nil { return err } if err := os.Chmod(tmp.Name(), f.mode); err != nil { return err } if err := os.Rename(tmp.Name(), f.path); err != nil { return fmt.Errorf("%s: %w", f.path, err) } f.body, f.isNew = body, false return nil }