suite rule и suite list
- suite rule дописывает правило: номер берётся следующим за наибольшим, блоки раскладываются в порядке норма, ПОЧЕМУ, ПРИМЕРЫ, --after ставит правило рядом с тем, которое оно уточняет - ступень называется категорией (requirement, prohibition, ...), а не словом языка, поэтому вызывающему не нужно знать, на каком языке записан набор - suite list показывает темы со слоями, а с осью — что возьмёт компонент; отбор слоёв вынесен в suite.Assemble, откуда его возьмут проектные команды - слои темы теперь всегда возвращаются базовым вперёд
This commit is contained in:
+7
-1
@@ -63,7 +63,7 @@ func Run(env Env, args []string) ExitCode {
|
||||
|
||||
func runSuite(env Env, args []string) ExitCode {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintln(env.Err, "convy suite: a subcommand is required — init, add, retire or check")
|
||||
fmt.Fprintln(env.Err, "convy suite: a subcommand is required — init, add, rule, retire, list or check")
|
||||
return Usage
|
||||
}
|
||||
switch args[0] {
|
||||
@@ -73,8 +73,12 @@ func runSuite(env Env, args []string) ExitCode {
|
||||
return runSuiteInit(env, args[1:])
|
||||
case "add":
|
||||
return runSuiteAdd(env, args[1:])
|
||||
case "rule":
|
||||
return runSuiteRule(env, args[1:])
|
||||
case "retire":
|
||||
return runSuiteRetire(env, args[1:])
|
||||
case "list":
|
||||
return runSuiteList(env, args[1:])
|
||||
default:
|
||||
fmt.Fprintf(env.Err, "unknown subcommand %q for convy suite\n", args[0])
|
||||
return Usage
|
||||
@@ -94,7 +98,9 @@ In a project:
|
||||
In a suite:
|
||||
convy suite init start a suite: a directory and a manifest
|
||||
convy suite add add a convention: a file, a topic and a prefix
|
||||
convy suite rule add a rule: the next number, the blocks in order
|
||||
convy suite retire retire a rule, a convention or a topic — never reusing it
|
||||
convy suite list what the suite holds, and what a component would take
|
||||
convy suite check suite integrity: prefixes, topics, axes, links, form
|
||||
|
||||
The commands that change something run in two modes. Bare, they ask for every
|
||||
|
||||
@@ -107,13 +107,17 @@ func validate(f Field, value string) error {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
// Options are what the dialogue offers; a check, where there is one, is
|
||||
// what decides. A field may well accept more than it suggests — naming a
|
||||
// step by its category is the suggestion, naming it by the word of the
|
||||
// suite is accepted all the same.
|
||||
if f.Check != nil {
|
||||
return f.Check(value)
|
||||
}
|
||||
if len(f.Options) > 0 && !slices.Contains(f.Options, value) {
|
||||
return fmt.Errorf("one of: %s", strings.Join(f.Options, ", "))
|
||||
}
|
||||
if f.Check == nil {
|
||||
return nil
|
||||
}
|
||||
return f.Check(value)
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolve settles the fields in automatic mode: what came on the command line
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/convy/internal/cli"
|
||||
)
|
||||
|
||||
func TestRuleTakesTheNextNumberAndKeepsTheOrderOfBlocks(t *testing.T) {
|
||||
root := retirable(t)
|
||||
|
||||
code, out := run(t, root, "", false, "suite", "rule",
|
||||
"--prefix", "TIME", "--title", "Точность носителя фиксируется",
|
||||
"--modality", "requirement",
|
||||
"--norm", "У колонки БД и у потока логов точность объявлена и не плавает.",
|
||||
"--why", "Плавающая точность ломает сортировку выборочно и невоспроизводимо.")
|
||||
if code != cli.OK {
|
||||
t.Fatalf("adding a rule returned %d: %s", code, out)
|
||||
}
|
||||
if !strings.Contains(out, "TIME-3 added") {
|
||||
t.Errorf("the number is not the next after the highest:\n%s", out)
|
||||
}
|
||||
|
||||
body := read(t, root, "conventions/time.md")
|
||||
want := "### TIME-3. Точность носителя фиксируется\n\n**ДОЛЖЕН.** У колонки БД и у потока логов точность объявлена и не плавает.\n\n**ПОЧЕМУ.** Плавающая точность ломает сортировку выборочно и невоспроизводимо."
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("the rule is not laid out as the language fixes it:\n%s", body)
|
||||
}
|
||||
checkClean(t, root)
|
||||
}
|
||||
|
||||
// A number is never reused, so retiring a rule does not free its number: the
|
||||
// next one still comes after the highest.
|
||||
func TestRuleNumbersPastARetiredOne(t *testing.T) {
|
||||
root := retirable(t)
|
||||
run(t, root, "", false, "suite", "retire", "--rule", "TIME-2", "--reason", "не нужно", "--date", "2026-07-27")
|
||||
|
||||
code, out := run(t, root, "", false, "suite", "rule",
|
||||
"--prefix", "TIME", "--title", "Третье", "--modality", "recommendation",
|
||||
"--norm", "Норма.", "--why", "Причина.")
|
||||
if code != cli.OK {
|
||||
t.Fatalf("adding a rule returned %d: %s", code, out)
|
||||
}
|
||||
if !strings.Contains(out, "TIME-3 added") {
|
||||
t.Errorf("the number of a retired rule was reused:\n%s", out)
|
||||
}
|
||||
checkClean(t, root)
|
||||
}
|
||||
|
||||
// Order in a file goes by reading rather than by number, so a rule elaborating
|
||||
// another has to be placeable next to it.
|
||||
func TestRulePlacesAfterTheOneItElaborates(t *testing.T) {
|
||||
root := retirable(t)
|
||||
|
||||
run(t, root, "", false, "suite", "rule",
|
||||
"--prefix", "TIME", "--title", "Третье", "--modality", "recommendation",
|
||||
"--norm", "Норма третьего.", "--why", "Причина третьего.")
|
||||
code, out := run(t, root, "", false, "suite", "rule",
|
||||
"--prefix", "TIME", "--title", "Уточнение первого", "--modality", "permission",
|
||||
"--norm", "Норма четвёртого.", "--why", "Причина четвёртого.",
|
||||
"--after", "TIME-1")
|
||||
if code != cli.OK {
|
||||
t.Fatalf("adding a rule returned %d: %s", code, out)
|
||||
}
|
||||
|
||||
body := read(t, root, "conventions/time.md")
|
||||
order := []string{"### TIME-1.", "### TIME-4.", "### TIME-2.", "### TIME-3."}
|
||||
at := 0
|
||||
for _, id := range order {
|
||||
i := strings.Index(body[at:], id)
|
||||
if i < 0 {
|
||||
t.Fatalf("%s is missing or out of place:\n%s", id, body)
|
||||
}
|
||||
at += i
|
||||
}
|
||||
checkClean(t, root)
|
||||
}
|
||||
|
||||
// The step is named by its category rather than by a word of any one language,
|
||||
// so that a caller need not know which language the suite is written in. The
|
||||
// word itself is taken too, for whoever has it at hand.
|
||||
func TestRuleTakesTheStepByCategoryOrByWord(t *testing.T) {
|
||||
root := retirable(t)
|
||||
|
||||
run(t, root, "", false, "suite", "rule",
|
||||
"--prefix", "SLOG", "--title", "Через категорию", "--modality", "not-recommended",
|
||||
"--norm", "Норма.", "--why", "Причина.")
|
||||
run(t, root, "", false, "suite", "rule",
|
||||
"--prefix", "SLOG", "--title", "Через слово", "--modality", "ДОПУСКАЕТСЯ",
|
||||
"--norm", "Норма.", "--why", "Причина.")
|
||||
|
||||
body := read(t, root, "conventions/logging.md")
|
||||
for _, want := range []string{"**НЕ СЛЕДУЕТ.** Норма.", "**ДОПУСКАЕТСЯ.** Норма."} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("the step did not render as %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
checkClean(t, root)
|
||||
}
|
||||
|
||||
func TestRuleRefusesWhatItCannotDo(t *testing.T) {
|
||||
root := retirable(t)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{{
|
||||
name: "a prefix the suite does not declare",
|
||||
args: []string{"--prefix", "ZZZZ", "--title", "Т", "--modality", "requirement", "--norm", "Н", "--why", "П"},
|
||||
want: "no live prefix ZZZZ",
|
||||
}, {
|
||||
name: "a step that is not on the scale",
|
||||
args: []string{"--prefix", "TIME", "--title", "Т", "--modality", "maybe", "--norm", "Н", "--why", "П"},
|
||||
want: "one of: requirement, prohibition",
|
||||
}, {
|
||||
name: "placing after a rule of another file",
|
||||
args: []string{"--prefix", "TIME", "--title", "Т", "--modality", "requirement", "--norm", "Н", "--why", "П", "--after", "SLOG-1"},
|
||||
want: "belongs to another file",
|
||||
}, {
|
||||
name: "a rationale left out",
|
||||
args: []string{"--prefix", "TIME", "--title", "Т", "--modality", "requirement", "--norm", "Н"},
|
||||
want: "--why",
|
||||
}}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
code, out := run(t, root, "", false, append([]string{"suite", "rule"}, tc.args...)...)
|
||||
if code == cli.OK {
|
||||
t.Fatalf("the command went through:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, tc.want) {
|
||||
t.Errorf("the refusal does not say %q:\n%s", tc.want, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
checkClean(t, root)
|
||||
}
|
||||
|
||||
func TestListShowsTheSuiteBaseLayerFirst(t *testing.T) {
|
||||
root := retirable(t)
|
||||
|
||||
code, out := run(t, root, "", false, "suite", "list")
|
||||
if code != cli.OK {
|
||||
t.Fatalf("listing returned %d: %s", code, out)
|
||||
}
|
||||
for _, want := range []string{"time — время", "logging — логирование", "TIME", "GTIM", "lang=go", "base"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("the listing lacks %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Index(out, "conventions/time.md") > strings.Index(out, "conventions/lang/go/time.md") {
|
||||
t.Errorf("a language layer came before the base one:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// The selection is the read-only half of assembly: a layer travels when its
|
||||
// axis keys agree with the component, and the base layer travels always.
|
||||
func TestListSelectsWhatAComponentWouldTake(t *testing.T) {
|
||||
root := retirable(t)
|
||||
|
||||
code, out := run(t, root, "", false, "suite", "list", "--topic", "time", "--lang", "go")
|
||||
if code != cli.OK {
|
||||
t.Fatalf("listing returned %d: %s", code, out)
|
||||
}
|
||||
if strings.Contains(out, "left out") {
|
||||
t.Errorf("a component of the right language left a layer out:\n%s", out)
|
||||
}
|
||||
|
||||
code, out = run(t, root, "", false, "suite", "list", "--topic", "time", "--lang", "python")
|
||||
if code != cli.OK {
|
||||
t.Fatalf("listing returned %d: %s", code, out)
|
||||
}
|
||||
if !strings.Contains(out, "conventions/lang/go/time.md") || !strings.Contains(out, "left out") {
|
||||
t.Errorf("the go layer was not left out for a python component:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "conventions/time.md") {
|
||||
t.Errorf("the base layer did not travel:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListShowsRetiredNames(t *testing.T) {
|
||||
root := retirable(t)
|
||||
|
||||
code, out := run(t, root, "", false, "suite", "list", "--retired")
|
||||
if code != cli.OK {
|
||||
t.Fatalf("listing returned %d: %s", code, out)
|
||||
}
|
||||
if !strings.Contains(out, "nothing has been retired yet") {
|
||||
t.Errorf("an untouched suite reported retirements:\n%s", out)
|
||||
}
|
||||
|
||||
run(t, root, "", false, "suite", "retire", "--prefix", "SLOG", "--reason", "свёрнута", "--date", "2026-07-27")
|
||||
_, out = run(t, root, "", false, "suite", "list", "--retired")
|
||||
for _, want := range []string{"SLOG", "2026-07-27", "свёрнута", "ever handed out again"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("the listing of retired names lacks %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
|
||||
"git.vakhrushev.me/av/convy/internal/doc"
|
||||
"git.vakhrushev.me/av/convy/internal/lang"
|
||||
"git.vakhrushev.me/av/convy/internal/suite"
|
||||
)
|
||||
|
||||
// suite list answers two questions. Bare, it says what the suite holds: topics,
|
||||
// their layers, the prefixes taken. Given an axis, it says what a component
|
||||
// would take — which is the read-only half of assembly, and the reason to build
|
||||
// it before anything starts writing copies into other repositories.
|
||||
|
||||
func runSuiteList(env Env, args []string) ExitCode {
|
||||
fs := flag.NewFlagSet("convy suite list", 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", "", "show one topic only")
|
||||
langAxis := fs.String("lang", "", "language of the component, to show what it would take")
|
||||
stackAxis := fs.String("stack", "", "stack of the component, to show what it would take")
|
||||
retired := fs.Bool("retired", false, "show the retired names instead of the live ones")
|
||||
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
|
||||
}
|
||||
|
||||
if *retired {
|
||||
listRetired(env.Out, s)
|
||||
return OK
|
||||
}
|
||||
|
||||
topics := s.Manifest.LiveTopics()
|
||||
if *topic != "" {
|
||||
if !s.Manifest.TopicLive(*topic) {
|
||||
fmt.Fprintf(env.Err, "the suite declares no live topic %q\n", *topic)
|
||||
return Usage
|
||||
}
|
||||
topics = []string{*topic}
|
||||
}
|
||||
|
||||
// The path column is sized to the suite rather than guessed: a name that
|
||||
// overruns a fixed width breaks every row below it.
|
||||
width := 0
|
||||
for _, d := range s.Docs {
|
||||
width = max(width, len(d.Path))
|
||||
}
|
||||
|
||||
selecting := *langAxis != "" || *stackAxis != ""
|
||||
component := suite.Component{Lang: *langAxis, Stack: *stackAxis}
|
||||
for i, name := range topics {
|
||||
if i > 0 {
|
||||
fmt.Fprintln(env.Out)
|
||||
}
|
||||
listTopic(env.Out, s, name, component, selecting, width)
|
||||
}
|
||||
|
||||
if !selecting {
|
||||
fmt.Fprintf(env.Out, "\n%s, %s, language version %d (%s)\n",
|
||||
plural(len(topics), "topic"), plural(len(s.Docs), "file"),
|
||||
s.Manifest.Language.Version, s.Manifest.Language.Lang)
|
||||
}
|
||||
return OK
|
||||
}
|
||||
|
||||
func listTopic(w io.Writer, s *suite.Suite, name string, c suite.Component, selecting bool, width int) {
|
||||
fmt.Fprintf(w, "%s — %s\n", name, s.Manifest.Topics.Live[name])
|
||||
|
||||
if !selecting {
|
||||
for _, d := range s.Layers(name) {
|
||||
fmt.Fprintln(w, " "+layerLine(s, d, width))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
taken, left := s.Assemble(name, c)
|
||||
if len(taken) == 0 {
|
||||
fmt.Fprintln(w, " nothing: the topic has no layer this component takes")
|
||||
}
|
||||
for _, d := range taken {
|
||||
fmt.Fprintln(w, " "+layerLine(s, d, width))
|
||||
}
|
||||
for _, d := range left {
|
||||
fmt.Fprintln(w, " · "+layerLine(s, d, width)+" left out")
|
||||
}
|
||||
}
|
||||
|
||||
// layerLine describes one layer: its prefix, where it lies, what axis it is on
|
||||
// and how much of it there is.
|
||||
func layerLine(s *suite.Suite, d *doc.Document, width int) string {
|
||||
rules, retired := 0, 0
|
||||
for _, r := range d.Rules {
|
||||
rules++
|
||||
if _, ok := r.Block(lang.Retired); ok {
|
||||
retired++
|
||||
}
|
||||
}
|
||||
count := plural(rules, "rule")
|
||||
if retired > 0 {
|
||||
count += fmt.Sprintf(", %d retired", retired)
|
||||
}
|
||||
return fmt.Sprintf("%-6s %-*s %-22s %s", s.Prefix(d), width, d.Path, suite.Axis(d), count)
|
||||
}
|
||||
|
||||
func listRetired(w io.Writer, s *suite.Suite) {
|
||||
sections := []struct {
|
||||
title string
|
||||
entries map[string]string
|
||||
}{
|
||||
{"topics", s.Manifest.Topics.Retired},
|
||||
{"prefixes", s.Manifest.Prefixes.Retired},
|
||||
}
|
||||
empty := true
|
||||
for _, section := range sections {
|
||||
if len(section.entries) == 0 {
|
||||
continue
|
||||
}
|
||||
empty = false
|
||||
fmt.Fprintf(w, "%s\n", section.title)
|
||||
for _, key := range sortedKeys(section.entries) {
|
||||
fmt.Fprintf(w, " %-16s %s\n", key, section.entries[key])
|
||||
}
|
||||
}
|
||||
if empty {
|
||||
fmt.Fprintln(w, "nothing has been retired yet")
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, "\nnone of these names is ever handed out again")
|
||||
}
|
||||
|
||||
func sortedKeys(m map[string]string) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user