suite init и suite add: заведение набора и конвенции в двух режимах
- suite init создаёт директорию и манифест со скелетом таблиц; suite add пишет файл конвенции и вправляет запись в suite.toml, сохраняя комментарии - без аргументов команды спрашивают поля с подсказками, с флагами берут всё сразу и не спрашивают ничего; без терминала пустой вызов отказывает - строка о версии языка генерируется из словаря набора, поэтому созданный файл проходит suite check без правок - починено разрешение extends: короткая форма бралась по суффиксу и могла указать на сам файл; теперь неоднозначность либо избегается при записи, либо сообщается ошибкой
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
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
|
||||
}
|
||||
|
||||
// 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]
|
||||
}
|
||||
}
|
||||
}
|
||||
return at[len(at)-1] + 1
|
||||
}
|
||||
|
||||
// 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) + `"`
|
||||
}
|
||||
return key + ` = "` + escape(value) + `"`
|
||||
}
|
||||
|
||||
func escape(s string) string {
|
||||
s = strings.ReplaceAll(s, `\`, `\\`)
|
||||
return strings.ReplaceAll(s, `"`, `\"`)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user