исправлены находки ревью проектной стороны
- манифест читается так, как записан: решётка внутри строки не открывает комментарий, скобка внутри комментария не закрывает массив, имя внутри комментария не становится подпиской; новый ключ встаёт после массива, а не внутрь него - всё записываемое проходит через manifest.Quote — обратный слэш в пути делал файл, который инструмент сам не читает - маркер локальной части переехал в doc и пропускает огороженные блоки: процитированный в примере маркер больше не считается границей, а копия без маркера не перезаписывается молча - лишний позиционный аргумент отсекается: flag прекращал разбор и прятал флаги после себя, из-за чего pull, list и check игнорировали --for - заведены тесты проверок копий, включая молчание на исправной копии
This commit is contained in:
+216
-65
@@ -4,7 +4,6 @@ import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -13,13 +12,43 @@ import (
|
||||
// author wrote around the data — which component is what, why a topic is taken
|
||||
// — has to survive the tool touching the file.
|
||||
//
|
||||
// The shape of the array survives too. An array written on one line stays on
|
||||
// one line, one written down a column stays a column: reflowing it would make
|
||||
// every commit that adds a topic look like a rewrite of 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*\[`)
|
||||
|
||||
// AddToList appends a value to the array under key in the given table. A table
|
||||
// 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")
|
||||
@@ -29,7 +58,7 @@ func AddToList(source []byte, table, key, value string) ([]byte, error) {
|
||||
return nil, fmt.Errorf("the manifest holds no table [%s]", table)
|
||||
}
|
||||
|
||||
from, to, found := arrayBounds(lines, start+1, end, key)
|
||||
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)
|
||||
@@ -41,21 +70,25 @@ func AddToList(source []byte, table, key, value string) ([]byte, error) {
|
||||
return []byte(strings.Join(out, "\n")), nil
|
||||
}
|
||||
|
||||
values := arrayValues(lines[from : to+1])
|
||||
if slices.Contains(values, value) {
|
||||
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 := sort.StringsAreSorted(values)
|
||||
values = append(values, value)
|
||||
if sorted {
|
||||
sort.Strings(values)
|
||||
}
|
||||
|
||||
indent := arrayKeyRe.FindStringSubmatch(lines[from])[1]
|
||||
return splice(lines, from, to, renderArray(indent, key, values, from != to)), nil
|
||||
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.
|
||||
// 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")
|
||||
|
||||
@@ -63,22 +96,27 @@ func RemoveFromList(source []byte, table, key, value string) ([]byte, error) {
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("the manifest holds no table [%s]", table)
|
||||
}
|
||||
from, to, found := arrayBounds(lines, start+1, end, key)
|
||||
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)
|
||||
}
|
||||
|
||||
values := arrayValues(lines[from : to+1])
|
||||
i := slices.Index(values, value)
|
||||
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)
|
||||
}
|
||||
values = slices.Delete(values, i, i+1)
|
||||
|
||||
indent := arrayKeyRe.FindStringSubmatch(lines[from])[1]
|
||||
return splice(lines, from, to, renderArray(indent, key, values, from != to)), nil
|
||||
// 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.
|
||||
@@ -122,54 +160,167 @@ func tableBounds(lines []string, table string) (start, end int, ok bool) {
|
||||
return start, end, true
|
||||
}
|
||||
|
||||
// arrayBounds finds the first and the last line of the array under key.
|
||||
func arrayBounds(lines []string, from, to int, key string) (start, end int, ok bool) {
|
||||
// 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++ {
|
||||
m := arrayKeyRe.FindStringSubmatch(lines[i])
|
||||
if m == nil || strings.Trim(m[2], `"`) != key {
|
||||
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 j := i; j < to; j++ {
|
||||
if strings.Contains(lines[j], "]") {
|
||||
return i, j, true
|
||||
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 i, i, true
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
var stringRe = regexp.MustCompile(`"((?:[^"\\]|\\.)*)"`)
|
||||
|
||||
// arrayValues pulls the strings out of an array. The array holds names — of
|
||||
// topics, of languages, of stacks — and a name is a string; anything else in
|
||||
// there is not a thing this tool wrote.
|
||||
func arrayValues(lines []string) []string {
|
||||
text := strings.Join(lines, " ")
|
||||
if i := strings.Index(text, "["); i >= 0 {
|
||||
text = text[i:]
|
||||
}
|
||||
var out []string
|
||||
for _, m := range stringRe.FindAllStringSubmatch(text, -1) {
|
||||
out = append(out, unescape(m[1]))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// renderArray writes the array back in the shape it had.
|
||||
func renderArray(indent, key string, values []string, column bool) []string {
|
||||
quoted := make([]string, len(values))
|
||||
for i, v := range values {
|
||||
quoted[i] = `"` + escape(v) + `"`
|
||||
}
|
||||
if !column {
|
||||
return []string{fmt.Sprintf("%s%s = [%s]", indent, key, strings.Join(quoted, ", "))}
|
||||
}
|
||||
out := []string{fmt.Sprintf("%s%s = [", indent, key)}
|
||||
for _, q := range quoted {
|
||||
out = append(out, indent+" "+q+",")
|
||||
}
|
||||
return append(out, indent+"]")
|
||||
return values, depth, closeAt
|
||||
}
|
||||
|
||||
// splice replaces lines from..to inclusive with the given block.
|
||||
|
||||
Reference in New Issue
Block a user