Files
av 8331aa1ca5 suite rule и suite list
- suite rule дописывает правило: номер берётся следующим за наибольшим, блоки
  раскладываются в порядке норма, ПОЧЕМУ, ПРИМЕРЫ, --after ставит правило рядом
  с тем, которое оно уточняет
- ступень называется категорией (requirement, prohibition, ...), а не словом
  языка, поэтому вызывающему не нужно знать, на каком языке записан набор
- suite list показывает темы со слоями, а с осью — что возьмёт компонент; отбор
  слоёв вынесен в suite.Assemble, откуда его возьмут проектные команды
- слои темы теперь всегда возвращаются базовым вперёд
2026-07-27 11:36:19 +03:00

225 lines
7.5 KiB
Go

package cli
import (
"flag"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"git.vakhrushev.me/av/convy/internal/doc"
"git.vakhrushev.me/av/convy/internal/lang"
"git.vakhrushev.me/av/convy/internal/suite"
)
// Adding a rule is where the tool stops merely judging the form and starts
// holding it. Everything the check verifies afterwards is known here already:
// the next free number, the prefix of the file, the words of the modality and
// the marks, the order of the blocks. Leaving the author to reproduce all of
// that by hand and then reporting what they got wrong is the worse half of the
// deal.
func runSuiteRule(env Env, args []string) ExitCode {
fs := flag.NewFlagSet("convy suite rule", flag.ContinueOnError)
fs.SetOutput(env.Err)
root := fs.String("root", "", "root of the suite; by default it is looked up upwards")
prefix := fs.String("prefix", "", "prefix of the convention the rule joins")
title := fs.String("title", "", "heading of the rule, what it is about")
modality := fs.String("modality", "", "step of the scale: "+strings.Join(lang.LevelNames(), ", "))
norm := fs.String("norm", "", "the norm itself, one statement")
why := fs.String("why", "", "what breaks if it is done otherwise")
examples := fs.String("examples", "", "illustration of the norm; optional")
after := fs.String("after", "", "identifier of the rule to place this one after; the last by default")
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{
"prefix": *prefix, "title": *title, "modality": *modality,
"norm": *norm, "why": *why, "examples": *examples, "after": *after,
}
if len(args) == 0 {
if !env.Interactive {
fmt.Fprintln(env.Err, "convy suite rule without arguments asks questions, and there is no terminal to ask on; pass --prefix, --title, --modality, --norm and --why")
return Usage
}
if err := askAll(env, ruleFields(s), given); err != nil {
return Usage
}
} else if err := resolve(ruleFields(s), given); err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
return writeRule(env, s, given)
}
func ruleFields(s *suite.Suite) []Field {
return []Field{{
Flag: "prefix",
Ask: "Prefix of the convention",
Hint: "Which file the rule joins. The number is taken from that file: the next one after its highest, never a number that was used before.",
Check: func(v string) error {
if _, ok := s.ByPrefix[v]; !ok {
return fmt.Errorf("the suite declares no live prefix %s", v)
}
return nil
},
}, {
Flag: "title",
Ask: "Heading of the rule",
Hint: "What the rule is about, in one line. It is read on its own in a list, so it says the substance rather than the topic.",
}, {
Flag: "modality",
Ask: "Step of the scale",
Hint: "How binding it is. The highest step asks two things at once: a named harm from breaking it, and a verdict two reviewers reach alike. Neither one — the rule belongs a step lower.",
Options: lang.LevelNames(),
Default: "recommendation",
Check: func(v string) error {
if _, ok := lang.LevelByName(v); ok {
return nil
}
if _, ok := s.Vocab.Modal(v); ok {
return nil
}
return fmt.Errorf("one of: %s — or the word the suite uses for the step",
strings.Join(lang.LevelNames(), ", "))
},
}, {
Flag: "norm",
Ask: "The norm",
Hint: "One statement of what is required. If it does not fit in one, these are two rules: half a compound norm cannot be addressed on its own.",
}, {
Flag: "why",
Ask: "The rationale",
Hint: "What breaks if it is done otherwise — not the norm said again. A cause that cannot be put into words means this is a habit rather than a rule.",
}, {
Flag: "examples",
Ask: "Examples",
Hint: "Code showing the norm at work, usually bad against good. Worth it where showing is cheaper than saying; a rule about choosing a boundary has nothing to illustrate.",
Optional: true,
}, {
Flag: "after",
Ask: "Place after",
Hint: "Identifier of the rule this one follows. Order in a file goes by reading rather than by number, so a rule elaborating another belongs next to it. Empty puts it last.",
Optional: true,
Check: func(v string) error {
if !ruleIDRe.MatchString(v) {
return fmt.Errorf("a rule is named by its identifier, PREFIX-N")
}
return nil
},
}}
}
func writeRule(env Env, s *suite.Suite, given map[string]string) ExitCode {
prefix := given["prefix"]
target := s.ByPrefix[prefix]
level, ok := lang.LevelByName(given["modality"])
if !ok {
if l, isWord := s.Vocab.Modal(given["modality"]); isWord {
level = l
} else {
fmt.Fprintf(env.Err, "unknown step %q; the steps are: %s\n",
given["modality"], strings.Join(lang.LevelNames(), ", "))
return Usage
}
}
own := ownRules(target, prefix)
if len(own) == 0 {
fmt.Fprintf(env.Err, "%s holds no rule yet, and a number is taken from the highest one; write the first rule as ### %s-1 by hand\n", target.Path, prefix)
return Usage
}
num := 0
for _, r := range own {
num = max(num, r.Num)
}
num++
// A number is never reused, so a rule retired long ago still owns its own.
// Taking the next after the highest is the only choice that cannot collide
// with a reference living in a foreign repository.
place := own[len(own)-1]
if given["after"] != "" {
m := ruleIDRe.FindStringSubmatch(given["after"])
if m[1] != prefix {
fmt.Fprintf(env.Err, "--after names %s, which belongs to another file than %s\n", given["after"], prefix)
return Usage
}
found := false
for _, r := range own {
if strconv.Itoa(r.Num) == m[2] {
place, found = r, true
}
}
if !found {
fmt.Fprintf(env.Err, "%s holds no rule %s\n", target.Path, given["after"])
return Usage
}
}
name := filepath.Join(s.Root, filepath.FromSlash(target.Path))
body, err := os.ReadFile(name)
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
lines := strings.Split(string(body), "\n")
at := place.End
for at > place.Line && strings.TrimSpace(lines[at-1]) == "" {
at--
}
block := renderRule(s, prefix, num, level, given)
out := make([]string, 0, len(lines)+len(block))
out = append(out, lines[:at]...)
out = append(out, block...)
out = append(out, lines[at:]...)
if err := os.WriteFile(name, []byte(strings.Join(out, "\n")), 0o644); err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
fmt.Fprintf(env.Out, "\n%s-%d added to %s after %s\n", prefix, num, target.Path, place.ID())
return OK
}
// renderRule lays out the blocks in the order the language fixes: the norm,
// then the reason, then the illustration.
func renderRule(s *suite.Suite, prefix string, num int, level lang.Level, given map[string]string) []string {
out := []string{"", fmt.Sprintf("### %s-%d. %s", prefix, num, given["title"]), ""}
out = append(out, wrap(fmt.Sprintf("**%s.** %s", s.Vocab.Word(level), given["norm"]), 78)...)
out = append(out, "")
out = append(out, wrap(fmt.Sprintf("**%s.** %s", s.Vocab.MarkWord(lang.Rationale), given["why"]), 78)...)
if given["examples"] != "" {
out = append(out, "")
out = append(out, wrap(fmt.Sprintf("**%s.** %s", s.Vocab.MarkWord(lang.Examples), given["examples"]), 78)...)
}
return out
}
// ownRules picks the rules a file numbers itself, in the order they stand.
func ownRules(d *doc.Document, prefix string) []doc.Rule {
var out []doc.Rule
for _, r := range d.Rules {
if r.Prefix == prefix {
out = append(out, r)
}
}
return out
}