Files
av 92bd1f463d манифесты стали данными, заведён convy sync
- убраны комментарии из suite.toml и .conventions.toml: файл, который
  машина переписывает, комментарий через круг не проносит; объяснения
  ушли в README рядом, который suite init теперь заводит
- удалена текстовая правка манифеста целиком — 520 строк ручного
  лексера TOML вместе со всем классом ошибок порчи данных
- запись идёт из структур энкодером; ключ, которого инструмент не
  знает, запись останавливает, а не теряется молча
- convy sync сверяет манифест и подводит под него раскладку файлов:
  чего не хватает — собирает, что осиротело — удаляет, копию с
  локальной частью не трогает никогда
2026-07-28 09:45:10 +03:00

368 lines
11 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/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
}
}
note := fmt.Sprintf("%s, was %s: %s", when, target.Path, given["reason"])
s.Manifest.Prefixes.Retire(prefix, note)
if err := s.Manifest.Save(); 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
}
}
s.Manifest.Topics.Retire(topic, when+": "+given["reason"])
if err := s.Manifest.Save(); 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
}