исправлены находки ревью проектной стороны

- манифест читается так, как записан: решётка внутри строки не открывает
  комментарий, скобка внутри комментария не закрывает массив, имя внутри
  комментария не становится подпиской; новый ключ встаёт после массива,
  а не внутрь него
- всё записываемое проходит через manifest.Quote — обратный слэш в пути
  делал файл, который инструмент сам не читает
- маркер локальной части переехал в doc и пропускает огороженные блоки:
  процитированный в примере маркер больше не считается границей, а копия
  без маркера не перезаписывается молча
- лишний позиционный аргумент отсекается: flag прекращал разбор и прятал
  флаги после себя, из-за чего pull, list и check игнорировали --for
- заведены тесты проверок копий, включая молчание на исправной копии
This commit is contained in:
av
2026-07-27 21:16:29 +03:00
parent 23d88c4048
commit b6b0976c19
19 changed files with 904 additions and 197 deletions
+34 -3
View File
@@ -135,15 +135,46 @@ func insertionPoint(keys []string, at []int, key string, lines []string, start,
}
}
}
return at[len(at)-1] + 1
// 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 = `"` + escape(key) + `"`
key = Quote(key)
}
return key + ` = "` + escape(value) + `"`
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 {
+216 -65
View File
@@ -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.
+96
View File
@@ -1,9 +1,12 @@
package manifest_test
import (
"slices"
"strings"
"testing"
"github.com/BurntSushi/toml"
"git.vakhrushev.me/av/convy/internal/manifest"
)
@@ -96,3 +99,96 @@ func TestAddTableGoesLast(t *testing.T) {
t.Errorf("a second table of the same name went through")
}
}
// The array is read the way the manifest wrote it. A # inside a string opens no
// comment, a bracket inside a comment closes no array, and a name inside a
// comment is not a subscription — turning any of the three into data is the one
// mistake there is no way back from.
func TestAddToListReadsCommentsAsComments(t *testing.T) {
cases := []struct {
name string
src string
add string
values []string
remains string
}{{
name: "a name quoted inside a comment",
src: "[c.app]\ntopics = [\n \"errors\",\n # \"config\" is not taken yet\n]\n",
add: "time",
values: []string{"errors", "time"},
remains: `# "config" is not taken yet`,
}, {
name: "a bracket inside a comment",
src: "[c.app]\ntopics = [\n \"errors\", # [enough for now]\n \"db-schema\",\n]\n",
add: "config",
values: []string{"errors", "db-schema", "config"},
remains: `"errors", # [enough for now]`,
}, {
name: "a comment trailing a one-line array",
src: "[c.app]\ntopics = [\"git\"] # only git for now\n",
add: "time",
values: []string{"git", "time"},
remains: `topics = ["git", "time"] # only git for now`,
}, {
name: "a hash inside a value",
src: "[c.app]\ntopics = [\"a#b\"]\n",
add: "time",
values: []string{"a#b", "time"},
remains: `topics = ["a#b", "time"]`,
}}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out, err := manifest.AddToList([]byte(tc.src), "c.app", "topics", tc.add)
if err != nil {
t.Fatal(err)
}
got := decodeTopics(t, out)
if !slices.Equal(got, tc.values) {
t.Errorf("the array holds %q, expected %q:\n%s", got, tc.values, out)
}
if !strings.Contains(string(out), tc.remains) {
t.Errorf("what the author wrote is gone — no %q:\n%s", tc.remains, out)
}
})
}
}
// decodeTopics reads the array back with the parser the tool itself uses.
func decodeTopics(t *testing.T, source []byte) []string {
t.Helper()
var got struct {
C map[string]struct{ Topics []string } `toml:"c"`
}
if _, err := toml.Decode(string(source), &got); err != nil {
t.Fatalf("the manifest the tool wrote does not parse: %v\n%s", err, source)
}
return got.C["app"].Topics
}
// A key is not always one line. Appending after the first line of an array
// written down a column would land the new entry inside it.
func TestAddToListAppendsPastAMultilineNeighbour(t *testing.T) {
src := "[c.app]\ndir = \"docs\"\nstack = [\n \"sqlite\",\n \"postgres\",\n]\n"
out, err := manifest.AddToList([]byte(src), "c.app", "topics", "errors")
if err != nil {
t.Fatal(err)
}
body := string(out)
if !strings.Contains(body, " \"postgres\",\n]\ntopics = [\"errors\"]") {
t.Errorf("the new key did not land past the array:\n%s", body)
}
}
// Whatever the tool writes into a manifest, it has to be able to read back.
func TestQuoteSurvivesTheRoundTrip(t *testing.T) {
for _, value := range []string{`docs\conventions`, `a "quoted" name`, `both\ "kinds"`} {
out, err := manifest.AddToList([]byte("[c.app]\ntopics = []\n"), "c.app", "topics", value)
if err != nil {
t.Fatal(err)
}
if list := decodeTopics(t, out); len(list) != 1 || list[0] != value {
t.Errorf("%q came back as %q", value, list)
}
}
}