v0.1.0 3b1b6ef

write.go RawHistoryFolder

1package kvconf
2
3import (
4	"bytes"
5	"fmt"
6	"os"
7	"path/filepath"
8	"runtime"
9	"slices"
10	"strings"
11)
12
13// Set returns data with each key of values assigned, keeping every other
14// line, comment and the spacing of edited lines. An existing assignment (the
15// last one) is replaced; otherwise a commented-out `# key = ...` is enabled;
16// otherwise the key goes after the last key sharing its longest dotted
17// parent, or at the end. A nil value removes every assignment of the key.
18// Keys are applied in sorted order, so the output is deterministic. A key
19// that cannot be read back as written (empty, with '=' or a line break,
20// starting with '#') is an error and nothing is changed.
21func Set(data []byte, values map[string]*string) ([]byte, error) {
22	keys := make([]string, 0, len(values))
23	for k := range values {
24		if !validKey(k) {
25			return nil, fmt.Errorf("kvconf: invalid key %q", k)
26		}
27		keys = append(keys, k)
28	}
29	slices.Sort(keys)
30	for _, k := range keys {
31		if v := values[k]; v == nil {
32			data = removeKey(data, k)
33		} else {
34			data = setKey(data, k, *v)
35		}
36	}
37	return data, nil
38}
39
40// lineKey returns the key of an assignment line, or "" for anything else.
41func lineKey(line string) string {
42	body := strings.TrimSpace(line)
43	if body == "" || body[0] == '#' {
44		return ""
45	}
46	k, _, ok := strings.Cut(body, "=")
47	if !ok {
48		return ""
49	}
50	return strings.TrimSpace(k)
51}
52
53func removeKey(data []byte, key string) []byte {
54	lines := strings.SplitAfter(string(data), "\n")
55	out := lines[:0]
56	for _, l := range lines {
57		if lineKey(l) != key {
58			out = append(out, l)
59		}
60	}
61	return []byte(strings.Join(out, ""))
62}
63
64func setKey(data []byte, key, value string) []byte {
65	value = Quote(value)
66	lines := strings.SplitAfter(string(data), "\n")
67	for i := len(lines) - 1; i >= 0; i-- {
68		if lineKey(lines[i]) == key {
69			prefix, rest, _ := strings.Cut(lines[i], "=")
70			lines[i] = replaceValue(prefix+"=", rest, value)
71			return []byte(strings.Join(lines, ""))
72		}
73	}
74	for i := len(lines) - 1; i >= 0; i-- {
75		indent, body, ok := strings.Cut(lines[i], "#")
76		if !ok || strings.TrimSpace(indent) != "" || lineKey(body) != key {
77			continue
78		}
79		prefix, rest, _ := strings.Cut(strings.TrimLeft(body, " \t"), "=")
80		lines[i] = replaceValue(indent+prefix+"=", rest, value)
81		return []byte(strings.Join(lines, ""))
82	}
83	// output.DP-2.scale belongs beside output.DP-2.*, not merely output.*.
84	for parent := key; ; {
85		dot := strings.LastIndexByte(parent, '.')
86		if dot < 0 {
87			break
88		}
89		parent = parent[:dot]
90		index := -1
91		for i, l := range lines {
92			if k := lineKey(l); k == parent || strings.HasPrefix(k, parent+".") {
93				index = i
94			}
95		}
96		if index < 0 {
97			continue
98		}
99		eol := "\n"
100		if strings.HasSuffix(lines[index], "\r\n") {
101			eol = "\r\n"
102		}
103		if !strings.HasSuffix(lines[index], "\n") {
104			lines[index] += eol
105		}
106		lines = slices.Insert(lines, index+1, key+" = "+value+eol)
107		return []byte(strings.Join(lines, ""))
108	}
109	var b bytes.Buffer
110	b.Grow(len(data) + len(key) + len(value) + 5)
111	b.Write(data)
112	if len(data) > 0 && data[len(data)-1] != '\n' {
113		b.WriteByte('\n')
114	}
115	b.WriteString(key)
116	b.WriteString(" = ")
117	b.WriteString(value)
118	b.WriteByte('\n')
119	return b.Bytes()
120}
121
122// replaceValue keeps indentation, spacing and a trailing comment.
123func replaceValue(prefix, rest, value string) string {
124	plain := strings.TrimRight(rest, "\r\n")
125	eol := rest[len(plain):]
126	old := stripComment(strings.TrimSpace(plain), CommentSpaced)
127	start := strings.Index(plain, old)
128	if old == "" {
129		start = len(plain) - len(strings.TrimLeft(plain, " \t"))
130		if start == 0 {
131			value = " " + value
132		}
133	}
134	return prefix + plain[:start] + value + plain[start+len(old):] + eol
135}
136
137// WriteFile replaces path with data atomically: readers and watchers never
138// see a half-written file. A symlinked path is written through to its target
139// so dotfile links survive. A new file gets perm and its directory is created
140// (0700); an existing file keeps its permissions.
141func WriteFile(path string, data []byte, perm os.FileMode) error {
142	if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 {
143		// Replacing a dangling link would lose it: report it instead.
144		if path, err = filepath.EvalSymlinks(path); err != nil {
145			return err
146		}
147	}
148	if info, err := os.Stat(path); err == nil {
149		perm = info.Mode().Perm()
150	} else if isNotExist(err) {
151		if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
152			return err
153		}
154	} else {
155		return err
156	}
157	tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*")
158	if err != nil {
159		return err
160	}
161	defer os.Remove(tmp.Name()) //nolint:errcheck // gone after the rename
162	if err := writeSync(tmp, data, perm); err != nil {
163		_ = tmp.Close()
164		return err
165	}
166	if err := tmp.Close(); err != nil {
167		return err
168	}
169	if err := rename(tmp.Name(), path); err != nil {
170		return err
171	}
172	return syncDir(filepath.Dir(path))
173}
174
175func writeSync(f *os.File, data []byte, perm os.FileMode) error {
176	// Restrict before writing: the content may be secret.
177	if err := f.Chmod(perm); err != nil && runtime.GOOS != "windows" {
178		return err
179	}
180	if _, err := f.Write(data); err != nil {
181		return err
182	}
183	return f.Sync()
184}
185
186// SetFile applies Set to the file at path and writes it back atomically. A
187// missing file is created with perm.
188func SetFile(path string, values map[string]*string, perm os.FileMode) error {
189	data, err := ReadFile(path, Options{})
190	if err != nil && !isNotExist(err) {
191		return err
192	}
193	if data, err = Set(data, values); err != nil {
194		return err
195	}
196	return WriteFile(path, data, perm)
197}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 3

↑↓ moveenter openesc close