манифесты стали данными, заведён convy sync
- убраны комментарии из suite.toml и .conventions.toml: файл, который машина переписывает, комментарий через круг не проносит; объяснения ушли в README рядом, который suite init теперь заводит - удалена текстовая правка манифеста целиком — 520 строк ручного лексера TOML вместе со всем классом ошибок порчи данных - запись идёт из структур энкодером; ключ, которого инструмент не знает, запись останавливает, а не теряется молча - convy sync сверяет манифест и подводит под него раскладку файлов: чего не хватает — собирает, что осиротело — удаляет, копию с локальной частью не трогает никогда
This commit is contained in:
@@ -1,183 +0,0 @@
|
||||
package manifest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The manifest is edited as text rather than decoded and written back.
|
||||
//
|
||||
// suite.toml carries more comment than data — the reasoning behind every topic
|
||||
// and every prefix lives there, and an encoder would drop all of it and reorder
|
||||
// what is left. So an entry is spliced into the source, and everything the
|
||||
// author wrote around it survives untouched.
|
||||
|
||||
var (
|
||||
tableRe = regexp.MustCompile(`^\s*\[([^\]]+)\]\s*$`)
|
||||
keyRe = regexp.MustCompile(`^\s*("[^"]+"|[A-Za-z0-9_-]+)\s*=`)
|
||||
bareRe = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
|
||||
)
|
||||
|
||||
// AddEntry splices key = "value" into the given table of a TOML source.
|
||||
//
|
||||
// Where the entry lands follows what the table already does: a table whose keys
|
||||
// are in alphabetical order keeps it, and one ordered by hand — by directory,
|
||||
// by age, by whatever the author meant — gets the entry appended, because
|
||||
// guessing at that order would scatter it.
|
||||
func AddEntry(source []byte, table, key, value string) ([]byte, error) {
|
||||
lines := strings.Split(string(source), "\n")
|
||||
|
||||
start := -1
|
||||
for i, line := range lines {
|
||||
if m := tableRe.FindStringSubmatch(line); m != nil && m[1] == table {
|
||||
start = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if start < 0 {
|
||||
return nil, fmt.Errorf("the manifest holds no table [%s]", table)
|
||||
}
|
||||
|
||||
end := len(lines)
|
||||
for i := start + 1; i < len(lines); i++ {
|
||||
if tableRe.MatchString(lines[i]) {
|
||||
end = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
keys, at := tableKeys(lines, start+1, end)
|
||||
if slices.Contains(keys, key) {
|
||||
return nil, fmt.Errorf("the table [%s] already holds the key %s", table, key)
|
||||
}
|
||||
|
||||
entry := renderEntry(key, value)
|
||||
insert := insertionPoint(keys, at, key, lines, start, end)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// RemoveEntry drops a key from a table, leaving everything around it alone.
|
||||
// Together with AddEntry it moves an entry from the live half of a section to
|
||||
// the retired one, which is the only way a name ever leaves the live half.
|
||||
func RemoveEntry(source []byte, table, key string) ([]byte, error) {
|
||||
lines := strings.Split(string(source), "\n")
|
||||
|
||||
start := -1
|
||||
for i, line := range lines {
|
||||
if m := tableRe.FindStringSubmatch(line); m != nil && m[1] == table {
|
||||
start = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if start < 0 {
|
||||
return nil, fmt.Errorf("the manifest holds no table [%s]", table)
|
||||
}
|
||||
end := len(lines)
|
||||
for i := start + 1; i < len(lines); i++ {
|
||||
if tableRe.MatchString(lines[i]) {
|
||||
end = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
keys, at := tableKeys(lines, start+1, end)
|
||||
for i, existing := range keys {
|
||||
if existing != key {
|
||||
continue
|
||||
}
|
||||
out := make([]string, 0, len(lines)-1)
|
||||
out = append(out, lines[:at[i]]...)
|
||||
out = append(out, lines[at[i]+1:]...)
|
||||
return []byte(strings.Join(out, "\n")), nil
|
||||
}
|
||||
return nil, fmt.Errorf("the table [%s] holds no key %s", table, key)
|
||||
}
|
||||
|
||||
// tableKeys collects the keys of a table together with the line each sits on.
|
||||
func tableKeys(lines []string, from, to int) (keys []string, at []int) {
|
||||
for i := from; i < to; i++ {
|
||||
m := keyRe.FindStringSubmatch(lines[i])
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, strings.Trim(m[1], `"`))
|
||||
at = append(at, i)
|
||||
}
|
||||
return keys, at
|
||||
}
|
||||
|
||||
// insertionPoint picks the line the entry goes before.
|
||||
func insertionPoint(keys []string, at []int, key string, lines []string, start, end int) int {
|
||||
if len(keys) == 0 {
|
||||
// An empty table owns the comments standing right under its header
|
||||
// and nothing further: a comment block separated by a blank line
|
||||
// belongs to the table header below it, not to this one. Walking to
|
||||
// the end of the section instead would file the entry under the wrong
|
||||
// explanation.
|
||||
i := start + 1
|
||||
for i < end && strings.HasPrefix(strings.TrimSpace(lines[i]), "#") {
|
||||
i++
|
||||
}
|
||||
return i
|
||||
}
|
||||
if sort.StringsAreSorted(keys) {
|
||||
for i, existing := range keys {
|
||||
if key < existing {
|
||||
return at[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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 = Quote(key)
|
||||
}
|
||||
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 {
|
||||
s = strings.ReplaceAll(s, `\`, `\\`)
|
||||
return strings.ReplaceAll(s, `"`, `\"`)
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
package manifest_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/convy/internal/manifest"
|
||||
)
|
||||
|
||||
func TestAddEntryKeepsComments(t *testing.T) {
|
||||
source := `# The suite manifest.
|
||||
|
||||
[language]
|
||||
version = 1
|
||||
|
||||
# ─── Topics ───
|
||||
#
|
||||
# A topic is a set of rules about one focus of development.
|
||||
|
||||
[topics.live]
|
||||
config = "configuration"
|
||||
time = "time"
|
||||
|
||||
[topics.retired]
|
||||
# Empty. Retired names land here together with a reason and a date.
|
||||
`
|
||||
got, err := manifest.AddEntry([]byte(source), "topics.live", "logging", "logging: levels, structure")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := string(got)
|
||||
|
||||
for _, want := range []string{
|
||||
"# ─── Topics ───",
|
||||
"# A topic is a set of rules about one focus of development.",
|
||||
"# Empty. Retired names land here together with a reason and a date.",
|
||||
`logging = "logging: levels, structure"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("the result lost %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A table whose keys are already sorted keeps its order; one ordered by hand
|
||||
// gets the entry appended, so that a grouping by directory survives.
|
||||
func TestAddEntryRespectsExistingOrder(t *testing.T) {
|
||||
sorted := `[topics.live]
|
||||
config = "c"
|
||||
time = "t"
|
||||
`
|
||||
got, err := manifest.AddEntry([]byte(sorted), "topics.live", "logging", "l")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantSorted := "[topics.live]\nconfig = \"c\"\nlogging = \"l\"\ntime = \"t\"\n"
|
||||
if string(got) != wantSorted {
|
||||
t.Errorf("a sorted table was not kept sorted:\n%s", got)
|
||||
}
|
||||
|
||||
grouped := `[prefixes.live]
|
||||
TIME = "conventions/arch/time.md"
|
||||
CONF = "conventions/arch/config.md"
|
||||
GTIM = "conventions/lang/go/time.md"
|
||||
`
|
||||
got, err = manifest.AddEntry([]byte(grouped), "prefixes.live", "GCFG", "conventions/lang/go/config.md")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasSuffix(strings.TrimRight(string(got), "\n"), `GCFG = "conventions/lang/go/config.md"`) {
|
||||
t.Errorf("a hand-ordered table did not get the entry appended:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddEntryIntoEmptyTable(t *testing.T) {
|
||||
source := `[topics.live]
|
||||
# Nothing yet.
|
||||
|
||||
[topics.retired]
|
||||
`
|
||||
got, err := manifest.AddEntry([]byte(source), "topics.live", "time", "time")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "[topics.live]\n# Nothing yet.\ntime = \"time\"\n\n[topics.retired]\n"
|
||||
if string(got) != want {
|
||||
t.Errorf("insertion into an empty table went wrong:\n%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A comment block separated from an empty table by a blank line explains the
|
||||
// table header standing below it, not the one above. Filing an entry after such
|
||||
// a block puts it under the wrong explanation.
|
||||
func TestAddEntryIntoEmptyTableStopsBeforeTheNextComment(t *testing.T) {
|
||||
source := `[topics.live]
|
||||
|
||||
# Retired names land here together with a reason and a date.
|
||||
|
||||
[topics.retired]
|
||||
`
|
||||
got, err := manifest.AddEntry([]byte(source), "topics.live", "time", "time")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "[topics.live]\ntime = \"time\"\n\n# Retired names land here together with a reason and a date.\n\n[topics.retired]\n"
|
||||
if string(got) != want {
|
||||
t.Errorf("the entry was filed under the wrong comment:\n%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddEntryRejectsDuplicateAndMissingTable(t *testing.T) {
|
||||
source := "[topics.live]\ntime = \"t\"\n"
|
||||
if _, err := manifest.AddEntry([]byte(source), "topics.live", "time", "t"); err == nil {
|
||||
t.Error("a duplicate key was accepted")
|
||||
}
|
||||
if _, err := manifest.AddEntry([]byte(source), "prefixes.live", "TIME", "x.md"); err == nil {
|
||||
t.Error("a missing table was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddEntryQuotesWhatIsNotBare(t *testing.T) {
|
||||
source := "[topics.live]\n"
|
||||
got, err := manifest.AddEntry([]byte(source), "topics.live", "web ui", `a "quoted" thing`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(got), `"web ui" = "a \"quoted\" thing"`) {
|
||||
t.Errorf("key or value was not escaped:\n%s", got)
|
||||
}
|
||||
}
|
||||
@@ -1,340 +0,0 @@
|
||||
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, `\\`, `\`)
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
package manifest_test
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
|
||||
"git.vakhrushev.me/av/convy/internal/manifest"
|
||||
)
|
||||
|
||||
const projectSource = `# What this repository takes.
|
||||
source = "../conventions"
|
||||
|
||||
# A component is a region where every chosen layer holds at once.
|
||||
[components.backend]
|
||||
dir = "backend/docs/conventions"
|
||||
lang = ["go"]
|
||||
topics = ["errors", "time"]
|
||||
|
||||
[components.web]
|
||||
dir = "web/docs/conventions"
|
||||
topics = [
|
||||
"client-logging",
|
||||
]
|
||||
`
|
||||
|
||||
func TestAddToListKeepsTheShapeOfTheArray(t *testing.T) {
|
||||
out, err := manifest.AddToList([]byte(projectSource), "components.backend", "topics", "logging")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := string(out)
|
||||
if !strings.Contains(body, `topics = ["errors", "logging", "time"]`) {
|
||||
t.Errorf("a sorted one-line array did not stay sorted and on one line:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "# A component is a region where every chosen layer holds at once.") {
|
||||
t.Errorf("the comment did not survive the edit:\n%s", body)
|
||||
}
|
||||
|
||||
out, err = manifest.AddToList(out, "components.web", "topics", "auth")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "topics = [\n \"auth\",\n \"client-logging\",\n]"
|
||||
if !strings.Contains(string(out), want) {
|
||||
t.Errorf("an array written down a column did not stay a column:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddToListCreatesTheKeyItDoesNotFind(t *testing.T) {
|
||||
out, err := manifest.AddToList([]byte(projectSource), "components.web", "stack", "express")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(out), `stack = ["express"]`) {
|
||||
t.Errorf("the missing key was not created:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddToListRefusesWhatIsThereAlready(t *testing.T) {
|
||||
_, err := manifest.AddToList([]byte(projectSource), "components.backend", "topics", "time")
|
||||
if err == nil || !strings.Contains(err.Error(), "already holds") {
|
||||
t.Errorf("a repeated subscription went through: %v", err)
|
||||
}
|
||||
if _, err := manifest.AddToList([]byte(projectSource), "components.mobile", "topics", "time"); err == nil {
|
||||
t.Errorf("a table that is not there took an entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveFromList(t *testing.T) {
|
||||
out, err := manifest.RemoveFromList([]byte(projectSource), "components.backend", "topics", "errors")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(out), `topics = ["time"]`) {
|
||||
t.Errorf("the value was not removed:\n%s", out)
|
||||
}
|
||||
if _, err := manifest.RemoveFromList([]byte(projectSource), "components.backend", "topics", "logging"); err == nil {
|
||||
t.Errorf("removing what is not there went through")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddTableGoesLast(t *testing.T) {
|
||||
out, err := manifest.AddTable([]byte(projectSource), "components.mobile",
|
||||
[][2]string{{"dir", `"mobile/docs/conventions"`}, {"topics", "[]"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := string(out)
|
||||
if !strings.Contains(body, "[components.mobile]\ndir = \"mobile/docs/conventions\"\ntopics = []") {
|
||||
t.Errorf("the table was not written:\n%s", body)
|
||||
}
|
||||
if strings.Index(body, "[components.mobile]") < strings.Index(body, "[components.web]") {
|
||||
t.Errorf("the new table did not go last:\n%s", body)
|
||||
}
|
||||
if _, err := manifest.AddTable(out, "components.mobile", nil); err == nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,24 @@
|
||||
// Package manifest reads suite.toml, the manifest of a conventions suite.
|
||||
// Package manifest reads and writes the two manifests of the model.
|
||||
//
|
||||
// The manifest declares three things: the language the suite's rules are
|
||||
// The suite manifest declares three things: the language the suite's rules are
|
||||
// written in, its live and retired topics, and its live and retired rule
|
||||
// prefixes together with the paths of their files. The tool knows no topic and
|
||||
// no prefix in advance — that whole list arrives from here.
|
||||
//
|
||||
// Both manifests are data the tool edits, so both are decoded into structs and
|
||||
// written back out of them. They carry no comments: a file a machine rewrites
|
||||
// cannot keep a comment through the round trip, and pretending otherwise costs
|
||||
// the comment on a day nobody is watching. What a topic is for is said in the
|
||||
// documents next to the manifest, which no command touches.
|
||||
//
|
||||
// Because a write goes out of the structs, a key the tool does not know would
|
||||
// disappear on the next edit. So it does not write at all while one is there:
|
||||
// a refusal naming the key is the only outcome that neither loses it nor hides
|
||||
// it.
|
||||
package manifest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -38,25 +50,44 @@ const DefaultLanguageCode = "ru"
|
||||
// changing — the vocabulary is picked by version and code either way.
|
||||
type Language struct {
|
||||
Version int `toml:"version"`
|
||||
Lang string `toml:"lang"`
|
||||
Source string `toml:"source"`
|
||||
Description string `toml:"description"`
|
||||
Reading string `toml:"reading"`
|
||||
Lang string `toml:"lang,omitempty"`
|
||||
Source string `toml:"source,omitempty"`
|
||||
Description string `toml:"description,omitempty"`
|
||||
Reading string `toml:"reading,omitempty"`
|
||||
}
|
||||
|
||||
// Section is a part of the manifest split into a live and a retired half.
|
||||
// Retired entries are kept rather than deleted: a topic name and a rule prefix
|
||||
// live on in foreign repositories, and neither may ever be reused.
|
||||
type Section struct {
|
||||
Live map[string]string `toml:"live"`
|
||||
Retired map[string]string `toml:"retired"`
|
||||
Live map[string]string `toml:"live,omitempty"`
|
||||
Retired map[string]string `toml:"retired,omitempty"`
|
||||
}
|
||||
|
||||
// Add puts an entry into the live half, making the map if there is none.
|
||||
func (s *Section) Add(key, value string) {
|
||||
if s.Live == nil {
|
||||
s.Live = make(map[string]string)
|
||||
}
|
||||
s.Live[key] = value
|
||||
}
|
||||
|
||||
// Retire moves an entry out of the live half into the retired one. A name is
|
||||
// never deleted and never reissued: it lives on in foreign repositories, and a
|
||||
// name handed out twice starts pointing at something else there.
|
||||
func (s *Section) Retire(key, note string) {
|
||||
delete(s.Live, key)
|
||||
if s.Retired == nil {
|
||||
s.Retired = make(map[string]string)
|
||||
}
|
||||
s.Retired[key] = note
|
||||
}
|
||||
|
||||
// Manifest is a parsed suite.toml.
|
||||
type Manifest struct {
|
||||
Language Language `toml:"language"`
|
||||
Topics Section `toml:"topics"`
|
||||
Prefixes Section `toml:"prefixes"`
|
||||
Topics Section `toml:"topics,omitempty"`
|
||||
Prefixes Section `toml:"prefixes,omitempty"`
|
||||
|
||||
// Path is where the manifest was read from.
|
||||
Path string `toml:"-"`
|
||||
@@ -91,6 +122,33 @@ func Load(root string) (*Manifest, error) {
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// Save writes the manifest back to where it was read from.
|
||||
func (m *Manifest) Save() error {
|
||||
return save(m.Path, m, m.Undecoded)
|
||||
}
|
||||
|
||||
// save encodes a manifest and puts it in place.
|
||||
func save(path string, value any, undecoded []string) error {
|
||||
if len(undecoded) > 0 {
|
||||
return fmt.Errorf("%s holds %s the tool does not know (%s); a write goes out of what the tool understands, so the key would be dropped — fix the spelling first",
|
||||
path, plural(len(undecoded), "key"), strings.Join(undecoded, ", "))
|
||||
}
|
||||
var b bytes.Buffer
|
||||
enc := toml.NewEncoder(&b)
|
||||
enc.Indent = ""
|
||||
if err := enc.Encode(value); err != nil {
|
||||
return fmt.Errorf("encoding %s: %w", path, err)
|
||||
}
|
||||
return os.WriteFile(path, b.Bytes(), 0o644)
|
||||
}
|
||||
|
||||
func plural(n int, noun string) string {
|
||||
if n == 1 {
|
||||
return fmt.Sprintf("%d %s", n, noun)
|
||||
}
|
||||
return fmt.Sprintf("%d %ss", n, noun)
|
||||
}
|
||||
|
||||
// Find walks up from start looking for a directory that holds a suite
|
||||
// manifest, so that `convy suite check` works from any subdirectory of a suite.
|
||||
func Find(start string) (string, error) {
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package manifest_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/convy/internal/manifest"
|
||||
)
|
||||
|
||||
func write(t *testing.T, dir, name, body string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// A manifest is data, and a command that changes it rewrites it whole. The
|
||||
// second write of the same content has to come out the same, or every command
|
||||
// would leave a diff of its own on top of the one it meant.
|
||||
func TestSaveIsIdempotent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write(t, dir, manifest.Name, `[language]
|
||||
version = 1
|
||||
lang = "ru"
|
||||
|
||||
[topics.live]
|
||||
time = "время"
|
||||
|
||||
[prefixes.live]
|
||||
TIME = "conventions/time.md"
|
||||
`)
|
||||
|
||||
m, err := manifest.Load(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
once, err := os.ReadFile(m.Path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
again, err := manifest.Load(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("the manifest the tool wrote does not load: %v\n%s", err, once)
|
||||
}
|
||||
if err := again.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
twice, err := os.ReadFile(m.Path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(once) != string(twice) {
|
||||
t.Errorf("the second write differs from the first:\n%s\n---\n%s", once, twice)
|
||||
}
|
||||
}
|
||||
|
||||
// A write goes out of the structs, so a key the tool does not know would be
|
||||
// dropped. It refuses instead: that neither loses the key nor hides it.
|
||||
func TestSaveRefusesWhileAKeyIsUnknown(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write(t, dir, manifest.Name, `[language]
|
||||
version = 1
|
||||
descriptoin = "LANGUAGE.md"
|
||||
`)
|
||||
|
||||
m, err := manifest.Load(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, _ := os.ReadFile(m.Path)
|
||||
|
||||
err = m.Save()
|
||||
if err == nil {
|
||||
t.Fatal("the manifest was rewritten over a key the tool does not know")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "descriptoin") {
|
||||
t.Errorf("the refusal does not name the key: %s", err)
|
||||
}
|
||||
after, _ := os.ReadFile(m.Path)
|
||||
if string(before) != string(after) {
|
||||
t.Errorf("the file was touched anyway:\n%s", after)
|
||||
}
|
||||
}
|
||||
|
||||
// A name never leaves the manifest: it lives on in foreign repositories, and
|
||||
// one handed out twice starts pointing at something else there.
|
||||
func TestRetireMovesRatherThanDeletes(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write(t, dir, manifest.Name, `[language]
|
||||
version = 1
|
||||
|
||||
[topics.live]
|
||||
time = "время"
|
||||
logging = "логирование"
|
||||
`)
|
||||
|
||||
m, err := manifest.Load(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Topics.Retire("logging", "2026-07-28: свёрнута в errors")
|
||||
if err := m.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
back, err := manifest.Load(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if back.TopicLive("logging") {
|
||||
t.Errorf("the topic stayed live")
|
||||
}
|
||||
if !back.TopicRetired("logging") {
|
||||
t.Errorf("the topic is neither live nor retired: the name is loose")
|
||||
}
|
||||
if !back.TopicLive("time") {
|
||||
t.Errorf("the other topic went with it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := &manifest.Project{
|
||||
Source: "../dev-conventions#v2",
|
||||
Components: map[string]manifest.Component{
|
||||
"backend": {Dir: `docs\conventions`, Lang: []string{"go"}, Topics: []string{"time"}},
|
||||
"web": {Dir: "web/docs", Topics: []string{}},
|
||||
},
|
||||
Path: filepath.Join(dir, manifest.ProjectName),
|
||||
Root: dir,
|
||||
}
|
||||
if err := p.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
back, err := manifest.LoadProject(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("the manifest the tool wrote does not load: %v", err)
|
||||
}
|
||||
if back.Source != p.Source {
|
||||
t.Errorf("source came back as %q", back.Source)
|
||||
}
|
||||
// A backslash is the ordinary way a written value fails to read back.
|
||||
if got := back.Components["backend"].Dir; got != `docs\conventions` {
|
||||
t.Errorf("the directory came back as %q", got)
|
||||
}
|
||||
if len(back.Components["web"].Lang) != 0 {
|
||||
t.Errorf("an empty axis was written and read back as something")
|
||||
}
|
||||
|
||||
back.Subscribe("web", "logging")
|
||||
back.Subscribe("web", "errors")
|
||||
if got := back.Components["web"].Topics; strings.Join(got, ",") != "errors,logging" {
|
||||
t.Errorf("the subscription is not kept in order: %v", got)
|
||||
}
|
||||
back.Unsubscribe("web", "errors")
|
||||
if got := back.Components["web"].Topics; strings.Join(got, ",") != "logging" {
|
||||
t.Errorf("unsubscribing left %v", got)
|
||||
}
|
||||
}
|
||||
@@ -26,8 +26,8 @@ const ProjectName = ".conventions.toml"
|
||||
// written in one of them, which is what a component exists to separate.
|
||||
type Component struct {
|
||||
Dir string `toml:"dir"`
|
||||
Lang []string `toml:"lang"`
|
||||
Stack []string `toml:"stack"`
|
||||
Lang []string `toml:"lang,omitempty"`
|
||||
Stack []string `toml:"stack,omitempty"`
|
||||
Topics []string `toml:"topics"`
|
||||
}
|
||||
|
||||
@@ -71,6 +71,28 @@ func LoadProject(root string) (*Project, error) {
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// Save writes the project manifest back to where it was read from.
|
||||
func (p *Project) Save() error {
|
||||
return save(p.Path, p, p.Undecoded)
|
||||
}
|
||||
|
||||
// Subscribe adds a topic to a component, keeping the list sorted so that the
|
||||
// file does not churn on the order things were added in.
|
||||
func (p *Project) Subscribe(name, topic string) {
|
||||
c := p.Components[name]
|
||||
c.Topics = append(c.Topics, topic)
|
||||
sort.Strings(c.Topics)
|
||||
p.Components[name] = c
|
||||
}
|
||||
|
||||
// Unsubscribe drops a topic from a component. Nothing is kept behind: a
|
||||
// subscription is a choice of the project, not a name anyone else may reuse.
|
||||
func (p *Project) Unsubscribe(name, topic string) {
|
||||
c := p.Components[name]
|
||||
c.Topics = slices.DeleteFunc(c.Topics, func(t string) bool { return t == topic })
|
||||
p.Components[name] = c
|
||||
}
|
||||
|
||||
// FindProject walks up from start looking for a project manifest, so that a
|
||||
// command works from any subdirectory of a repository.
|
||||
func FindProject(start string) (string, error) {
|
||||
|
||||
Reference in New Issue
Block a user