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 }