suite init и suite add: заведение набора и конвенции в двух режимах

- suite init создаёт директорию и манифест со скелетом таблиц; suite add пишет
  файл конвенции и вправляет запись в suite.toml, сохраняя комментарии
- без аргументов команды спрашивают поля с подсказками, с флагами берут всё
  сразу и не спрашивают ничего; без терминала пустой вызов отказывает
- строка о версии языка генерируется из словаря набора, поэтому созданный файл
  проходит suite check без правок
- починено разрешение extends: короткая форма бралась по суффиксу и могла
  указать на сам файл; теперь неоднозначность либо избегается при записи, либо
  сообщается ошибкой
This commit is contained in:
av
2026-07-27 10:40:57 +03:00
parent 709157237d
commit b29b5b5e6f
9 changed files with 1247 additions and 21 deletions
+145
View File
@@ -0,0 +1,145 @@
package cli
import (
"flag"
"fmt"
"os"
"path/filepath"
"git.vakhrushev.me/av/convy/internal/lang"
"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.
//
// 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.
[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
# ─── 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.
[topics.live]
# 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.retired]
# ─── 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.
[prefixes.live]
# Prefixes of deleted and split files land here, likewise never to be reissued.
[prefixes.retired]
`
func runSuiteInit(env Env, args []string) ExitCode {
fs := flag.NewFlagSet("convy suite init", flag.ContinueOnError)
fs.SetOutput(env.Err)
path := fs.String("path", "", "directory of the suite; it is created if absent")
code := fs.String("lang", "", "code of the natural language the suite is written in")
version := fs.Int("language-version", 1, "version of the conventions language")
if err := fs.Parse(args); err != nil {
return Usage
}
fields := []Field{{
Flag: "path",
Ask: "Directory of the suite",
Hint: "Where the suite will live. The directory is created if it is not there yet.",
Default: ".",
}, {
Flag: "lang",
Ask: "Natural language of the suite",
Hint: "The language the rules are written in. It settles the key words: ДОЛЖЕН and ПОЧЕМУ for ru, MUST and WHY for en.",
Default: manifest.DefaultLanguageCode,
Check: func(v string) error {
_, err := lang.Lookup(*version, v)
return err
},
}}
given := map[string]string{"path": *path, "lang": *code}
if len(args) == 0 {
if !env.Interactive {
fmt.Fprintln(env.Err, "convy suite init without arguments asks questions, and there is no terminal to ask on; pass --path and --lang")
return Usage
}
if err := askAll(env, fields, given); err != nil {
return Usage
}
} else if err := resolve(fields, given); err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
root := given["path"]
if _, err := os.Stat(filepath.Join(root, manifest.Name)); err == nil {
fmt.Fprintf(env.Err, "%s already holds %s: this is a suite already\n", root, manifest.Name)
return Usage
}
if err := os.MkdirAll(filepath.Join(root, "conventions"), 0o755); err != nil {
fmt.Fprintln(env.Err, err)
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 {
fmt.Fprintln(env.Err, err)
return Usage
}
fmt.Fprintf(env.Out, "\ncreated %s\ncreated %s\n", name, filepath.Join(root, "conventions"))
fmt.Fprintf(env.Out, "\nthe suite speaks %s, conventions language version %d\n", given["lang"], *version)
fmt.Fprint(env.Out, `
next:
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
`)
return OK
}
// askAll walks the fields in interactive mode, keeping answers already given on
// the command line.
func askAll(env Env, fields []Field, given map[string]string) error {
d := newDialogue(env)
for _, f := range fields {
if given[f.Flag] != "" {
continue
}
answer, err := d.ask(f)
if err != nil {
fmt.Fprintln(env.Err, "\ninterrupted, nothing was written")
return err
}
given[f.Flag] = answer
}
return nil
}