- suite retire снимает правило, конвенцию или тему: правило остаётся заглушкой с датой и причиной, имя уезжает в раздел выбывших, снятие с непогашенными ссылками отклоняется с перечнем мест - добавлена проверка META-30: короткое описание языка обязано называть все слова словаря, иначе читатель копии толкует их по памяти - retired-раздел манифеста больше не считается объявлением пути: снятый префикс означает, что файл ушёл вместе с ним
397 lines
12 KiB
Go
397 lines
12 KiB
Go
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
|
|
}
|