- манифест читается так, как записан: решётка внутри строки не открывает комментарий, скобка внутри комментария не закрывает массив, имя внутри комментария не становится подпиской; новый ключ встаёт после массива, а не внутрь него - всё записываемое проходит через manifest.Quote — обратный слэш в пути делал файл, который инструмент сам не читает - маркер локальной части переехал в doc и пропускает огороженные блоки: процитированный в примере маркер больше не считается границей, а копия без маркера не перезаписывается молча - лишний позиционный аргумент отсекается: flag прекращал разбор и прятал флаги после себя, из-за чего pull, list и check игнорировали --for - заведены тесты проверок копий, включая молчание на исправной копии
341 lines
9.8 KiB
Go
341 lines
9.8 KiB
Go
package manifest
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
)
|
|
|
|
// A subscription is an array rather than a key-value pair, and the project
|
|
// manifest is edited as text for the same reason the suite one is: what the
|
|
// author wrote around the data — which component is what, why a topic is taken
|
|
// — has to survive the tool touching the file.
|
|
//
|
|
// So the array is read as the manifest wrote it, not as a regular expression
|
|
// over the raw lines: a # inside a string does not open a comment, a bracket
|
|
// inside a comment does not close an array, and a name inside a comment is not
|
|
// a subscription. Getting any of the three wrong turns a comment into data, and
|
|
// there is no way back from that.
|
|
//
|
|
// The shape survives too. An array written on one line stays on one line, one
|
|
// written down a column stays a column, and the comments keep their places: a
|
|
// comment block above a value belongs to that value — the same rule that
|
|
// governs a comment above a table — so sorting carries it along, while a
|
|
// comment trailing on the same line stays on its line.
|
|
|
|
var arrayKeyRe = regexp.MustCompile(`^(\s*)("[^"]+"|[A-Za-z0-9_-]+)\s*=\s*\[`)
|
|
|
|
// element is one value of an array together with what was written around it.
|
|
type element struct {
|
|
value string
|
|
above []string
|
|
after string
|
|
}
|
|
|
|
// array is an array of the manifest, parsed.
|
|
type array struct {
|
|
indent string
|
|
key string
|
|
// column says the array was written down a column rather than on one line.
|
|
column bool
|
|
elems []element
|
|
// opening is a comment trailing the opening bracket.
|
|
opening string
|
|
// dangling holds comment lines standing after the last value.
|
|
dangling []string
|
|
// tail is whatever follows the closing bracket.
|
|
tail string
|
|
}
|
|
|
|
// AddToList adds a value to the array under key in the given table. A table
|
|
// that has no such key gets one holding the single value.
|
|
func AddToList(source []byte, table, key, value string) ([]byte, error) {
|
|
lines := strings.Split(string(source), "\n")
|
|
|
|
start, end, ok := tableBounds(lines, table)
|
|
if !ok {
|
|
return nil, fmt.Errorf("the manifest holds no table [%s]", table)
|
|
}
|
|
|
|
from, found := arrayKeyLine(lines, start+1, end, key)
|
|
if !found {
|
|
keys, at := tableKeys(lines, start+1, end)
|
|
insert := insertionPoint(keys, at, key, lines, start, end)
|
|
entry := fmt.Sprintf(`%s = ["%s"]`, key, escape(value))
|
|
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
|
|
}
|
|
|
|
a, last, ok := readArray(lines, from, end)
|
|
if !ok {
|
|
return nil, fmt.Errorf("the array %s in [%s] is not closed by a bracket", key, table)
|
|
}
|
|
if slices.ContainsFunc(a.elems, func(e element) bool { return e.value == value }) {
|
|
return nil, fmt.Errorf("%s already holds %q", key, value)
|
|
}
|
|
|
|
sorted := slices.IsSortedFunc(a.elems, byValue)
|
|
a.elems = append(a.elems, element{value: value})
|
|
if sorted {
|
|
slices.SortStableFunc(a.elems, byValue)
|
|
}
|
|
return splice(lines, from, last, a.render()), nil
|
|
}
|
|
|
|
// RemoveFromList drops a value from the array under key. It is the other half
|
|
// of AddToList: unsubscribing is not a command yet, and a pair of edits where
|
|
// only one direction is written is a pair where the untried direction is wrong.
|
|
func RemoveFromList(source []byte, table, key, value string) ([]byte, error) {
|
|
lines := strings.Split(string(source), "\n")
|
|
|
|
start, end, ok := tableBounds(lines, table)
|
|
if !ok {
|
|
return nil, fmt.Errorf("the manifest holds no table [%s]", table)
|
|
}
|
|
from, found := arrayKeyLine(lines, start+1, end, key)
|
|
if !found {
|
|
return nil, fmt.Errorf("the table [%s] holds no key %s", table, key)
|
|
}
|
|
a, last, ok := readArray(lines, from, end)
|
|
if !ok {
|
|
return nil, fmt.Errorf("the array %s in [%s] is not closed by a bracket", key, table)
|
|
}
|
|
|
|
i := slices.IndexFunc(a.elems, func(e element) bool { return e.value == value })
|
|
if i < 0 {
|
|
return nil, fmt.Errorf("%s does not hold %q", key, value)
|
|
}
|
|
// The comment above a value went with it and goes away with it; a comment
|
|
// left hanging over the next value would say the wrong thing about it.
|
|
a.elems = slices.Delete(a.elems, i, i+1)
|
|
return splice(lines, from, last, a.render()), nil
|
|
}
|
|
|
|
func byValue(a, b element) int { return strings.Compare(a.value, b.value) }
|
|
|
|
// AddTable appends a table to the end of the manifest. A new component is a new
|
|
// table, and it goes last because the order of components is the author's:
|
|
// there is nothing to sort them by that would mean anything.
|
|
func AddTable(source []byte, table string, entries [][2]string) ([]byte, error) {
|
|
lines := strings.Split(string(source), "\n")
|
|
if _, _, ok := tableBounds(lines, table); ok {
|
|
return nil, fmt.Errorf("the manifest already holds the table [%s]", table)
|
|
}
|
|
|
|
for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" {
|
|
lines = lines[:len(lines)-1]
|
|
}
|
|
block := []string{"", "[" + table + "]"}
|
|
for _, e := range entries {
|
|
block = append(block, e[0]+" = "+e[1])
|
|
}
|
|
block = append(block, "")
|
|
return []byte(strings.Join(append(lines, block...), "\n")), nil
|
|
}
|
|
|
|
// tableBounds finds the lines a table spans: its header and the line the next
|
|
// table starts on.
|
|
func tableBounds(lines []string, table string) (start, end int, ok bool) {
|
|
start = -1
|
|
for i, line := range lines {
|
|
if m := tableRe.FindStringSubmatch(line); m != nil && m[1] == table {
|
|
start = i
|
|
break
|
|
}
|
|
}
|
|
if start < 0 {
|
|
return 0, 0, false
|
|
}
|
|
end = len(lines)
|
|
for i := start + 1; i < len(lines); i++ {
|
|
if tableRe.MatchString(lines[i]) {
|
|
end = i
|
|
break
|
|
}
|
|
}
|
|
return start, end, true
|
|
}
|
|
|
|
// arrayKeyLine finds the line an array opens on.
|
|
func arrayKeyLine(lines []string, from, to int, key string) (int, bool) {
|
|
for i := from; i < to; i++ {
|
|
code, _ := splitComment(lines[i])
|
|
m := arrayKeyRe.FindStringSubmatch(code)
|
|
if m != nil && strings.Trim(m[2], `"`) == key {
|
|
return i, true
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
// readArray parses the array opening on line from and returns the line it
|
|
// closes on.
|
|
func readArray(lines []string, from, to int) (a array, last int, ok bool) {
|
|
code, comment := splitComment(lines[from])
|
|
m := arrayKeyRe.FindStringSubmatch(code)
|
|
if m == nil {
|
|
return a, 0, false
|
|
}
|
|
a.indent, a.key = m[1], strings.Trim(m[2], `"`)
|
|
|
|
open := strings.Index(code, "[")
|
|
values, depth, closeAt := scanCode(code[open:], 0)
|
|
for _, v := range values {
|
|
a.elems = append(a.elems, element{value: v})
|
|
}
|
|
if closeAt >= 0 {
|
|
a.tail = joinTail(code[open+closeAt:], comment)
|
|
return a, from, true
|
|
}
|
|
a.column = true
|
|
a.opening = comment
|
|
|
|
var pending []string
|
|
for n := from + 1; n < to; n++ {
|
|
code, comment := splitComment(lines[n])
|
|
values, next, closeAt := scanCode(code, depth)
|
|
depth = next
|
|
|
|
if len(values) == 0 && strings.TrimSpace(code) == "" && closeAt < 0 {
|
|
if comment != "" {
|
|
pending = append(pending, lines[n])
|
|
}
|
|
continue
|
|
}
|
|
for i, v := range values {
|
|
e := element{value: v}
|
|
if i == 0 {
|
|
e.above, pending = pending, nil
|
|
}
|
|
if i == len(values)-1 && closeAt < 0 {
|
|
e.after = comment
|
|
}
|
|
a.elems = append(a.elems, e)
|
|
}
|
|
if closeAt >= 0 {
|
|
a.dangling = pending
|
|
a.tail = joinTail(code[closeAt:], comment)
|
|
return a, n, true
|
|
}
|
|
}
|
|
return a, 0, false
|
|
}
|
|
|
|
// render writes the array back in the shape it had.
|
|
func (a array) render() []string {
|
|
quoted := make([]string, len(a.elems))
|
|
for i, e := range a.elems {
|
|
quoted[i] = `"` + escape(e.value) + `"`
|
|
}
|
|
|
|
if !a.column {
|
|
line := fmt.Sprintf("%s%s = [%s]", a.indent, a.key, strings.Join(quoted, ", "))
|
|
return []string{appendTail(line, a.tail)}
|
|
}
|
|
|
|
out := []string{appendTail(a.indent+a.key+" = [", a.opening)}
|
|
for i, e := range a.elems {
|
|
out = append(out, e.above...)
|
|
out = append(out, appendTail(a.indent+" "+quoted[i]+",", e.after))
|
|
}
|
|
out = append(out, a.dangling...)
|
|
return append(out, appendTail(a.indent+"]", a.tail))
|
|
}
|
|
|
|
func appendTail(line, tail string) string {
|
|
if tail == "" {
|
|
return line
|
|
}
|
|
return line + " " + tail
|
|
}
|
|
|
|
func joinTail(rest, comment string) string {
|
|
return strings.TrimSpace(strings.TrimSpace(rest) + " " + comment)
|
|
}
|
|
|
|
// splitComment cuts a line into its code and its comment. A # inside a string
|
|
// opens no comment, which is the whole reason this is not a call to strings.Cut.
|
|
func splitComment(line string) (code, comment string) {
|
|
quote := byte(0)
|
|
for i := 0; i < len(line); i++ {
|
|
c := line[i]
|
|
if quote != 0 {
|
|
if quote == '"' && c == '\\' {
|
|
i++
|
|
continue
|
|
}
|
|
if c == quote {
|
|
quote = 0
|
|
}
|
|
continue
|
|
}
|
|
switch c {
|
|
case '"', '\'':
|
|
quote = c
|
|
case '#':
|
|
return line[:i], line[i:]
|
|
}
|
|
}
|
|
return line, ""
|
|
}
|
|
|
|
// scanCode walks the code of a line, collecting the strings in it and following
|
|
// the bracket depth. closeAt is the offset just past the bracket that brought
|
|
// the depth back to zero, or -1 while the array is still open.
|
|
func scanCode(code string, depth int) (values []string, depthOut, closeAt int) {
|
|
closeAt = -1
|
|
for i := 0; i < len(code); i++ {
|
|
switch c := code[i]; c {
|
|
case '"', '\'':
|
|
var b strings.Builder
|
|
j := i + 1
|
|
for j < len(code) {
|
|
if c == '"' && code[j] == '\\' && j+1 < len(code) {
|
|
b.WriteString(code[j : j+2])
|
|
j += 2
|
|
continue
|
|
}
|
|
if code[j] == c {
|
|
break
|
|
}
|
|
b.WriteByte(code[j])
|
|
j++
|
|
}
|
|
text := b.String()
|
|
if c == '"' {
|
|
text = unescape(text)
|
|
}
|
|
values = append(values, text)
|
|
i = j
|
|
case '[':
|
|
depth++
|
|
case ']':
|
|
depth--
|
|
if depth == 0 && closeAt < 0 {
|
|
closeAt = i + 1
|
|
}
|
|
}
|
|
}
|
|
return values, depth, closeAt
|
|
}
|
|
|
|
// splice replaces lines from..to inclusive with the given block.
|
|
func splice(lines []string, from, to int, block []string) []byte {
|
|
out := make([]string, 0, len(lines)+len(block))
|
|
out = append(out, lines[:from]...)
|
|
out = append(out, block...)
|
|
if to < len(lines) {
|
|
out = append(out, lines[to+1:]...)
|
|
}
|
|
return []byte(strings.Join(out, "\n"))
|
|
}
|
|
|
|
func unescape(s string) string {
|
|
s = strings.ReplaceAll(s, `\"`, `"`)
|
|
return strings.ReplaceAll(s, `\\`, `\`)
|
|
}
|