Files
convy/internal/manifest/edit.go
T
av b6b0976c19 исправлены находки ревью проектной стороны
- манифест читается так, как записан: решётка внутри строки не открывает
  комментарий, скобка внутри комментария не закрывает массив, имя внутри
  комментария не становится подпиской; новый ключ встаёт после массива,
  а не внутрь него
- всё записываемое проходит через manifest.Quote — обратный слэш в пути
  делал файл, который инструмент сам не читает
- маркер локальной части переехал в doc и пропускает огороженные блоки:
  процитированный в примере маркер больше не считается границей, а копия
  без маркера не перезаписывается молча
- лишний позиционный аргумент отсекается: flag прекращал разбор и прятал
  флаги после себя, из-за чего pull, list и check игнорировали --for
- заведены тесты проверок копий, включая молчание на исправной копии
2026-07-27 21:16:29 +03:00

184 lines
5.2 KiB
Go

package manifest
import (
"fmt"
"regexp"
"slices"
"sort"
"strings"
)
// The manifest is edited as text rather than decoded and written back.
//
// suite.toml carries more comment than data — the reasoning behind every topic
// and every prefix lives there, and an encoder would drop all of it and reorder
// what is left. So an entry is spliced into the source, and everything the
// author wrote around it survives untouched.
var (
tableRe = regexp.MustCompile(`^\s*\[([^\]]+)\]\s*$`)
keyRe = regexp.MustCompile(`^\s*("[^"]+"|[A-Za-z0-9_-]+)\s*=`)
bareRe = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
)
// AddEntry splices key = "value" into the given table of a TOML source.
//
// Where the entry lands follows what the table already does: a table whose keys
// are in alphabetical order keeps it, and one ordered by hand — by directory,
// by age, by whatever the author meant — gets the entry appended, because
// guessing at that order would scatter it.
func AddEntry(source []byte, table, key, value string) ([]byte, error) {
lines := strings.Split(string(source), "\n")
start := -1
for i, line := range lines {
if m := tableRe.FindStringSubmatch(line); m != nil && m[1] == table {
start = i
break
}
}
if start < 0 {
return nil, fmt.Errorf("the manifest holds no table [%s]", table)
}
end := len(lines)
for i := start + 1; i < len(lines); i++ {
if tableRe.MatchString(lines[i]) {
end = i
break
}
}
keys, at := tableKeys(lines, start+1, end)
if slices.Contains(keys, key) {
return nil, fmt.Errorf("the table [%s] already holds the key %s", table, key)
}
entry := renderEntry(key, value)
insert := insertionPoint(keys, at, key, lines, start, end)
out := make([]string, 0, len(lines)+1)
out = append(out, lines[:insert]...)
out = append(out, entry)
out = append(out, lines[insert:]...)
return []byte(strings.Join(out, "\n")), nil
}
// RemoveEntry drops a key from a table, leaving everything around it alone.
// Together with AddEntry it moves an entry from the live half of a section to
// the retired one, which is the only way a name ever leaves the live half.
func RemoveEntry(source []byte, table, key string) ([]byte, error) {
lines := strings.Split(string(source), "\n")
start := -1
for i, line := range lines {
if m := tableRe.FindStringSubmatch(line); m != nil && m[1] == table {
start = i
break
}
}
if start < 0 {
return nil, fmt.Errorf("the manifest holds no table [%s]", table)
}
end := len(lines)
for i := start + 1; i < len(lines); i++ {
if tableRe.MatchString(lines[i]) {
end = i
break
}
}
keys, at := tableKeys(lines, start+1, end)
for i, existing := range keys {
if existing != key {
continue
}
out := make([]string, 0, len(lines)-1)
out = append(out, lines[:at[i]]...)
out = append(out, lines[at[i]+1:]...)
return []byte(strings.Join(out, "\n")), nil
}
return nil, fmt.Errorf("the table [%s] holds no key %s", table, key)
}
// tableKeys collects the keys of a table together with the line each sits on.
func tableKeys(lines []string, from, to int) (keys []string, at []int) {
for i := from; i < to; i++ {
m := keyRe.FindStringSubmatch(lines[i])
if m == nil {
continue
}
keys = append(keys, strings.Trim(m[1], `"`))
at = append(at, i)
}
return keys, at
}
// insertionPoint picks the line the entry goes before.
func insertionPoint(keys []string, at []int, key string, lines []string, start, end int) int {
if len(keys) == 0 {
// An empty table owns the comments standing right under its header
// and nothing further: a comment block separated by a blank line
// belongs to the table header below it, not to this one. Walking to
// the end of the section instead would file the entry under the wrong
// explanation.
i := start + 1
for i < end && strings.HasPrefix(strings.TrimSpace(lines[i]), "#") {
i++
}
return i
}
if sort.StringsAreSorted(keys) {
for i, existing := range keys {
if key < existing {
return at[i]
}
}
}
// The entry goes after the last key, and a key is not always one line: an
// array written down a column ends where its bracket closes. Appending
// after the first line of it would land the entry inside the array.
return valueEnd(lines, at[len(at)-1], end) + 1
}
// valueEnd returns the last line the value of a key occupies.
func valueEnd(lines []string, at, to int) int {
depth := 0
for n := at; n < to; n++ {
code, _ := splitComment(lines[n])
if n == at {
if i := strings.Index(code, "="); i >= 0 {
code = code[i+1:]
}
}
_, next, closeAt := scanCode(code, depth)
if closeAt >= 0 {
return n
}
if depth = next; depth <= 0 {
return n
}
}
return at
}
// renderEntry writes one key-value line, quoting the key when it is not bare.
func renderEntry(key, value string) string {
if !bareRe.MatchString(key) {
key = Quote(key)
}
return key + " = " + Quote(value)
}
// Quote writes a string the way TOML reads it back. Every value the tool puts
// into a manifest goes through here: a Windows path is the ordinary case where
// a backslash left alone makes the tool unable to read the file it just wrote.
func Quote(s string) string {
return `"` + escape(s) + `"`
}
func escape(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
return strings.ReplaceAll(s, `"`, `\"`)
}