манифесты стали данными, заведён convy sync
- убраны комментарии из suite.toml и .conventions.toml: файл, который машина переписывает, комментарий через круг не проносит; объяснения ушли в README рядом, который suite init теперь заводит - удалена текстовая правка манифеста целиком — 520 строк ручного лексера TOML вместе со всем классом ошибок порчи данных - запись идёт из структур энкодером; ключ, которого инструмент не знает, запись останавливает, а не теряется молча - convy sync сверяет манифест и подводит под него раскладку файлов: чего не хватает — собирает, что осиротело — удаляет, копию с локальной частью не трогает никогда
This commit is contained in:
@@ -34,7 +34,7 @@ CLI для управления конвенциями. Модель здесь
|
||||
```
|
||||
internal/lang словарь: реестр «версия языка × естественный язык»
|
||||
internal/source ссылки между уровнями: путь на диске, git-репозиторий
|
||||
internal/manifest suite.toml и .conventions.toml — чтение и текстовая правка
|
||||
internal/manifest suite.toml и .conventions.toml — чтение и запись
|
||||
internal/doc разбор документа: шапка, области правил, блоки
|
||||
internal/suite сборка набора в память, отбор слоёв под компонент
|
||||
internal/project сборка копий в проекте: разделы, маркер, READING.md
|
||||
@@ -59,11 +59,14 @@ internal/cli команды, диалог, два режима
|
||||
- **Что инструмент пишет, инструмент принимает.** Набор, созданный `suite init`,
|
||||
`add` и `rule`, обязан проходить `suite check` без правок. Это проверяет
|
||||
`checkClean` в `internal/cli`; ломать инвариант нельзя.
|
||||
- **Манифест правится текстом, а не энкодером.** В `suite.toml` комментариев
|
||||
больше, чем данных. `manifest.AddEntry` вставляет запись, подстраиваясь под
|
||||
порядок таблицы: отсортированную по алфавиту держит отсортированной,
|
||||
упорядоченную вручную дополняет в конец. Комментарий, отделённый пустой
|
||||
строкой, принадлежит таблице **ниже** себя.
|
||||
- **Манифест — данные.** Оба манифеста декодируются в структуры и пишутся
|
||||
обратно энкодером целиком. Комментариев в них нет: файл, который машина
|
||||
переписывает, комментарий через круг не проносит, и вид, что проносит, стоит
|
||||
этого комментария в день, когда никто не смотрит. Объяснения — в соседних
|
||||
файлах, которых ни одна команда не касается.
|
||||
- **Непонятый ключ останавливает запись.** Раз запись идёт из структур, ключ,
|
||||
которого в них нет, при сохранении исчез бы. `manifest.save` отказывается,
|
||||
называя ключ: это единственный исход, который его не теряет и не прячет.
|
||||
- **Разбор опирается на разметку, а не на суждение.** Область правила — от
|
||||
заголовка до следующего заголовка любого уровня. Метка открывает блок только
|
||||
первой в абзаце и полужирным. Огороженные блоки кода исключаются везде;
|
||||
@@ -71,11 +74,6 @@ internal/cli команды, диалог, два режима
|
||||
пути канона. Маркер локальной части — то же самое: `doc.LocalMarker` один на
|
||||
весь инструмент, `doc.Marker()` пропускает огороженные блоки, потому что
|
||||
конвенция о ведении копий этот маркер цитирует.
|
||||
- **Манифест читается так, как он записан.** Решётка внутри строки не открывает
|
||||
комментарий, скобка внутри комментария не закрывает массив, имя внутри
|
||||
комментария не подписка. Регуляркой по сырым строкам это не берётся —
|
||||
`splitComment` и `scanCode` в `internal/manifest`. Превращение комментария в
|
||||
данные — единственная ошибка, из которой нет дороги назад.
|
||||
- **Уровень называется ссылкой, а не путём.** Проект ссылается на набор, набор
|
||||
на язык; `source.Ref` разбирает ссылку, `source.Open` отдаёт директорию,
|
||||
которую можно читать. Транспортов два, но `Kind` — перечисление, а не булево:
|
||||
@@ -124,9 +122,16 @@ internal/cli команды, диалог, два режима
|
||||
Проверка гоняется на каждой правке и в сеть ходить не должна. Пропуск
|
||||
объявляется строкой в выводе: молча не выполненная проверка читается ровно
|
||||
как пройденная.
|
||||
- **Всё, что попадает в манифест, проходит через `manifest.Quote`.** Обратный
|
||||
слэш в пути — обычный случай, на котором инструмент перестаёт читать файл,
|
||||
который сам записал.
|
||||
- **Комментариев в манифестах не будет.** Пробовали держать их текстовой
|
||||
правкой — вышло четыре случая порчи данных подряд: комментарий с кавычками
|
||||
становился подпиской, скобка в комментарии обрезала массив. Формат с
|
||||
сохранением комментариев при записи (`go-toml-edit`, YAML через `yaml.Node`)
|
||||
отвергнут как усложнение под задачу, которой нет: манифест машинный.
|
||||
- **`sync` — о наборе файлов, `pull` — о содержимом.** `pull` берёт текст всех
|
||||
подписок заново, и оставленный им дифф и есть смысл запуска. `sync` сверяет
|
||||
манифест и подводит под него раскладку: чего не хватает — собирает, что
|
||||
осиротело — удаляет. Копию с непустой локальной частью не удаляет никогда и
|
||||
завершается с ошибкой, пока она лежит.
|
||||
- **Позиционный аргумент отсекается явно.** `flag` прекращает разбор на первом
|
||||
не-флаге, поэтому лишний аргумент не просто лежит без дела — он прячет все
|
||||
флаги после себя. `noStrayArgs` в командах без позиционных, ручное снятие
|
||||
@@ -203,11 +208,12 @@ Commits и `Co-Authored-By` не используются.
|
||||
## Состояние
|
||||
|
||||
Наборная сторона: `init`, `add`, `rule`, `retire`, `list`, `check`.
|
||||
Проектная: `init`, `add`, `pull`, `list`, `check`. Обе стороны закончены по
|
||||
тому, что намечено в `TOOL.md`.
|
||||
Проектная: `init`, `add`, `pull`, `sync`, `list`, `check`. Обе стороны
|
||||
закончены по тому, что намечено в `TOOL.md`; `sync` в `TOOL.md` не значится и
|
||||
заведён сверх него.
|
||||
|
||||
Отбор слоёв под компонент — один на обе стороны: `suite.Assemble`. `suite list`
|
||||
показывает, что взял бы компонент, `convy pull` то же самое пишет в файл;
|
||||
разъехаться они не должны.
|
||||
показывает, что взял бы компонент, `convy pull` и `convy sync` то же самое
|
||||
пишут в файл; разъехаться они не должны.
|
||||
|
||||
Линтеров и CI нет.
|
||||
|
||||
@@ -84,7 +84,8 @@ go build -o convy .
|
||||
В проекте:
|
||||
convy init подключить конвенции: источник и первый компонент
|
||||
convy add <тема> подписаться и собрать
|
||||
convy pull пересобрать подписанное
|
||||
convy pull пересобрать подписанное, текст и всё
|
||||
convy sync привести файлы в соответствие манифесту
|
||||
convy list что подключено и что ещё есть в наборе
|
||||
convy check проверить форму того, что здесь
|
||||
```
|
||||
@@ -180,6 +181,52 @@ topics = ["logging", "time"]
|
||||
`postgres` действуют вместе, это разные таблицы одного сервиса. Два языка не
|
||||
действуют вместе никогда — ради этого компонент и заведён.
|
||||
|
||||
## Манифесты — данные, а не текст
|
||||
|
||||
Оба манифеста инструмент и читает, и переписывает целиком. Поэтому комментариев
|
||||
в них нет: файл, который машина переписывает, комментарий через круг не
|
||||
проносит, а вид, что проносит, стоит этого комментария в день, когда никто не
|
||||
смотрит. Объяснения живут в соседних файлах, которых ни одна команда не
|
||||
касается: `convy suite init` заводит рядом `README.md` и пишет их туда.
|
||||
|
||||
Ключ, которого инструмент не знает, при записи потерялся бы. Поэтому он не
|
||||
пишет вовсе:
|
||||
|
||||
```
|
||||
$ convy suite add --topic time --about "время" --prefix TIME --title "Время"
|
||||
suite.toml holds 1 key the tool does not know (language.descriptoin); a write
|
||||
goes out of what the tool understands, so the key would be dropped — fix the
|
||||
spelling first
|
||||
```
|
||||
|
||||
## Манифест — источник истины
|
||||
|
||||
`.conventions.toml` правится руками так же законно, как командой. Дальше
|
||||
раскладку под него подводит `sync`:
|
||||
|
||||
```
|
||||
$ convy sync --dry-run
|
||||
backend → docs/conventions
|
||||
+ docs/conventions/errors.md subscribed, and no file
|
||||
- docs/conventions/logging.md nothing subscribes to "logging"
|
||||
|
||||
2 files would change; run without --dry-run to do it
|
||||
```
|
||||
|
||||
Деление с `pull` проходит по тому, о чём команда. `pull` — о содержимом:
|
||||
берёт текст всех подписок заново, и оставленный им дифф и есть смысл запуска.
|
||||
`sync` — о наборе файлов: чего манифест требует и нет — собирается, что есть и
|
||||
никому не нужно — удаляется.
|
||||
|
||||
Копия с локальной частью не удаляется никогда: ниже маркера лежит
|
||||
единственное, чего нет больше нигде. Такая копия называется в отчёте, и `sync`
|
||||
завершается с ошибкой, пока её не убрали руками или не подписались снова.
|
||||
|
||||
Перед тем как что-то трогать, `sync` сверяет манифест: подписка на снятую или
|
||||
несуществующую тему, тема дважды, два языка в одном компоненте, тема без
|
||||
подходящего слоя, общая директория у двух компонентов. Находки называются
|
||||
разом, и ничего не пишется.
|
||||
|
||||
Копия плоская, файл на тему. Первый слой — сам документ; каждый следующий
|
||||
становится его разделом, и заголовки внутри опускаются на уровень: слой
|
||||
реализует и сужает базу, а не стоит рядом с ней. Строка о версии языка
|
||||
@@ -255,4 +302,6 @@ errors: 1, warnings: 0
|
||||
- не переносит правки из проекта в набор: операция ручная и редкая;
|
||||
- не перенумеровывает правила: номер — идентификатор, а не позиция;
|
||||
- не кэширует источник: клон делается заново и удаляется;
|
||||
- не знает нескольких наборов сразу: `source` в проекте один.
|
||||
- не знает нескольких наборов сразу: `source` в проекте один;
|
||||
- не хранит комментарии в манифестах: они данные, а объяснения — в соседних
|
||||
файлах.
|
||||
|
||||
+2
-12
@@ -3,7 +3,6 @@ package cli
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.vakhrushev.me/av/convy/internal/manifest"
|
||||
@@ -96,17 +95,8 @@ func runAdd(env Env, args []string) ExitCode {
|
||||
// The manifest is written after the file: a subscription recorded against
|
||||
// an assembly that failed would send the next pull looking for a copy that
|
||||
// was never made.
|
||||
source, err := os.ReadFile(o.Manifest.Path)
|
||||
if err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return Failed
|
||||
}
|
||||
updated, err := manifest.AddToList(source, "components."+name, "topics", topic)
|
||||
if err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return Failed
|
||||
}
|
||||
if err := os.WriteFile(o.Manifest.Path, updated, 0o644); err != nil {
|
||||
o.Manifest.Subscribe(name, topic)
|
||||
if err := o.Manifest.Save(); err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return Failed
|
||||
}
|
||||
|
||||
+4
-1
@@ -55,6 +55,8 @@ func Run(env Env, args []string) ExitCode {
|
||||
return runAdd(env, args[1:])
|
||||
case "pull":
|
||||
return runPull(env, args[1:])
|
||||
case "sync":
|
||||
return runSync(env, args[1:])
|
||||
case "list":
|
||||
return runList(env, args[1:])
|
||||
case "check":
|
||||
@@ -100,7 +102,8 @@ func usage(w io.Writer) {
|
||||
In a project:
|
||||
convy init wire up conventions: the source and the first component
|
||||
convy add <topic> subscribe and assemble
|
||||
convy pull reassemble what is subscribed
|
||||
convy pull reassemble what is subscribed, text and all
|
||||
convy sync make the files follow the manifest, and say what is off
|
||||
convy list what is wired up and what else the suite has
|
||||
convy check check the form of what is here
|
||||
|
||||
|
||||
+18
-55
@@ -5,42 +5,16 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.vakhrushev.me/av/convy/internal/manifest"
|
||||
"git.vakhrushev.me/av/convy/internal/source"
|
||||
"git.vakhrushev.me/av/convy/internal/suite"
|
||||
)
|
||||
|
||||
// projectSkeleton is what a project manifest starts as. It is written as text
|
||||
// with its comments in place for the same reason the suite manifest is: the
|
||||
// file is read far more often than a tool touches it, and what a component is
|
||||
// for is not deducible from three keys.
|
||||
const projectSkeleton = `# What this repository takes from a conventions suite.
|
||||
#
|
||||
# Two manifests exist in the model, each named after what it describes:
|
||||
# suite.toml in a suite describes the suite itself; .conventions.toml here
|
||||
# describes the subscription — where the copies come from and who takes what.
|
||||
#
|
||||
# Copies are committed. Nothing is fetched on the fly, and the answer to "what
|
||||
# did it look like last time" is given by git rather than by a lock file.
|
||||
|
||||
# Where the copies come from: a path on disk, relative to this file or absolute,
|
||||
# or a git repository over http or https. A trailing #branch, #tag or #commit
|
||||
# pins a revision.
|
||||
source = %s
|
||||
|
||||
# ─── Components ─────────────────────────────────────────────────────────────
|
||||
#
|
||||
# A component is a region of the repository where every selected layer holds at
|
||||
# once: one language, one set of tools, one kind of application. Each one has
|
||||
# its own directory, and the directories differ — that is the only thing that
|
||||
# tells two copies of one topic apart.
|
||||
#
|
||||
# lang and stack choose the layers: a layer travels when the axis it declares is
|
||||
# among them, and a layer declaring no axis travels always. topics is the
|
||||
# subscription itself, by the names the suite gives its topics.
|
||||
`
|
||||
// A project manifest is data, and the tool rewrites it whole on every
|
||||
// subscription. So it carries no comment: what a component is for is said in
|
||||
// the documentation, which stays put, rather than in a file that a machine
|
||||
// re-encodes behind the author's back.
|
||||
|
||||
func runInit(env Env, args []string) ExitCode {
|
||||
fs := flag.NewFlagSet("convy init", flag.ContinueOnError)
|
||||
@@ -105,27 +79,25 @@ func runInit(env Env, args []string) ExitCode {
|
||||
return Failed
|
||||
}
|
||||
|
||||
entries := [][2]string{{"dir", manifest.Quote(given["dir"])}}
|
||||
if list := split(given["lang"]); len(list) > 0 {
|
||||
entries = append(entries, [2]string{"lang", array(list)})
|
||||
}
|
||||
if list := split(given["stack"]); len(list) > 0 {
|
||||
entries = append(entries, [2]string{"stack", array(list)})
|
||||
}
|
||||
entries = append(entries, [2]string{"topics", "[]"})
|
||||
|
||||
body, err := manifest.AddTable(fmt.Appendf(nil, projectSkeleton, manifest.Quote(given["source"])),
|
||||
"components."+given["component"], entries)
|
||||
if err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return Failed
|
||||
}
|
||||
if err := os.MkdirAll(where, 0o755); err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return Failed
|
||||
}
|
||||
name := filepath.Join(where, manifest.ProjectName)
|
||||
if err := os.WriteFile(name, body, 0o644); err != nil {
|
||||
m := &manifest.Project{
|
||||
Source: given["source"],
|
||||
Components: map[string]manifest.Component{
|
||||
given["component"]: {
|
||||
Dir: given["dir"],
|
||||
Lang: split(given["lang"]),
|
||||
Stack: split(given["stack"]),
|
||||
Topics: []string{},
|
||||
},
|
||||
},
|
||||
Path: name,
|
||||
Root: where,
|
||||
}
|
||||
if err := m.Save(); err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return Failed
|
||||
}
|
||||
@@ -181,12 +153,3 @@ func initFields() []Field {
|
||||
Optional: true,
|
||||
}}
|
||||
}
|
||||
|
||||
// array writes a TOML array of strings.
|
||||
func array(values []string) string {
|
||||
parts := make([]string, len(values))
|
||||
for i, v := range values {
|
||||
parts[i] = manifest.Quote(v)
|
||||
}
|
||||
return "[" + strings.Join(parts, ", ") + "]"
|
||||
}
|
||||
|
||||
@@ -11,8 +11,22 @@ import (
|
||||
"git.vakhrushev.me/av/convy/internal/check"
|
||||
"git.vakhrushev.me/av/convy/internal/cli"
|
||||
"git.vakhrushev.me/av/convy/internal/doc"
|
||||
"git.vakhrushev.me/av/convy/internal/manifest"
|
||||
)
|
||||
|
||||
// addComponent puts one more component into the project manifest.
|
||||
func addComponent(t *testing.T, root, name string, c manifest.Component) {
|
||||
t.Helper()
|
||||
m, err := manifest.LoadProject(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Components[name] = c
|
||||
if err := m.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// readingGuide stands in for what a suite puts next to its copies: the short
|
||||
// account of the language, naming every word the version line names.
|
||||
const readingGuide = `# Как читать конвенцию
|
||||
@@ -43,21 +57,44 @@ func subscribable(t *testing.T) string {
|
||||
**ДОЛЖЕН.** Текущее время приходит из `+"`store.Now()`"+`.
|
||||
|
||||
**ПОЧЕМУ.** Единая точка даёт гарантированный UTC и один формат.
|
||||
`)
|
||||
|
||||
// A topic whose only layer sits on an axis: a component of another stack
|
||||
// takes nothing of it at all. The canon has such topics, and a fixture
|
||||
// where every topic has a base layer would never exercise that.
|
||||
run(t, root, "", false, "suite", "add", "--topic", "web-ui", "--about", "веб-UI",
|
||||
"--prefix", "HTMX", "--stack", "htmx", "--title", "Веб-UI на htmx")
|
||||
appendRules(t, root, "conventions/stack/htmx/web-ui.md", `
|
||||
### HTMX-1. Партиал отвечает фрагментом, а не страницей
|
||||
|
||||
**ДОЛЖЕН.** Обработчик свопа возвращает только заменяемый фрагмент.
|
||||
|
||||
**ПОЧЕМУ.** Страница целиком заставляет браузер выбросить состояние формы.
|
||||
`)
|
||||
|
||||
name := filepath.Join(root, "READING.md")
|
||||
if err := os.WriteFile(name, []byte(readingGuide), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := read(t, root, "suite.toml")
|
||||
body = strings.Replace(body, `# reading = "READING.md"`, `reading = "READING.md"`, 1)
|
||||
if err := os.WriteFile(filepath.Join(root, "suite.toml"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
edit(t, root, func(m *manifest.Manifest) { m.Language.Reading = "READING.md" })
|
||||
checkClean(t, root)
|
||||
return root
|
||||
}
|
||||
|
||||
// edit changes the suite manifest the way the tool does: through the struct,
|
||||
// because the file is data and carries nothing else to preserve.
|
||||
func edit(t *testing.T, root string, change func(*manifest.Manifest)) {
|
||||
t.Helper()
|
||||
m, err := manifest.Load(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
change(m)
|
||||
if err := m.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// wired builds a project taking from that suite, and returns its root.
|
||||
func wired(t *testing.T, suiteRoot string, args ...string) string {
|
||||
t.Helper()
|
||||
@@ -260,11 +297,7 @@ func TestAddTakesTheTopicBeforeTheFlags(t *testing.T) {
|
||||
suiteRoot := subscribable(t)
|
||||
root := wired(t, suiteRoot, "--component", "backend", "--dir", "backend/docs", "--lang", "go")
|
||||
|
||||
body := read(t, root, ".conventions.toml")
|
||||
body += "\n[components.web]\ndir = \"web/docs\"\ntopics = []\n"
|
||||
if err := os.WriteFile(filepath.Join(root, ".conventions.toml"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addComponent(t, root, "web", manifest.Component{Dir: "web/docs", Topics: []string{}})
|
||||
|
||||
code, out := run(t, root, "", false, "add", "logging", "--for", "web")
|
||||
if code != cli.OK {
|
||||
@@ -285,11 +318,7 @@ func TestTwoComponentsMayNotShareADirectory(t *testing.T) {
|
||||
suiteRoot := subscribable(t)
|
||||
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
|
||||
|
||||
body := read(t, root, ".conventions.toml")
|
||||
body += "\n[components.web]\ndir = \"docs/conventions\"\ntopics = []\n"
|
||||
if err := os.WriteFile(filepath.Join(root, ".conventions.toml"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addComponent(t, root, "web", manifest.Component{Dir: "docs/conventions", Topics: []string{}})
|
||||
|
||||
code, out := run(t, root, "", false, "pull")
|
||||
if code == cli.OK {
|
||||
@@ -340,11 +369,7 @@ func TestTheLanguageMayLiveApartFromTheSuite(t *testing.T) {
|
||||
if err := os.Rename(filepath.Join(suiteRoot, "READING.md"), filepath.Join(apart, "READING.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := read(t, suiteRoot, "suite.toml")
|
||||
body = strings.Replace(body, `reading = "READING.md"`, "source = \"../language\"\nreading = \"READING.md\"", 1)
|
||||
if err := os.WriteFile(filepath.Join(suiteRoot, "suite.toml"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
edit(t, suiteRoot, func(m *manifest.Manifest) { m.Language.Source = "../language" })
|
||||
checkClean(t, suiteRoot)
|
||||
|
||||
// A check that did not run says so: silence would read as a check passed.
|
||||
|
||||
@@ -252,27 +252,14 @@ func writeConvention(env Env, s *suite.Suite, given map[string]string) ExitCode
|
||||
return Usage
|
||||
}
|
||||
|
||||
source, err := os.ReadFile(s.Manifest.Path)
|
||||
if err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return Failed
|
||||
}
|
||||
if !s.Manifest.TopicLive(given["topic"]) {
|
||||
source, err = manifest.AddEntry(source, "topics.live", given["topic"], given["about"])
|
||||
if err != nil {
|
||||
fmt.Fprintf(env.Err, "%s was written, but the manifest was not: %s\n", rel, err)
|
||||
return Failed
|
||||
}
|
||||
s.Manifest.Topics.Add(given["topic"], given["about"])
|
||||
}
|
||||
source, err = manifest.AddEntry(source, "prefixes.live", given["prefix"], rel)
|
||||
if err != nil {
|
||||
s.Manifest.Prefixes.Add(given["prefix"], rel)
|
||||
if err := s.Manifest.Save(); err != nil {
|
||||
fmt.Fprintf(env.Err, "%s was written, but the manifest was not: %s\n", rel, err)
|
||||
return Failed
|
||||
}
|
||||
if err := os.WriteFile(s.Manifest.Path, source, 0o644); err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return Failed
|
||||
}
|
||||
|
||||
fmt.Fprintf(env.Out, "\ncreated %s\n", rel)
|
||||
fmt.Fprintf(env.Out, "updated %s: prefix %s", manifest.Name, given["prefix"])
|
||||
|
||||
+59
-39
@@ -10,52 +10,56 @@ import (
|
||||
"git.vakhrushev.me/av/convy/internal/manifest"
|
||||
)
|
||||
|
||||
// manifestSkeleton is what a suite starts as. The tables stand empty but
|
||||
// present: `convy suite add` splices entries into them, and a table that is not
|
||||
// there is a table an edit cannot find.
|
||||
// A suite starts as a manifest holding only what it knows about itself, and a
|
||||
// README next to it holding everything a person needs to know to fill it in.
|
||||
//
|
||||
// The two documents about the language are left commented out on purpose. The
|
||||
// suite is expected to carry them, but the tool cannot author them, and a
|
||||
// manifest pointing at a file that does not exist is a manifest that fails its
|
||||
// own check on the first run.
|
||||
const manifestSkeleton = `# The manifest of a conventions suite.
|
||||
#
|
||||
# Two manifests exist in the model, each named after what it describes:
|
||||
# suite.toml here, in the suite, describes the suite itself; .conventions.toml
|
||||
# in a project describes what that project subscribed to. Which of the two lies
|
||||
# next to you tells you where you are.
|
||||
// The two are split because the manifest is data the tool rewrites on every
|
||||
// `suite add` and every `suite retire`, while the README is prose nothing
|
||||
// touches. Keeping the explanations inside the manifest would mean losing them
|
||||
// the first time a command wrote the file.
|
||||
//
|
||||
// The README is a starting point, in the tool's own language, for its author to
|
||||
// replace. What the suite itself is about, only its author knows.
|
||||
const readmeSkeleton = `# Conventions suite
|
||||
|
||||
[language]
|
||||
version = %d
|
||||
lang = "%s"
|
||||
# description = "LANGUAGE.md" # the full account of the language, stays with the author
|
||||
# reading = "READING.md" # the short guide for a reader, travels into every copy
|
||||
The rules live in ` + "`conventions/`" + `. What each file is and what it may do is
|
||||
settled by the conventions language; this README is the place to say what this
|
||||
particular suite is for and how it is kept.
|
||||
|
||||
# ─── Topics ─────────────────────────────────────────────────────────────────
|
||||
#
|
||||
# A topic is a set of rules about one focus of development, and the unit of
|
||||
# subscription. The value is the one line about what the topic is for; the
|
||||
# table of conventions in a consumer's README is built out of it.
|
||||
## The manifest
|
||||
|
||||
[topics.live]
|
||||
` + "`suite.toml`" + ` holds the identity of the suite: the language its rules are
|
||||
written in, its topics and its rule prefixes. It is written by ` + "`convy`" + ` and
|
||||
carries no comments — a command rewrites the whole file, and a comment would not
|
||||
survive that. Explanations belong here instead.
|
||||
|
||||
# Retired names land here together with a reason and a date, so that they can
|
||||
# never be handed to another topic: the name lives on in foreign repositories.
|
||||
## Topics
|
||||
|
||||
[topics.retired]
|
||||
A topic is a set of rules about one focus of development — time, configuration,
|
||||
the database schema — and it is the unit of subscription: a consumer takes it
|
||||
whole. A name is never renamed and never reissued, because it lives on in
|
||||
foreign repositories: in the ` + "`origin:`" + ` header of every copy and in the
|
||||
subscription of every consumer.
|
||||
|
||||
# ─── Rule prefixes ──────────────────────────────────────────────────────────
|
||||
#
|
||||
# A prefix is four uppercase Latin letters, unique across the suite, chosen for
|
||||
# a file rather than derived by a formula. The letter X is reserved for the
|
||||
# local rules of consumers and is never taken here. Paths are given from the
|
||||
# root of the repository.
|
||||
Since the name is permanent, a topic is named after a decision and whom it
|
||||
addresses rather than after the role some part of today's project plays.
|
||||
|
||||
[prefixes.live]
|
||||
## Prefixes
|
||||
|
||||
# Prefixes of deleted and split files land here, likewise never to be reissued.
|
||||
A prefix is four uppercase Latin letters, unique across the suite, chosen for a
|
||||
file rather than derived by a formula. Rules are addressed by identifier —
|
||||
` + "`KEYS-5`" + ` — with no path, so the identifier survives a file moving between
|
||||
axes. A prefix is never reissued either.
|
||||
|
||||
[prefixes.retired]
|
||||
The letter X in first position is reserved for the local rules of consuming
|
||||
repositories. The suite never takes it, so a local prefix can never collide with
|
||||
a future one here.
|
||||
|
||||
## Retirement
|
||||
|
||||
Nothing leaves the manifest. A topic or a prefix that is done moves to the
|
||||
retired half together with a reason and a date, so that the name can never be
|
||||
handed to something else.
|
||||
`
|
||||
|
||||
func runSuiteInit(env Env, args []string) ExitCode {
|
||||
@@ -108,17 +112,33 @@ func runSuiteInit(env Env, args []string) ExitCode {
|
||||
return Usage
|
||||
}
|
||||
|
||||
body := fmt.Sprintf(manifestSkeleton, *version, given["lang"])
|
||||
name := filepath.Join(root, manifest.Name)
|
||||
if err := os.WriteFile(name, []byte(body), 0o644); err != nil {
|
||||
m := &manifest.Manifest{
|
||||
Language: manifest.Language{Version: *version, Lang: given["lang"]},
|
||||
Path: name,
|
||||
}
|
||||
if err := m.Save(); err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return Usage
|
||||
}
|
||||
|
||||
fmt.Fprintf(env.Out, "\ncreated %s\ncreated %s\n", name, filepath.Join(root, "conventions"))
|
||||
|
||||
// The README is written only when there is none: it is a starting point,
|
||||
// and a starting point that overwrites what somebody already wrote is not
|
||||
// one.
|
||||
readme := filepath.Join(root, "README.md")
|
||||
if !exists(readme) {
|
||||
if err := os.WriteFile(readme, []byte(readmeSkeleton), 0o644); err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return Usage
|
||||
}
|
||||
fmt.Fprintf(env.Out, "created %s\n", readme)
|
||||
}
|
||||
|
||||
fmt.Fprintf(env.Out, "\nthe suite speaks %s, conventions language version %d\n", given["lang"], *version)
|
||||
fmt.Fprint(env.Out, `
|
||||
next:
|
||||
rewrite README.md — it says what a topic and a prefix are, not what this suite is for
|
||||
write the two documents about the language and name them in [language]
|
||||
convy suite add add the first convention
|
||||
convy suite check verify the suite holds together
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
|
||||
"git.vakhrushev.me/av/convy/internal/doc"
|
||||
"git.vakhrushev.me/av/convy/internal/lang"
|
||||
"git.vakhrushev.me/av/convy/internal/manifest"
|
||||
"git.vakhrushev.me/av/convy/internal/suite"
|
||||
)
|
||||
|
||||
@@ -260,23 +259,9 @@ func retirePrefix(env Env, d *dialogue, s *suite.Suite, given map[string]string,
|
||||
}
|
||||
}
|
||||
|
||||
source, err := os.ReadFile(s.Manifest.Path)
|
||||
if err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return Failed
|
||||
}
|
||||
source, err = manifest.RemoveEntry(source, "prefixes.live", prefix)
|
||||
if err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return Failed
|
||||
}
|
||||
note := fmt.Sprintf("%s, was %s: %s", when, target.Path, given["reason"])
|
||||
source, err = manifest.AddEntry(source, "prefixes.retired", prefix, note)
|
||||
if err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return Failed
|
||||
}
|
||||
if err := os.WriteFile(s.Manifest.Path, source, 0o644); err != nil {
|
||||
s.Manifest.Prefixes.Retire(prefix, note)
|
||||
if err := s.Manifest.Save(); err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return Failed
|
||||
}
|
||||
@@ -314,22 +299,8 @@ func retireTopic(env Env, d *dialogue, s *suite.Suite, given map[string]string,
|
||||
}
|
||||
}
|
||||
|
||||
source, err := os.ReadFile(s.Manifest.Path)
|
||||
if err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return Failed
|
||||
}
|
||||
source, err = manifest.RemoveEntry(source, "topics.live", topic)
|
||||
if err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return Failed
|
||||
}
|
||||
source, err = manifest.AddEntry(source, "topics.retired", topic, when+": "+given["reason"])
|
||||
if err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return Failed
|
||||
}
|
||||
if err := os.WriteFile(s.Manifest.Path, source, 0o644); err != nil {
|
||||
s.Manifest.Topics.Retire(topic, when+": "+given["reason"])
|
||||
if err := s.Manifest.Save(); err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return Failed
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.vakhrushev.me/av/convy/internal/manifest"
|
||||
"git.vakhrushev.me/av/convy/internal/project"
|
||||
)
|
||||
|
||||
// convy sync makes the files agree with the manifest. The manifest is the
|
||||
// truth: it says which components exist, where they write and what each takes,
|
||||
// and everything under those directories follows from that.
|
||||
//
|
||||
// It divides from pull by what it is about. pull is about the contents of a
|
||||
// copy — it takes the text of every subscription afresh, and the diff it leaves
|
||||
// is the point of running it. sync is about the set of files: what the manifest
|
||||
// calls for and is not there gets assembled, what is there and nothing calls
|
||||
// for gets reported and, when nothing of the repository is in it, removed.
|
||||
//
|
||||
// A copy carrying a local part is never removed. Below the marker is the one
|
||||
// thing in the directory that exists nowhere else, and a command that tidies up
|
||||
// has no business deciding it is spent.
|
||||
|
||||
func runSync(env Env, args []string) ExitCode {
|
||||
fs := flag.NewFlagSet("convy sync", flag.ContinueOnError)
|
||||
fs.SetOutput(env.Err)
|
||||
root := fs.String("root", "", "root of the project; it is looked up upwards by default")
|
||||
forComponent := fs.String("for", "", "component to bring in line; every one of them by default")
|
||||
dry := fs.Bool("dry-run", false, "say what would change and change nothing")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return Usage
|
||||
}
|
||||
if code := noStrayArgs(env, "convy sync", fs.Args()); code != OK {
|
||||
return code
|
||||
}
|
||||
|
||||
o, code := openProject(env, *root)
|
||||
if code != OK {
|
||||
return code
|
||||
}
|
||||
defer o.Close()
|
||||
|
||||
names, code := components(env, o.Manifest, *forComponent)
|
||||
if code != OK {
|
||||
return code
|
||||
}
|
||||
|
||||
problems := validateManifest(o, names)
|
||||
for _, p := range problems {
|
||||
fmt.Fprintf(env.Err, "%s\n", p)
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
fmt.Fprintln(env.Err, "\nthe manifest is what the layout follows, so nothing was touched")
|
||||
return Failed
|
||||
}
|
||||
|
||||
changed, left := 0, 0
|
||||
for i, name := range names {
|
||||
c := o.Manifest.Components[name]
|
||||
if i > 0 {
|
||||
fmt.Fprintln(env.Out)
|
||||
}
|
||||
fmt.Fprintf(env.Out, "%s → %s\n", name, c.Dir)
|
||||
n, stuck, code := syncComponent(env, o, c, *dry)
|
||||
if code != OK {
|
||||
return code
|
||||
}
|
||||
changed += n
|
||||
left += stuck
|
||||
}
|
||||
|
||||
fmt.Fprintln(env.Out)
|
||||
switch {
|
||||
case *dry && changed > 0:
|
||||
fmt.Fprintf(env.Out, "%s would change; run without --dry-run to do it\n", plural(changed, "file"))
|
||||
case changed > 0:
|
||||
fmt.Fprintf(env.Out, "%s changed; convy pull takes the text of the rest afresh\n", plural(changed, "file"))
|
||||
case left == 0:
|
||||
fmt.Fprintln(env.Out, "the layout already follows the manifest")
|
||||
}
|
||||
if left > 0 {
|
||||
fmt.Fprintf(env.Out, "%s left alone: nothing subscribes to it and it holds a local part\n", plural(left, "file"))
|
||||
return Failed
|
||||
}
|
||||
return OK
|
||||
}
|
||||
|
||||
// validate checks the manifest against itself and against the suite. Everything
|
||||
// wrong is reported at once: being sent back one line at a time is the worst way
|
||||
// to learn what a file wants.
|
||||
func validateManifest(o *opened, names []string) []string {
|
||||
var out []string
|
||||
if err := distinctDirs(o.Manifest); err != nil {
|
||||
out = append(out, err.Error())
|
||||
}
|
||||
for _, name := range names {
|
||||
c := o.Manifest.Components[name]
|
||||
if c.Dir == "" {
|
||||
out = append(out, fmt.Sprintf("the component %q names no dir, and a copy has to be written somewhere", name))
|
||||
}
|
||||
if len(c.Lang) > 1 {
|
||||
out = append(out, fmt.Sprintf("the component %q declares two languages (%s): a line of code is written in one of them, and a component is the region where every chosen layer holds at once — split it",
|
||||
name, strings.Join(c.Lang, ", ")))
|
||||
}
|
||||
seen := make(map[string]bool, len(c.Topics))
|
||||
for _, topic := range c.Topics {
|
||||
switch {
|
||||
case seen[topic]:
|
||||
out = append(out, fmt.Sprintf("the component %q takes %q twice", name, topic))
|
||||
case o.Suite.Manifest.TopicRetired(topic):
|
||||
out = append(out, fmt.Sprintf("the component %q takes %q, which the suite has retired: %s",
|
||||
name, topic, o.Suite.Manifest.Topics.Retired[topic]))
|
||||
case !o.Suite.Manifest.TopicLive(topic):
|
||||
out = append(out, fmt.Sprintf("the component %q takes %q, and the suite declares no such topic", name, topic))
|
||||
default:
|
||||
if taken, _ := o.Suite.Assemble(topic, project.Axis(c)); len(taken) == 0 {
|
||||
out = append(out, fmt.Sprintf("the component %q takes %q, and no layer of it fits this component", name, topic))
|
||||
}
|
||||
}
|
||||
seen[topic] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// syncComponent brings one directory in line. It returns how many files moved
|
||||
// and how many it would not touch.
|
||||
func syncComponent(env Env, o *opened, c manifest.Component, dry bool) (changed, left int, code ExitCode) {
|
||||
|
||||
for _, topic := range c.Topics {
|
||||
rel := filepath.ToSlash(filepath.Join(c.Dir, topic+".md"))
|
||||
if exists(filepath.Join(o.Root, filepath.FromSlash(rel))) {
|
||||
continue
|
||||
}
|
||||
changed++
|
||||
if dry {
|
||||
fmt.Fprintf(env.Out, " + %-28s subscribed, and no file\n", rel)
|
||||
continue
|
||||
}
|
||||
made, err := project.Assemble(o.Suite, o.Root, c, topic)
|
||||
if err != nil {
|
||||
fmt.Fprintf(env.Err, " %s: %s\n", topic, err)
|
||||
return changed, left, Failed
|
||||
}
|
||||
fmt.Fprintf(env.Out, " + %-28s %s\n", made.Path, plural(len(made.Layers), "layer"))
|
||||
}
|
||||
|
||||
orphans, err := orphaned(o.Root, c)
|
||||
if err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return changed, left, Failed
|
||||
}
|
||||
for _, orphan := range orphans {
|
||||
switch {
|
||||
case orphan.local:
|
||||
left++
|
||||
fmt.Fprintf(env.Out, " ! %-28s nothing subscribes to %q, and it carries a local part: remove it by hand or subscribe again\n",
|
||||
orphan.path, orphan.topic)
|
||||
case dry:
|
||||
changed++
|
||||
fmt.Fprintf(env.Out, " - %-28s nothing subscribes to %q\n", orphan.path, orphan.topic)
|
||||
default:
|
||||
if err := os.Remove(filepath.Join(o.Root, filepath.FromSlash(orphan.path))); err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return changed, left, Failed
|
||||
}
|
||||
changed++
|
||||
fmt.Fprintf(env.Out, " - %-28s nothing subscribes to %q\n", orphan.path, orphan.topic)
|
||||
}
|
||||
}
|
||||
|
||||
if !dry {
|
||||
guide, err := project.Reading(o.LangRoot, o.Suite.Manifest.Language.Reading, o.Root, c.Dir)
|
||||
if err != nil {
|
||||
fmt.Fprintf(env.Err, " %s\n", err)
|
||||
return changed, left, Failed
|
||||
}
|
||||
fmt.Fprintf(env.Out, " = %-28s the guide to reading a rule\n", guide)
|
||||
}
|
||||
return changed, left, OK
|
||||
}
|
||||
|
||||
// orphan is a copy in a component directory that the manifest does not call for.
|
||||
type orphan struct {
|
||||
path string
|
||||
topic string
|
||||
local bool
|
||||
}
|
||||
|
||||
// orphaned finds the copies nothing subscribes to. What is a copy is decided by
|
||||
// the origin key: README.md belongs to the repository, READING.md belongs to the
|
||||
// suite, and a file whose origin was taken away has become a document of the
|
||||
// repository — none of the three is anyone's to remove.
|
||||
func orphaned(root string, c manifest.Component) ([]orphan, error) {
|
||||
docs, broken := copies(root, c.Dir)
|
||||
if len(broken) > 0 {
|
||||
return nil, broken[0]
|
||||
}
|
||||
var out []orphan
|
||||
for _, d := range docs {
|
||||
if c.Subscribed(d.Front.Origin) {
|
||||
continue
|
||||
}
|
||||
local := ""
|
||||
if at := d.Marker(); at > 0 {
|
||||
local = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(d.Below(at)), project.LocalMarker))
|
||||
}
|
||||
out = append(out, orphan{path: d.Path, topic: d.Front.Origin, local: local != ""})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/convy/internal/cli"
|
||||
"git.vakhrushev.me/av/convy/internal/manifest"
|
||||
)
|
||||
|
||||
// subscribe edits the project manifest the way a person would: by hand, in the
|
||||
// file. That is the whole premise of sync — the manifest is the truth, and the
|
||||
// layout follows it.
|
||||
func subscribe(t *testing.T, root string, topics ...string) {
|
||||
t.Helper()
|
||||
m, err := manifest.LoadProject(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := m.Components["backend"]
|
||||
c.Topics = topics
|
||||
m.Components["backend"] = c
|
||||
if err := m.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncAssemblesWhatIsMissingAndRemovesWhatIsOrphaned(t *testing.T) {
|
||||
suiteRoot := subscribable(t)
|
||||
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
|
||||
run(t, root, "", false, "add", "time")
|
||||
|
||||
// The manifest is edited by hand: time goes, logging comes.
|
||||
subscribe(t, root, "logging")
|
||||
|
||||
code, out := run(t, root, "", false, "sync", "--dry-run")
|
||||
if code != cli.OK {
|
||||
t.Fatalf("the dry run returned %d: %s", code, out)
|
||||
}
|
||||
if !strings.Contains(out, "would change") {
|
||||
t.Errorf("the dry run promised nothing:\n%s", out)
|
||||
}
|
||||
if !exists(t, root, "docs/conventions/time.md") {
|
||||
t.Errorf("the dry run removed a file")
|
||||
}
|
||||
if exists(t, root, "docs/conventions/logging.md") {
|
||||
t.Errorf("the dry run assembled a file")
|
||||
}
|
||||
|
||||
code, out = run(t, root, "", false, "sync")
|
||||
if code != cli.OK {
|
||||
t.Fatalf("sync returned %d: %s", code, out)
|
||||
}
|
||||
if !exists(t, root, "docs/conventions/logging.md") {
|
||||
t.Errorf("the subscribed topic was not assembled:\n%s", out)
|
||||
}
|
||||
if exists(t, root, "docs/conventions/time.md") {
|
||||
t.Errorf("the copy nothing subscribes to stayed:\n%s", out)
|
||||
}
|
||||
|
||||
// Run again: nothing left to do, and it says so.
|
||||
code, out = run(t, root, "", false, "sync")
|
||||
if code != cli.OK || !strings.Contains(out, "already follows the manifest") {
|
||||
t.Errorf("a second sync found work to do:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Below the marker is the one thing in the directory that exists nowhere else.
|
||||
func TestSyncLeavesAnOrphanCarryingALocalPart(t *testing.T) {
|
||||
suiteRoot := subscribable(t)
|
||||
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
|
||||
run(t, root, "", false, "add", "time")
|
||||
|
||||
name := filepath.Join(root, "docs", "conventions", "time.md")
|
||||
body := read(t, root, "docs/conventions/time.md")
|
||||
body += "\nTIME-1 — МЕХАНИЗИРОВАНО: `internal/archrules`.\n"
|
||||
if err := os.WriteFile(name, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
subscribe(t, root)
|
||||
|
||||
code, out := run(t, root, "", false, "sync")
|
||||
if code == cli.OK {
|
||||
t.Fatalf("an orphan with a local part went unremarked:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "local part") {
|
||||
t.Errorf("the report does not say why the file was left:\n%s", out)
|
||||
}
|
||||
if !exists(t, root, "docs/conventions/time.md") {
|
||||
t.Fatalf("the local part was destroyed:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// The manifest is the truth, so a manifest that does not hold together stops
|
||||
// the command before anything is written.
|
||||
func TestSyncValidatesTheManifestBeforeTouchingAnything(t *testing.T) {
|
||||
suiteRoot := subscribable(t)
|
||||
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
change func(*manifest.Project)
|
||||
want string
|
||||
}{{
|
||||
name: "a topic the suite does not declare",
|
||||
change: func(m *manifest.Project) { subscribeTo(m, "billing") },
|
||||
want: "no such topic",
|
||||
}, {
|
||||
name: "the same topic twice",
|
||||
change: func(m *manifest.Project) { subscribeTo(m, "time", "time") },
|
||||
want: "twice",
|
||||
}, {
|
||||
name: "two languages in one component",
|
||||
change: func(m *manifest.Project) {
|
||||
c := m.Components["backend"]
|
||||
c.Lang = []string{"go", "javascript"}
|
||||
m.Components["backend"] = c
|
||||
},
|
||||
want: "declares two languages",
|
||||
}, {
|
||||
name: "a topic no layer of which fits",
|
||||
change: func(m *manifest.Project) {
|
||||
// web-ui lives on the htmx stack only, and this component is on
|
||||
// no stack at all.
|
||||
subscribeTo(m, "web-ui")
|
||||
},
|
||||
want: "no layer of it fits",
|
||||
}}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
m, err := manifest.LoadProject(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tc.change(m)
|
||||
if err := m.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code, out := run(t, root, "", false, "sync")
|
||||
if code == cli.OK {
|
||||
t.Fatalf("the manifest went through:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, tc.want) {
|
||||
t.Errorf("the report does not say %q:\n%s", tc.want, out)
|
||||
}
|
||||
if !strings.Contains(out, "nothing was touched") {
|
||||
t.Errorf("the report does not say it wrote nothing:\n%s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func subscribeTo(m *manifest.Project, topics ...string) {
|
||||
c := m.Components["backend"]
|
||||
c.Topics = topics
|
||||
m.Components["backend"] = c
|
||||
}
|
||||
|
||||
func exists(t *testing.T, parts ...string) bool {
|
||||
t.Helper()
|
||||
_, err := os.Stat(filepath.Join(parts...))
|
||||
return err == nil
|
||||
}
|
||||
@@ -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