проектные команды и ссылки на источник

- заведён internal/source: уровни ссылаются друг на друга путём на диске
  или git-репозиторием, ревизия закрепляется хвостом #ref; клон делается
  заново и удаляется, кэша нет
- добавлены init, add, pull, list, check в проекте — манифест
  .conventions.toml, сборка копий по разу на компонент, маркер локальной
  части, READING.md рядом
- проверки формы развязаны с набором: принимают lang.Vocabulary, а язык
  копии узнаётся по строке о версии — манифеста рядом с ней нет
This commit is contained in:
av
2026-07-27 20:42:18 +03:00
parent 4615de6e86
commit 23d88c4048
27 changed files with 3009 additions and 57 deletions
+189
View File
@@ -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, `\\`, `\`)
}