suite init и suite add: заведение набора и конвенции в двух режимах
- suite init создаёт директорию и манифест со скелетом таблиц; suite add пишет файл конвенции и вправляет запись в suite.toml, сохраняя комментарии - без аргументов команды спрашивают поля с подсказками, с флагами берут всё сразу и не спрашивают ничего; без терминала пустой вызов отказывает - строка о версии языка генерируется из словаря набора, поэтому созданный файл проходит suite check без правок - починено разрешение extends: короткая форма бралась по суффиксу и могла указать на сам файл; теперь неоднозначность либо избегается при записи, либо сообщается ошибкой
This commit is contained in:
@@ -0,0 +1,380 @@
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
source, err = manifest.AddEntry(source, "prefixes.live", given["prefix"], rel)
|
||||
if 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"])
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user