проектные команды и ссылки на источник
- заведён internal/source: уровни ссылаются друг на друга путём на диске или git-репозиторием, ревизия закрепляется хвостом #ref; клон делается заново и удаляется, кэша нет - добавлены init, add, pull, list, check в проекте — манифест .conventions.toml, сборка копий по разу на компонент, маркер локальной части, READING.md рядом - проверки формы развязаны с набором: принимают lang.Vocabulary, а язык копии узнаётся по строке о версии — манифеста рядом с ней нет
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
package manifest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"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.
|
||||
//
|
||||
// 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.
|
||||
|
||||
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
|
||||
// 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, to, found := arrayBounds(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
|
||||
}
|
||||
|
||||
values := arrayValues(lines[from : to+1])
|
||||
if slices.Contains(values, 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
|
||||
}
|
||||
|
||||
// RemoveFromList drops a value from the array under key.
|
||||
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, to, found := arrayBounds(lines, start+1, end, key)
|
||||
if !found {
|
||||
return nil, fmt.Errorf("the table [%s] holds no key %s", table, key)
|
||||
}
|
||||
|
||||
values := arrayValues(lines[from : to+1])
|
||||
i := slices.Index(values, 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
for i := from; i < to; i++ {
|
||||
m := arrayKeyRe.FindStringSubmatch(lines[i])
|
||||
if m == nil || strings.Trim(m[2], `"`) != key {
|
||||
continue
|
||||
}
|
||||
for j := i; j < to; j++ {
|
||||
if strings.Contains(lines[j], "]") {
|
||||
return i, j, true
|
||||
}
|
||||
}
|
||||
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+"]")
|
||||
}
|
||||
|
||||
// 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, `\\`, `\`)
|
||||
}
|
||||
Reference in New Issue
Block a user