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 }