bots-garden/mini-mepublic Fork 0
main
Commits
Clone
git clone https://git.rickub.com/bots-garden/mini-me.git
git clone ssh://git@rickub.com/bots-garden/mini-me.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

💾 Saved. d722711 · on main · k33g · 3h ago
textfile.go · 120 lines · 3.7 KBGo Blame HistoryRaw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
// 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
}