suite retire и проверка словаря в документе для читателя
- suite retire снимает правило, конвенцию или тему: правило остаётся заглушкой с датой и причиной, имя уезжает в раздел выбывших, снятие с непогашенными ссылками отклоняется с перечнем мест - добавлена проверка META-30: короткое описание языка обязано называть все слова словаря, иначе читатель копии толкует их по памяти - retired-раздел манифеста больше не считается объявлением пути: снятый префикс означает, что файл ушёл вместе с ним
This commit is contained in:
+4
-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 or check")
|
||||
fmt.Fprintln(env.Err, "convy suite: a subcommand is required — init, add, retire or check")
|
||||
return Usage
|
||||
}
|
||||
switch args[0] {
|
||||
@@ -73,6 +73,8 @@ func runSuite(env Env, args []string) ExitCode {
|
||||
return runSuiteInit(env, args[1:])
|
||||
case "add":
|
||||
return runSuiteAdd(env, args[1:])
|
||||
case "retire":
|
||||
return runSuiteRetire(env, args[1:])
|
||||
default:
|
||||
fmt.Fprintf(env.Err, "unknown subcommand %q for convy suite\n", args[0])
|
||||
return Usage
|
||||
@@ -92,6 +94,7 @@ 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 retire retire a rule, a convention or a topic — never reusing it
|
||||
convy suite check suite integrity: prefixes, topics, axes, links, form
|
||||
|
||||
The commands that change something run in two modes. Bare, they ask for every
|
||||
|
||||
+28
-1
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -29,6 +30,8 @@ type Field struct {
|
||||
Default string
|
||||
// Optional fields may stay empty.
|
||||
Optional bool
|
||||
// Options, when given, are the only answers accepted.
|
||||
Options []string
|
||||
// Check validates an answer; it is applied in both modes.
|
||||
Check func(string) error
|
||||
}
|
||||
@@ -54,6 +57,11 @@ func (d *dialogue) ask(f Field) (string, error) {
|
||||
}
|
||||
prompt := f.Ask
|
||||
switch {
|
||||
case len(f.Options) > 0:
|
||||
prompt += fmt.Sprintf(" (%s)", strings.Join(f.Options, " / "))
|
||||
if f.Default != "" {
|
||||
prompt += fmt.Sprintf(" [%s]", f.Default)
|
||||
}
|
||||
case f.Default != "":
|
||||
prompt += fmt.Sprintf(" [%s]", f.Default)
|
||||
case f.Optional:
|
||||
@@ -81,9 +89,28 @@ func (d *dialogue) ask(f Field) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// confirm asks a yes-or-no question before something irreversible, defaulting
|
||||
// to no: a retirement that went through because the reader pressed enter is the
|
||||
// one mistake the model cannot undo.
|
||||
func (d *dialogue) confirm(question string) (bool, error) {
|
||||
fmt.Fprintf(d.out, "\n%s [y/N]: ", question)
|
||||
line, err := d.in.ReadString('\n')
|
||||
answer := strings.ToLower(strings.TrimSpace(line))
|
||||
if answer == "" && err != nil {
|
||||
return false, errStop
|
||||
}
|
||||
return answer == "y" || answer == "yes", nil
|
||||
}
|
||||
|
||||
// validate applies a field's check to a value that is not empty.
|
||||
func validate(f Field, value string) error {
|
||||
if value == "" || f.Check == nil {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/convy/internal/cli"
|
||||
)
|
||||
|
||||
// retirable builds a suite with two topics, three conventions and a couple of
|
||||
// rules, which is the least that lets every case of retirement be told apart.
|
||||
func retirable(t *testing.T) string {
|
||||
t.Helper()
|
||||
root := filepath.Join(t.TempDir(), "suite")
|
||||
run(t, ".", "", false, "suite", "init", "--path", root, "--lang", "ru")
|
||||
run(t, root, "", false, "suite", "add", "--topic", "time", "--about", "время", "--prefix", "TIME", "--title", "Время")
|
||||
run(t, root, "", false, "suite", "add", "--topic", "time", "--prefix", "GTIM", "--lang", "go", "--title", "Время на Go")
|
||||
run(t, root, "", false, "suite", "add", "--topic", "logging", "--about", "логирование", "--prefix", "SLOG", "--title", "Логирование")
|
||||
|
||||
appendRules(t, root, "conventions/time.md", `
|
||||
### TIME-1. Момент записывается в UTC
|
||||
|
||||
**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.
|
||||
|
||||
**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.
|
||||
|
||||
### TIME-2. Ширина строки фиксируется
|
||||
|
||||
**ДОЛЖЕН.** Внутри одной колонки длина строки времени одна.
|
||||
|
||||
**ПОЧЕМУ.** Лексикографическая сортировка совпадает с хронологией только среди
|
||||
строк одинаковой длины.
|
||||
`)
|
||||
appendRules(t, root, "conventions/logging.md", `
|
||||
### SLOG-1. Уровень выбирается по адресату
|
||||
|
||||
**ДОЛЖЕН.** Уровень отвечает на вопрос «кому сообщение».
|
||||
|
||||
**ПОЧЕМУ.** Адресат — единственный воспроизводимый признак.
|
||||
`)
|
||||
return root
|
||||
}
|
||||
|
||||
func appendRules(t *testing.T, root, rel, rules string) {
|
||||
t.Helper()
|
||||
name := filepath.Join(root, filepath.FromSlash(rel))
|
||||
body, err := os.ReadFile(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(name, append(body, []byte("\n## Правила\n"+rules)...), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetireRuleLeavesAStub(t *testing.T) {
|
||||
root := retirable(t)
|
||||
|
||||
code, out := run(t, root, "", false, "suite", "retire",
|
||||
"--rule", "TIME-2", "--reason", "ширина следует из TIME-1 и отдельного правила не требует",
|
||||
"--date", "2026-07-27")
|
||||
if code != cli.OK {
|
||||
t.Fatalf("retiring a rule returned %d: %s", code, out)
|
||||
}
|
||||
|
||||
body := read(t, root, "conventions/time.md")
|
||||
if !strings.Contains(body, "### TIME-2. Ширина строки фиксируется") {
|
||||
t.Errorf("the heading and the number did not survive:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "**СНЯТО 2026-07-27.** ширина следует из TIME-1") {
|
||||
t.Errorf("the stub is not there or is worded wrong:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, "Лексикографическая сортировка") {
|
||||
t.Errorf("the rationale of the retired rule survived:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, "**ДОЛЖЕН.** Внутри одной колонки") {
|
||||
t.Errorf("the norm of the retired rule survived:\n%s", body)
|
||||
}
|
||||
|
||||
// The numbering stays contiguous and the whole suite still checks clean —
|
||||
// which is the point of a stub over a deletion.
|
||||
checkClean(t, root)
|
||||
}
|
||||
|
||||
func TestRetireRuleRefusesWhatItCannotDo(t *testing.T) {
|
||||
root := retirable(t)
|
||||
run(t, root, "", false, "suite", "retire", "--rule", "TIME-2", "--reason", "уже снято", "--date", "2026-07-27")
|
||||
|
||||
cases := []struct{ name, id, want string }{
|
||||
{"a rule already retired", "TIME-2", "retired already"},
|
||||
{"a number no rule carries", "TIME-9", "holds no rule numbered 9"},
|
||||
{"a prefix the suite does not declare", "ZZZZ-1", "no live prefix ZZZZ"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
code, out := run(t, root, "", false, "suite", "retire", "--rule", tc.id, "--reason", "почему-то")
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A file takes its rules with it, and nothing stands in for a file the way a
|
||||
// stub stands in for a rule. So a convention still pointed at cannot go.
|
||||
func TestRetirePrefixRefusesWhileStillPointedAt(t *testing.T) {
|
||||
root := retirable(t)
|
||||
|
||||
name := filepath.Join(root, "conventions", "logging.md")
|
||||
body, _ := os.ReadFile(name)
|
||||
if err := os.WriteFile(name, append(body,
|
||||
[]byte("\nВремя в записи следует TIME-1.\n")...), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
code, out := run(t, root, "", false, "suite", "retire", "--prefix", "TIME", "--reason", "тема закрыта")
|
||||
if code == cli.OK {
|
||||
t.Fatalf("a convention still pointed at was retired:\n%s", out)
|
||||
}
|
||||
for _, want := range []string{"still pointed at", "conventions/logging.md", "TIME-1"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("the refusal does not say %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "conventions", "time.md")); err != nil {
|
||||
t.Error("the file was removed despite the refusal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetirePrefixMovesTheNameAndTakesTheFile(t *testing.T) {
|
||||
root := retirable(t)
|
||||
|
||||
code, out := run(t, root, "", false, "suite", "retire",
|
||||
"--prefix", "SLOG", "--reason", "тема свёрнута", "--date", "2026-07-27")
|
||||
if code != cli.OK {
|
||||
t.Fatalf("retiring a convention returned %d: %s", code, out)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "conventions", "logging.md")); !os.IsNotExist(err) {
|
||||
t.Error("the file stayed behind")
|
||||
}
|
||||
|
||||
toml := read(t, root, "suite.toml")
|
||||
if strings.Contains(toml, `SLOG = "conventions/logging.md"`) {
|
||||
t.Errorf("the prefix stayed in the live half:\n%s", toml)
|
||||
}
|
||||
if !strings.Contains(toml, `SLOG = "2026-07-27, was conventions/logging.md: тема свёрнута"`) {
|
||||
t.Errorf("the retired half does not carry the date, the path and the reason:\n%s", toml)
|
||||
}
|
||||
if !strings.Contains(out, "no layer left") {
|
||||
t.Errorf("the report does not point out that the topic is now empty:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// A topic lives as long as one layer of it does, so the name cannot go first.
|
||||
func TestRetireTopicWaitsForItsLayers(t *testing.T) {
|
||||
root := retirable(t)
|
||||
|
||||
code, out := run(t, root, "", false, "suite", "retire", "--topic", "time", "--reason", "не нужна")
|
||||
if code == cli.OK {
|
||||
t.Fatalf("a topic with layers was retired:\n%s", out)
|
||||
}
|
||||
for _, want := range []string{"still holds layers", "conventions/time.md", "conventions/lang/go/time.md"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("the refusal does not say %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
|
||||
run(t, root, "", false, "suite", "retire", "--prefix", "GTIM", "--reason", "слой снят", "--date", "2026-07-27")
|
||||
run(t, root, "", false, "suite", "retire", "--prefix", "TIME", "--reason", "слой снят", "--date", "2026-07-27")
|
||||
|
||||
code, out = run(t, root, "", false, "suite", "retire",
|
||||
"--topic", "time", "--reason", "решение переехало в logging", "--date", "2026-07-27")
|
||||
if code != cli.OK {
|
||||
t.Fatalf("retiring an empty topic returned %d: %s", code, out)
|
||||
}
|
||||
|
||||
toml := read(t, root, "suite.toml")
|
||||
if strings.Contains(toml, `time = "время"`) {
|
||||
t.Errorf("the topic stayed in the live half:\n%s", toml)
|
||||
}
|
||||
if !strings.Contains(toml, `time = "2026-07-27: решение переехало в logging"`) {
|
||||
t.Errorf("the retired half does not carry the date and the reason:\n%s", toml)
|
||||
}
|
||||
checkClean(t, root)
|
||||
}
|
||||
|
||||
func TestRetireNeedsExactlyOneTarget(t *testing.T) {
|
||||
root := retirable(t)
|
||||
|
||||
code, out := run(t, root, "", false, "suite", "retire",
|
||||
"--rule", "TIME-1", "--topic", "time", "--reason", "и то и другое")
|
||||
if code == cli.OK {
|
||||
t.Fatalf("two targets at once went through:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "exactly one") {
|
||||
t.Errorf("the refusal does not say why:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Retirement is irreversible, so the dialogue asks before writing and takes
|
||||
// silence for no.
|
||||
func TestRetireInteractiveConfirms(t *testing.T) {
|
||||
root := retirable(t)
|
||||
|
||||
code, out := run(t, root, "rule\nTIME-2\nбольше не нужно\n\n", true, "suite", "retire")
|
||||
if code == cli.OK {
|
||||
t.Fatalf("an unconfirmed retirement went through:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "nothing was written") {
|
||||
t.Errorf("the report does not say that nothing happened:\n%s", out)
|
||||
}
|
||||
if strings.Contains(read(t, root, "conventions/time.md"), "**СНЯТО ") {
|
||||
t.Error("the file was written despite no confirmation")
|
||||
}
|
||||
|
||||
code, out = run(t, root, "rule\nTIME-2\nбольше не нужно\ny\n", true, "suite", "retire")
|
||||
if code != cli.OK {
|
||||
t.Fatalf("a confirmed retirement returned %d: %s", code, out)
|
||||
}
|
||||
if !strings.Contains(read(t, root, "conventions/time.md"), "**СНЯТО ") {
|
||||
t.Error("a confirmed retirement wrote nothing")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/convy/internal/doc"
|
||||
"git.vakhrushev.me/av/convy/internal/lang"
|
||||
"git.vakhrushev.me/av/convy/internal/manifest"
|
||||
"git.vakhrushev.me/av/convy/internal/suite"
|
||||
)
|
||||
|
||||
// Retirement is the one operation in the model whose mistakes cannot be undone.
|
||||
// A text rewritten badly gets rewritten again; a prefix handed out twice is a
|
||||
// reference from a foreign repository that now points at a different statement,
|
||||
// and that is discovered by its content rather than by any check.
|
||||
//
|
||||
// So the command does the bookkeeping the discipline otherwise leans on
|
||||
// attention for: it writes the date, it words the stub out of the suite's own
|
||||
// vocabulary, it moves the name into the retired half instead of deleting it,
|
||||
// and it refuses whatever would leave a reference pointing at nothing.
|
||||
|
||||
var ruleIDRe = regexp.MustCompile(`^([A-Z]{4})-(\d+)$`)
|
||||
|
||||
func runSuiteRetire(env Env, args []string) ExitCode {
|
||||
fs := flag.NewFlagSet("convy suite retire", flag.ContinueOnError)
|
||||
fs.SetOutput(env.Err)
|
||||
root := fs.String("root", "", "root of the suite; by default it is looked up upwards")
|
||||
rule := fs.String("rule", "", "identifier of a rule to retire, PREFIX-N")
|
||||
prefix := fs.String("prefix", "", "prefix of a convention to retire together with its file")
|
||||
topic := fs.String("topic", "", "name of a topic to retire")
|
||||
reason := fs.String("reason", "", "why it is being retired")
|
||||
date := fs.String("date", "", "date of retirement; today 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
|
||||
}
|
||||
|
||||
when := *date
|
||||
if when == "" {
|
||||
when = time.Now().Format("2006-01-02")
|
||||
}
|
||||
|
||||
given := map[string]string{"rule": *rule, "prefix": *prefix, "topic": *topic, "reason": *reason}
|
||||
talk := newDialogue(env)
|
||||
if len(args) == 0 {
|
||||
if !env.Interactive {
|
||||
fmt.Fprintln(env.Err, "convy suite retire without arguments asks questions, and there is no terminal to ask on; pass one of --rule, --prefix, --topic together with --reason")
|
||||
return Usage
|
||||
}
|
||||
if err := askRetire(env, talk, s, given); err != nil {
|
||||
return Usage
|
||||
}
|
||||
} else if err := resolve(retireFields(s), given); err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return Usage
|
||||
}
|
||||
|
||||
targets := 0
|
||||
for _, key := range []string{"rule", "prefix", "topic"} {
|
||||
if given[key] != "" {
|
||||
targets++
|
||||
}
|
||||
}
|
||||
if targets != 1 {
|
||||
fmt.Fprintln(env.Err, "name exactly one of --rule, --prefix, --topic: the three retire different things and undo differently")
|
||||
return Usage
|
||||
}
|
||||
|
||||
// The dialogue is built once and carried through: a second one over the
|
||||
// same input would find it drained, because the first reads ahead.
|
||||
var d *dialogue
|
||||
if env.Interactive {
|
||||
d = talk
|
||||
}
|
||||
switch {
|
||||
case given["rule"] != "":
|
||||
return retireRule(env, d, s, given, when)
|
||||
case given["prefix"] != "":
|
||||
return retirePrefix(env, d, s, given, when)
|
||||
default:
|
||||
return retireTopic(env, d, s, given, when)
|
||||
}
|
||||
}
|
||||
|
||||
func retireFields(s *suite.Suite) []Field {
|
||||
return []Field{{
|
||||
Flag: "rule",
|
||||
Ask: "Rule",
|
||||
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
|
||||
},
|
||||
}, {
|
||||
Flag: "prefix",
|
||||
Ask: "Prefix",
|
||||
Optional: true,
|
||||
Check: func(v string) error {
|
||||
if _, ok := s.Manifest.PathOf(v); !ok {
|
||||
return fmt.Errorf("the suite declares no live prefix %s", v)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}, {
|
||||
Flag: "topic",
|
||||
Ask: "Topic",
|
||||
Optional: true,
|
||||
Check: func(v string) error {
|
||||
if !s.Manifest.TopicLive(v) {
|
||||
return fmt.Errorf("the suite declares no live topic %q", v)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}, {
|
||||
Flag: "reason",
|
||||
Ask: "Reason",
|
||||
Hint: "Why it is going. The reason outlives the thing itself — it is what a reader finds in place of what they were looking for.",
|
||||
}}
|
||||
}
|
||||
|
||||
// askRetire walks the dialogue: what kind of thing, which one, and why.
|
||||
func askRetire(env Env, d *dialogue, s *suite.Suite, given map[string]string) error {
|
||||
fields := retireFields(s)
|
||||
|
||||
kind, err := d.ask(Field{
|
||||
Ask: "What is being retired",
|
||||
Hint: "A rule keeps its number and turns into a stub; a convention takes its file with it; a topic goes once no layer of it is left.",
|
||||
Options: []string{"rule", "convention", "topic"},
|
||||
Default: "rule",
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintln(env.Err, "\ninterrupted, nothing was written")
|
||||
return err
|
||||
}
|
||||
|
||||
flagOf := map[string]string{"rule": "rule", "convention": "prefix", "topic": "topic"}[kind]
|
||||
for _, f := range fields {
|
||||
if f.Flag != flagOf && f.Flag != "reason" {
|
||||
continue
|
||||
}
|
||||
f.Optional = false
|
||||
answer, err := d.ask(f)
|
||||
if err != nil {
|
||||
fmt.Fprintln(env.Err, "\ninterrupted, nothing was written")
|
||||
return err
|
||||
}
|
||||
given[f.Flag] = answer
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// retireRule replaces the norm and the rationale with a stub, keeping the
|
||||
// heading and the number. The number stays taken forever, so a reference from a
|
||||
// foreign repository lands on the explanation instead of on nothing.
|
||||
func retireRule(env Env, d *dialogue, s *suite.Suite, given map[string]string, when string) ExitCode {
|
||||
m := ruleIDRe.FindStringSubmatch(given["rule"])
|
||||
num, _ := strconv.Atoi(m[2])
|
||||
target, ok := s.ByPrefix[m[1]]
|
||||
if !ok {
|
||||
fmt.Fprintf(env.Err, "the suite declares no live prefix %s\n", m[1])
|
||||
return Usage
|
||||
}
|
||||
|
||||
var found *doc.Rule
|
||||
for i := range target.Rules {
|
||||
if target.Rules[i].Num == num {
|
||||
found = &target.Rules[i]
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
fmt.Fprintf(env.Err, "%s holds no rule numbered %d\n", target.Path, num)
|
||||
return Usage
|
||||
}
|
||||
if _, already := found.Block(lang.Retired); already {
|
||||
fmt.Fprintf(env.Err, "%s is retired already\n", found.ID())
|
||||
return Usage
|
||||
}
|
||||
|
||||
if d != nil {
|
||||
ok, err := d.confirm(fmt.Sprintf(
|
||||
"%s in %s loses its norm and its rationale, keeping the number. Go on?", found.ID(), target.Path))
|
||||
if err != nil || !ok {
|
||||
fmt.Fprintln(env.Out, "nothing was written")
|
||||
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")
|
||||
|
||||
stub := fmt.Sprintf("**%s %s.** %s", s.Vocab.MarkWord(lang.Retired), when, given["reason"])
|
||||
replacement := append([]string{""}, wrap(stub, 78)...)
|
||||
replacement = append(replacement, "")
|
||||
|
||||
out := make([]string, 0, len(lines))
|
||||
out = append(out, lines[:found.Line]...)
|
||||
out = append(out, replacement...)
|
||||
if found.End < len(lines) {
|
||||
out = append(out, lines[found.End:]...)
|
||||
}
|
||||
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 is retired in %s\n", found.ID(), target.Path)
|
||||
fmt.Fprintf(env.Out, "the number stays taken, and a reference to it now lands on the reason\n")
|
||||
return OK
|
||||
}
|
||||
|
||||
// retirePrefix retires a convention together with its file. The rules of that
|
||||
// file cease to exist, so the command refuses while anything still points at
|
||||
// them: a stub can stand in for a rule, but nothing stands in for a file.
|
||||
func retirePrefix(env Env, d *dialogue, s *suite.Suite, given map[string]string, when string) ExitCode {
|
||||
prefix := given["prefix"]
|
||||
target, ok := s.ByPrefix[prefix]
|
||||
if !ok {
|
||||
fmt.Fprintf(env.Err, "the suite declares no live prefix %s\n", prefix)
|
||||
return Usage
|
||||
}
|
||||
|
||||
if pointing := referencesTo(s, prefix, target); len(pointing) > 0 {
|
||||
fmt.Fprintf(env.Err, "%s is still pointed at, so retiring it would leave references resolving to nothing:\n", prefix)
|
||||
for _, where := range pointing {
|
||||
fmt.Fprintf(env.Err, " %s\n", where)
|
||||
}
|
||||
fmt.Fprintln(env.Err, "move or retire those rules first")
|
||||
return Usage
|
||||
}
|
||||
|
||||
if d != nil {
|
||||
ok, err := d.confirm(fmt.Sprintf(
|
||||
"%s goes, and %s is deleted. The prefix is never reissued. Go on?", target.Path, target.Path))
|
||||
if err != nil || !ok {
|
||||
fmt.Fprintln(env.Out, "nothing was written")
|
||||
return Usage
|
||||
}
|
||||
}
|
||||
|
||||
source, err := os.ReadFile(s.Manifest.Path)
|
||||
if err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return Failed
|
||||
}
|
||||
source, err = manifest.RemoveEntry(source, "prefixes.live", prefix)
|
||||
if err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return Failed
|
||||
}
|
||||
note := fmt.Sprintf("%s, was %s: %s", when, target.Path, given["reason"])
|
||||
source, err = manifest.AddEntry(source, "prefixes.retired", prefix, note)
|
||||
if err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return Failed
|
||||
}
|
||||
if err := os.WriteFile(s.Manifest.Path, source, 0o644); err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return Failed
|
||||
}
|
||||
if err := os.Remove(filepath.Join(s.Root, filepath.FromSlash(target.Path))); err != nil {
|
||||
fmt.Fprintf(env.Err, "the manifest was updated, but the file was not removed: %s\n", err)
|
||||
return Failed
|
||||
}
|
||||
|
||||
fmt.Fprintf(env.Out, "\n%s is retired and %s is gone\n", prefix, target.Path)
|
||||
if left := s.Layers(target.Front.Topic); len(left) == 1 {
|
||||
fmt.Fprintf(env.Out, "the topic %q has no layer left; retire the topic too if it is done\n", target.Front.Topic)
|
||||
}
|
||||
return OK
|
||||
}
|
||||
|
||||
// retireTopic sends a topic name to the retired half. A topic lives as long as
|
||||
// one layer of it does, so the command refuses while any layer is left.
|
||||
func retireTopic(env Env, d *dialogue, s *suite.Suite, given map[string]string, when string) ExitCode {
|
||||
topic := given["topic"]
|
||||
if layers := s.Layers(topic); len(layers) > 0 {
|
||||
fmt.Fprintf(env.Err, "the topic %q still holds layers, and a topic lives as long as one of them does:\n", topic)
|
||||
for _, d := range layers {
|
||||
fmt.Fprintf(env.Err, " %s\n", d.Path)
|
||||
}
|
||||
fmt.Fprintln(env.Err, "retire those conventions first")
|
||||
return Usage
|
||||
}
|
||||
|
||||
if d != nil {
|
||||
ok, err := d.confirm(fmt.Sprintf(
|
||||
"the name %q goes to the retired half and is never handed to another topic. Go on?", topic))
|
||||
if err != nil || !ok {
|
||||
fmt.Fprintln(env.Out, "nothing was written")
|
||||
return Usage
|
||||
}
|
||||
}
|
||||
|
||||
source, err := os.ReadFile(s.Manifest.Path)
|
||||
if err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return Failed
|
||||
}
|
||||
source, err = manifest.RemoveEntry(source, "topics.live", topic)
|
||||
if err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return Failed
|
||||
}
|
||||
source, err = manifest.AddEntry(source, "topics.retired", topic, when+": "+given["reason"])
|
||||
if err != nil {
|
||||
fmt.Fprintln(env.Err, 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, "\nthe topic %q is retired\n", topic)
|
||||
return OK
|
||||
}
|
||||
|
||||
// referencesTo finds where the rules of a prefix are pointed at from outside
|
||||
// the file that owns them.
|
||||
func referencesTo(s *suite.Suite, prefix string, own *doc.Document) []string {
|
||||
var out []string
|
||||
for _, d := range s.Docs {
|
||||
if d == own {
|
||||
continue
|
||||
}
|
||||
for _, ref := range refsOf(d, prefix) {
|
||||
out = append(out, fmt.Sprintf("%s:%d %s", d.Path, ref.line, ref.text))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type refAt struct {
|
||||
line int
|
||||
text string
|
||||
}
|
||||
|
||||
var anyRefRe = regexp.MustCompile(`\b[A-Z]{4}-\d+(?:\.\d+)?`)
|
||||
|
||||
func refsOf(d *doc.Document, prefix string) []refAt {
|
||||
var out []refAt
|
||||
for n := d.Body; n <= d.Len(); n++ {
|
||||
if d.Fenced(n) {
|
||||
continue
|
||||
}
|
||||
for _, text := range anyRefRe.FindAllString(doc.StripInline(d.Line(n)), -1) {
|
||||
if strings.HasPrefix(text, prefix+"-") {
|
||||
out = append(out, refAt{line: n, text: text})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// wrap breaks a paragraph at a width, the way the text around it is written by
|
||||
// hand. A stub that runs off in one long line reads as machine-made and gets
|
||||
// reflowed by the next person to touch the file.
|
||||
func wrap(text string, width int) []string {
|
||||
words := strings.Fields(text)
|
||||
if len(words) == 0 {
|
||||
return []string{""}
|
||||
}
|
||||
lines := []string{words[0]}
|
||||
for _, w := range words[1:] {
|
||||
last := len(lines) - 1
|
||||
if len([]rune(lines[last]))+1+len([]rune(w)) <= width {
|
||||
lines[last] += " " + w
|
||||
continue
|
||||
}
|
||||
lines = append(lines, w)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
Reference in New Issue
Block a user