- убраны комментарии из suite.toml и .conventions.toml: файл, который машина переписывает, комментарий через круг не проносит; объяснения ушли в README рядом, который suite init теперь заводит - удалена текстовая правка манифеста целиком — 520 строк ручного лексера TOML вместе со всем классом ошибок порчи данных - запись идёт из структур энкодером; ключ, которого инструмент не знает, запись останавливает, а не теряется молча - convy sync сверяет манифест и подводит под него раскладку файлов: чего не хватает — собирает, что осиротело — удаляет, копию с локальной частью не трогает никогда
368 lines
12 KiB
Go
368 lines
12 KiB
Go
package cli
|
|
|
|
import (
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"git.vakhrushev.me/av/convy/internal/manifest"
|
|
"git.vakhrushev.me/av/convy/internal/suite"
|
|
)
|
|
|
|
var (
|
|
nameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
|
|
kebabRe = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
|
|
)
|
|
|
|
func runSuiteAdd(env Env, args []string) ExitCode {
|
|
fs := flag.NewFlagSet("convy suite add", flag.ContinueOnError)
|
|
fs.SetOutput(env.Err)
|
|
root := fs.String("root", "", "root of the suite; by default it is looked up upwards")
|
|
topic := fs.String("topic", "", "name of the topic the convention belongs to")
|
|
about := fs.String("about", "", "one line about the topic, for a new topic only")
|
|
prefix := fs.String("prefix", "", "prefix of the rules, four uppercase Latin letters")
|
|
title := fs.String("title", "", "title of the document")
|
|
intro := fs.String("intro", "", "introductory prose, one or two sentences")
|
|
langAxis := fs.String("lang", "", "language axis of the layer; empty means the base layer")
|
|
stackAxis := fs.String("stack", "", "stack axis of the layer; empty means the base layer")
|
|
file := fs.String("path", "", "path of the file from the root of the suite")
|
|
if err := fs.Parse(args); err != nil {
|
|
return Usage
|
|
}
|
|
|
|
dir, code := suiteRoot(env, *root)
|
|
if code != OK {
|
|
return code
|
|
}
|
|
s, err := suite.Load(dir)
|
|
if err != nil {
|
|
fmt.Fprintln(env.Err, err)
|
|
return Usage
|
|
}
|
|
|
|
given := map[string]string{
|
|
"topic": *topic, "about": *about, "prefix": *prefix,
|
|
"title": *title, "intro": *intro,
|
|
"lang": *langAxis, "stack": *stackAxis, "path": *file,
|
|
}
|
|
|
|
if len(args) == 0 {
|
|
if !env.Interactive {
|
|
fmt.Fprintln(env.Err, "convy suite add without arguments asks questions, and there is no terminal to ask on; pass --topic, --prefix and --title")
|
|
return Usage
|
|
}
|
|
if err := askAdd(env, s, given); err != nil {
|
|
return Usage
|
|
}
|
|
} else {
|
|
if err := resolve(addFields(s), given); err != nil {
|
|
fmt.Fprintln(env.Err, err)
|
|
return Usage
|
|
}
|
|
if given["path"] == "" {
|
|
given["path"] = defaultPath(given)
|
|
}
|
|
if err := checkTopicAbout(s, given); err != nil {
|
|
fmt.Fprintln(env.Err, err)
|
|
return Usage
|
|
}
|
|
}
|
|
|
|
return writeConvention(env, s, given)
|
|
}
|
|
|
|
// addFields describes what a convention needs to be added. The checks live here
|
|
// rather than at the point of writing so that both modes apply the same ones.
|
|
func addFields(s *suite.Suite) []Field {
|
|
return []Field{{
|
|
Flag: "topic",
|
|
Ask: "Topic",
|
|
Hint: "The focus the rules are about: time, config, db-schema. A topic is the unit of subscription — a consumer takes it whole. The name never changes and is never reused.",
|
|
Check: func(v string) error {
|
|
if !nameRe.MatchString(v) {
|
|
return fmt.Errorf("a topic name travels into the file system of a consumer, so it is written in Latin letters and digits")
|
|
}
|
|
if s.Manifest.TopicRetired(v) {
|
|
return fmt.Errorf("the topic %q is retired and cannot be handed out again", v)
|
|
}
|
|
return nil
|
|
},
|
|
}, {
|
|
Flag: "prefix",
|
|
Ask: "Prefix of the rules",
|
|
Hint: "Four uppercase Latin letters, unique across the suite, one per file. Pick a word that reads, not a formula: the prefix exists to be searched for. Rules will be numbered PREFIX-1, PREFIX-2.",
|
|
Check: func(v string) error {
|
|
if err := manifest.ValidPrefix(v); err != nil {
|
|
return err
|
|
}
|
|
if _, taken := s.Manifest.PathOf(v); taken {
|
|
return fmt.Errorf("the prefix %s is already taken in the suite", v)
|
|
}
|
|
if s.Manifest.PrefixRetired(v) {
|
|
return fmt.Errorf("the prefix %s is retired and is never reissued", v)
|
|
}
|
|
return nil
|
|
},
|
|
}, {
|
|
Flag: "title",
|
|
Ask: "Title of the document",
|
|
Hint: "The level-one heading, in the language of the suite.",
|
|
}, {
|
|
Flag: "lang",
|
|
Ask: "Language axis",
|
|
Hint: "The programming language this layer is about, if it is about one: go, python. Leave empty for the base layer, the one that reaches every copy.",
|
|
Optional: true,
|
|
Check: axisCheck,
|
|
}, {
|
|
Flag: "stack",
|
|
Ask: "Stack axis",
|
|
Hint: "The tool or framework this layer is about, if it is about one: htmx, ansible. Leave empty together with the language axis to get the base layer.",
|
|
Optional: true,
|
|
Check: axisCheck,
|
|
}, {
|
|
Flag: "intro",
|
|
Ask: "Introductory prose",
|
|
Hint: "One or two sentences on what the convention covers. Key words in capitals do not belong here: prose is never a norm.",
|
|
Optional: true,
|
|
}}
|
|
}
|
|
|
|
func axisCheck(v string) error {
|
|
if !nameRe.MatchString(v) {
|
|
return fmt.Errorf("an axis value becomes a directory name, so it is written in Latin letters and digits")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// askAdd walks the dialogue, asking for the topic description only when the
|
|
// topic is new and for the path once the axis is known.
|
|
func askAdd(env Env, s *suite.Suite, given map[string]string) error {
|
|
d := newDialogue(env)
|
|
fields := addFields(s)
|
|
|
|
for _, f := range fields {
|
|
answer, err := d.ask(f)
|
|
if err != nil {
|
|
fmt.Fprintln(env.Err, "\ninterrupted, nothing was written")
|
|
return err
|
|
}
|
|
given[f.Flag] = answer
|
|
|
|
if f.Flag != "topic" {
|
|
continue
|
|
}
|
|
if s.Manifest.TopicLive(answer) {
|
|
fmt.Fprintf(env.Out, " the topic is known, this will be another layer of it\n")
|
|
continue
|
|
}
|
|
about, err := d.ask(Field{
|
|
Flag: "about",
|
|
Ask: "One line about the topic",
|
|
Hint: "It goes into the manifest and builds the table of conventions in a consumer's README.",
|
|
})
|
|
if err != nil {
|
|
fmt.Fprintln(env.Err, "\ninterrupted, nothing was written")
|
|
return err
|
|
}
|
|
given["about"] = about
|
|
}
|
|
|
|
answer, err := d.ask(Field{
|
|
Flag: "path",
|
|
Ask: "Path of the file",
|
|
Hint: "Where the file lies in the suite. The directory tree documents the tie between layers for a human; what a layer actually is comes from the front matter.",
|
|
Default: defaultPath(given),
|
|
})
|
|
if err != nil {
|
|
fmt.Fprintln(env.Err, "\ninterrupted, nothing was written")
|
|
return err
|
|
}
|
|
given["path"] = answer
|
|
return nil
|
|
}
|
|
|
|
// checkTopicAbout guards the one field whose need depends on another: a new
|
|
// topic has to be described, a known one is described already.
|
|
func checkTopicAbout(s *suite.Suite, given map[string]string) error {
|
|
if s.Manifest.TopicLive(given["topic"]) {
|
|
return nil
|
|
}
|
|
if given["about"] == "" {
|
|
return fmt.Errorf("the topic %q is new to the suite, so --about is required: the line describes it in the manifest and in a consumer's README", given["topic"])
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// defaultPath puts a file where the axis says it belongs. The path is
|
|
// documentation, not a declaration — but documentation that agrees with the
|
|
// declaration costs nothing to produce.
|
|
func defaultPath(given map[string]string) string {
|
|
topic := given["topic"]
|
|
switch {
|
|
case given["lang"] != "":
|
|
return path.Join("conventions/lang", given["lang"], topic+".md")
|
|
case given["stack"] != "":
|
|
return path.Join("conventions/stack", given["stack"], topic+".md")
|
|
}
|
|
return path.Join("conventions", topic+".md")
|
|
}
|
|
|
|
// writeConvention writes the file and splices the manifest. The file goes first
|
|
// and the manifest second, because a file the manifest does not declare is
|
|
// reported by the check, while a declared file that is missing is an error the
|
|
// author has to undo by hand.
|
|
func writeConvention(env Env, s *suite.Suite, given map[string]string) ExitCode {
|
|
if given["lang"] != "" && given["stack"] != "" {
|
|
// Both axes at once is a layer meaningful only when language and tool
|
|
// coincide. The model allows it; the default path does not express
|
|
// it, so the path has to be given explicitly.
|
|
if given["path"] == "" {
|
|
fmt.Fprintln(env.Err, "a layer on both axes needs --path: the tree cannot express two axes at once")
|
|
return Usage
|
|
}
|
|
}
|
|
rel := filepath.ToSlash(given["path"])
|
|
name := filepath.Join(s.Root, filepath.FromSlash(rel))
|
|
if _, err := os.Stat(name); err == nil {
|
|
fmt.Fprintf(env.Err, "%s already exists\n", rel)
|
|
return Usage
|
|
}
|
|
|
|
base := baseLayerOf(s, given["topic"])
|
|
if base == "" && given["lang"] == "" && given["stack"] == "" {
|
|
// The first layer of a topic and no axis: this is the base one.
|
|
} else if base == "" {
|
|
fmt.Fprintf(env.Out, "note: the topic has no base layer yet, so this one stands alone\n")
|
|
} else if given["lang"] == "" && given["stack"] == "" {
|
|
fmt.Fprintf(env.Err, "the topic %q already holds a base layer (%s): a second one would put two base texts in one assembled file\n", given["topic"], base)
|
|
return Usage
|
|
}
|
|
|
|
body := conventionSkeleton(s, given, base, rel)
|
|
if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
|
|
fmt.Fprintln(env.Err, err)
|
|
return Usage
|
|
}
|
|
if err := os.WriteFile(name, []byte(body), 0o644); err != nil {
|
|
fmt.Fprintln(env.Err, err)
|
|
return Usage
|
|
}
|
|
|
|
if !s.Manifest.TopicLive(given["topic"]) {
|
|
s.Manifest.Topics.Add(given["topic"], given["about"])
|
|
}
|
|
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
|
|
}
|
|
|
|
fmt.Fprintf(env.Out, "\ncreated %s\n", rel)
|
|
fmt.Fprintf(env.Out, "updated %s: prefix %s", manifest.Name, given["prefix"])
|
|
if given["about"] != "" {
|
|
fmt.Fprintf(env.Out, ", topic %s", given["topic"])
|
|
}
|
|
fmt.Fprintf(env.Out, "\n\nwrite the first rule as ### %s-1, then run convy suite check\n", given["prefix"])
|
|
if !kebabRe.MatchString(given["topic"]) {
|
|
fmt.Fprintf(env.Out, "note: lower kebab-case is the recommended shape for a topic name\n")
|
|
}
|
|
return OK
|
|
}
|
|
|
|
// baseLayerOf returns the path of the base layer of a topic, if the suite holds
|
|
// one.
|
|
func baseLayerOf(s *suite.Suite, topic string) string {
|
|
for _, d := range s.Layers(topic) {
|
|
if !d.Front.Axis() {
|
|
return d.Path
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// conventionSkeleton builds the file: front matter, title, prose and the
|
|
// language version line. The line is rendered out of the suite's vocabulary,
|
|
// which is the whole point of the vocabulary being a property of the language
|
|
// rather than of the code — what the tool writes, the tool also accepts.
|
|
func conventionSkeleton(s *suite.Suite, given map[string]string, base, rel string) string {
|
|
var b strings.Builder
|
|
b.WriteString("---\n")
|
|
fmt.Fprintf(&b, "topic: %s\n", given["topic"])
|
|
fmt.Fprintf(&b, "prefix: %s\n", given["prefix"])
|
|
if given["lang"] != "" {
|
|
fmt.Fprintf(&b, "lang: %s\n", given["lang"])
|
|
}
|
|
if given["stack"] != "" {
|
|
fmt.Fprintf(&b, "stack: %s\n", given["stack"])
|
|
}
|
|
if base != "" {
|
|
fmt.Fprintf(&b, "extends: %s\n", extendsRef(s, base, rel))
|
|
}
|
|
b.WriteString("---\n\n")
|
|
|
|
fmt.Fprintf(&b, "# %s\n\n", given["title"])
|
|
if given["intro"] != "" {
|
|
fmt.Fprintf(&b, "%s\n\n", given["intro"])
|
|
}
|
|
b.WriteString(s.Vocab.VersionLine())
|
|
b.WriteString("\n")
|
|
return b.String()
|
|
}
|
|
|
|
// extendsRef writes the base layer the way the suite already writes it: from
|
|
// the directory the two files share, when that form names one file and no more.
|
|
//
|
|
// The short form is what a reader expects, but two layers of one topic usually
|
|
// carry the same file name, so a tail like "time.md" can fit both the base
|
|
// layer and the file being written. Where that happens the reference falls back
|
|
// to the path from the root of the suite, which fits exactly one file always.
|
|
func extendsRef(s *suite.Suite, base, rel string) string {
|
|
short := base
|
|
shared := path.Dir(rel)
|
|
for shared != "." && shared != "/" {
|
|
if rest, ok := strings.CutPrefix(base, path.Dir(shared)+"/"); ok {
|
|
short = rest
|
|
break
|
|
}
|
|
shared = path.Dir(shared)
|
|
}
|
|
if short == base || unambiguous(s, short, base, rel) {
|
|
return short
|
|
}
|
|
return base
|
|
}
|
|
|
|
// unambiguous reports whether a tail names the base layer and nothing else,
|
|
// counting the file about to be written as one of the suite's own.
|
|
func unambiguous(s *suite.Suite, short, base, rel string) bool {
|
|
paths := []string{rel}
|
|
for _, d := range s.Docs {
|
|
paths = append(paths, d.Path)
|
|
}
|
|
hits := 0
|
|
for _, p := range paths {
|
|
if p == short || strings.HasSuffix(p, "/"+short) {
|
|
hits++
|
|
}
|
|
}
|
|
return hits == 1 && (base == short || strings.HasSuffix(base, "/"+short))
|
|
}
|
|
|
|
// suiteRoot settles the directory a suite command works in.
|
|
func suiteRoot(env Env, given string) (string, ExitCode) {
|
|
if given != "" {
|
|
return given, OK
|
|
}
|
|
found, err := manifest.Find(env.Dir)
|
|
if err != nil {
|
|
fmt.Fprintf(env.Err, "not a conventions suite: no %s here or above\n", manifest.Name)
|
|
fmt.Fprintln(env.Err, "convy suite init starts one")
|
|
return "", Usage
|
|
}
|
|
return found, OK
|
|
}
|