diff --git a/internal/check/spread.go b/internal/check/spread.go index 581ac3b..cee1b91 100644 --- a/internal/check/spread.go +++ b/internal/check/spread.go @@ -3,6 +3,7 @@ package check import ( "path" "regexp" + "sort" "strings" "git.vakhrushev.me/av/convy/internal/doc" @@ -74,7 +75,13 @@ func checkExtends(s *suite.Suite, d *doc.Document, rep *Report) { return } at := d.Front.At["extends"] - target := resolveExtends(s, d.Front.Extends) + target, others := resolveExtends(s, d, d.Front.Extends) + if len(others) > 1 { + rep.Errorf(Spread, d.Path, at, + "extends points at %q, and the suite holds several files it could mean (%v): give the path from the root of the suite", + d.Front.Extends, others) + return + } if target == nil { rep.Errorf(Spread, d.Path, at, "extends points at %q, and the suite holds no such file", d.Front.Extends) @@ -91,17 +98,52 @@ func checkExtends(s *suite.Suite, d *doc.Document, rep *Report) { } } -// resolveExtends looks up the document at the path written in extends. The path -// is given from the conventions directory, so both it and a path from the root -// of the suite are tried. -func resolveExtends(s *suite.Suite, ref string) *doc.Document { +// resolveExtends looks up the document at the path written in extends. +// +// The path may be given from the root of the suite or from the directory of +// conventions, so an exact match is tried first and a tail match second. A tail +// match can fit several files at once — two layers of one topic often share a +// file name — so every candidate is returned and the caller reports the +// ambiguity instead of picking by the order documents happen to be loaded in. +// The document doing the extending is never its own base. +func resolveExtends(s *suite.Suite, from *doc.Document, ref string) (*doc.Document, []string) { ref = path.Clean(strings.TrimPrefix(ref, "./")) + var candidates []*doc.Document for _, d := range s.Docs { - if d.Path == ref || strings.HasSuffix(d.Path, "/"+ref) { - return d + if d == from { + continue + } + if d.Path == ref { + return d, []string{d.Path} + } + if strings.HasSuffix(d.Path, "/"+ref) { + candidates = append(candidates, d) } } - return nil + paths := make([]string, len(candidates)) + for i, d := range candidates { + paths[i] = d.Path + } + sort.Strings(paths) + if len(candidates) == 1 { + return candidates[0], paths + } + return nil, paths +} + +// namesSuiteFile reports whether a candidate written in the text names a file +// the suite holds. Unlike an extends key it needs no single answer: a path that +// fits several files of the canon is a path all the same. +func namesSuiteFile(s *suite.Suite, candidate string) bool { + if s.Exists(candidate) { + return true + } + for _, d := range s.Docs { + if d.Path == candidate || strings.HasSuffix(d.Path, "/"+candidate) { + return true + } + } + return false } // checkMechanized looks for the mark of mechanization in the text of a @@ -142,8 +184,7 @@ func checkCanonPaths(s *suite.Suite, d *doc.Document, rep *Report) { continue } for _, candidate := range mdPathRe.FindAllString(d.Line(n), -1) { - target := resolveExtends(s, candidate) - if target == nil && !s.Exists(candidate) { + if !namesSuiteFile(s, candidate) { continue } rep.Errorf(Spread, d.Path, n, diff --git a/internal/cli/cli.go b/internal/cli/cli.go index dbc5f2c..d251a8c 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -28,11 +28,14 @@ const ( // Env is the environment of a run. It is pulled out so that commands can be // tested without a process. type Env struct { - Dir string - Out io.Writer - Err io.Writer - NoTTY bool - Colors bool + Dir string + In io.Reader + Out io.Writer + Err io.Writer + // Interactive says whether input comes from a terminal. A command that + // asks questions refuses to start without one rather than blocking on an + // answer nobody is there to give. + Interactive bool } // Run parses the arguments and executes the command. @@ -60,15 +63,16 @@ 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 — check") + fmt.Fprintln(env.Err, "convy suite: a subcommand is required — init, add or check") return Usage } switch args[0] { case "check": return runSuiteCheck(env, args[1:]) - case "new": - fmt.Fprintln(env.Err, "the \"suite new\" command is not implemented yet") - return Usage + case "init": + return runSuiteInit(env, args[1:]) + case "add": + return runSuiteAdd(env, args[1:]) default: fmt.Fprintf(env.Err, "unknown subcommand %q for convy suite\n", args[0]) return Usage @@ -86,8 +90,13 @@ In a project: convy check check the form of what is here (not implemented) 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 check suite integrity: prefixes, topics, axes, links, form - convy suite new a new topic (not implemented) + +The commands that change something run in two modes. Bare, they ask for every +field with a hint attached — that mode is for a person. With flags, they take +everything at once and ask nothing — that mode is for agents and scripts. `) } @@ -99,6 +108,22 @@ func Main() int { fmt.Fprintln(os.Stderr, "cannot determine the current directory:", err) return int(Usage) } - env := Env{Dir: dir, Out: os.Stdout, Err: os.Stderr} + env := Env{ + Dir: dir, + In: os.Stdin, + Out: os.Stdout, + Err: os.Stderr, + Interactive: terminal(os.Stdin), + } return int(Run(env, os.Args[1:])) } + +// terminal reports whether a file is a character device, which is as close as +// the standard library gets to asking whether a person is on the other end. +func terminal(f *os.File) bool { + info, err := f.Stat() + if err != nil { + return false + } + return info.Mode()&os.ModeCharDevice != 0 +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 0000000..7151926 --- /dev/null +++ b/internal/cli/cli_test.go @@ -0,0 +1,251 @@ +package cli_test + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "git.vakhrushev.me/av/convy/internal/check" + "git.vakhrushev.me/av/convy/internal/cli" + "git.vakhrushev.me/av/convy/internal/suite" +) + +// run executes a command the way the process does, with input and output in +// hand. Interactive says whether a terminal is pretended to be there. +func run(t *testing.T, dir, input string, interactive bool, args ...string) (cli.ExitCode, string) { + t.Helper() + var out, errOut bytes.Buffer + env := cli.Env{ + Dir: dir, + In: strings.NewReader(input), + Out: &out, + Err: &errOut, + Interactive: interactive, + } + code := cli.Run(env, args) + return code, out.String() + errOut.String() +} + +func read(t *testing.T, parts ...string) string { + t.Helper() + body, err := os.ReadFile(filepath.Join(parts...)) + if err != nil { + t.Fatal(err) + } + return string(body) +} + +// checkClean asserts that the suite the commands built passes every check. +// This is the invariant worth the most: what the tool writes, the tool accepts. +func checkClean(t *testing.T, root string) { + t.Helper() + s, err := suite.Load(root) + if err != nil { + t.Fatalf("loading the suite the commands built: %v", err) + } + rep := check.Suite(s) + if rep.Errors() > 0 || rep.Warnings() > 0 { + var b strings.Builder + for _, f := range rep.Findings() { + fmt.Fprintf(&b, " %s: %s\n", f.Path, f.Msg) + } + t.Fatalf("the suite the commands built does not check clean:\n%s", b.String()) + } +} + +func TestInitAndAddInAutomaticMode(t *testing.T) { + root := filepath.Join(t.TempDir(), "suite") + + if code, out := run(t, ".", "", false, "suite", "init", "--path", root, "--lang", "ru"); code != cli.OK { + t.Fatalf("suite init returned %d: %s", code, out) + } + if code, out := run(t, root, "", false, "suite", "add", + "--topic", "time", "--about", "время: хранение и форматы", + "--prefix", "TIME", "--title", "Время", + "--intro", "Как приложение записывает моменты."); code != cli.OK { + t.Fatalf("suite add returned %d: %s", code, out) + } + if code, out := run(t, root, "", false, "suite", "add", + "--topic", "time", "--prefix", "GTIM", "--lang", "go", + "--title", "Время: реализация на Go"); code != cli.OK { + t.Fatalf("suite add of a layer returned %d: %s", code, out) + } + + base := read(t, root, "conventions/time.md") + for _, want := range []string{"topic: time", "prefix: TIME", "# Время", "ДОЛЖЕН", "версии 1"} { + if !strings.Contains(base, want) { + t.Errorf("the base layer lost %q:\n%s", want, base) + } + } + if strings.Contains(base, "extends:") { + t.Errorf("the base layer got an extends key:\n%s", base) + } + + layer := read(t, root, "conventions/lang/go/time.md") + for _, want := range []string{"lang: go", "extends: conventions/time.md"} { + if !strings.Contains(layer, want) { + t.Errorf("the language layer lost %q:\n%s", want, layer) + } + } + + checkClean(t, root) +} + +// The dialogue asks in the order the fields are declared, and a new topic gets +// one extra question about what it is for. +func TestAddInInteractiveMode(t *testing.T) { + root := filepath.Join(t.TempDir(), "suite") + if code, out := run(t, ".", "", false, "suite", "init", "--path", root, "--lang", "ru"); code != cli.OK { + t.Fatalf("suite init returned %d: %s", code, out) + } + + answers := strings.Join([]string{ + "logging", // topic + "логирование: уровни", // one line about a topic new to the suite + "SLOG", // prefix + "Логирование", // title + "", // language axis: the base layer + "", // stack axis + "Как приложение пишет записи.", // introductory prose + "", // path: the default offered + }, "\n") + "\n" + + code, out := run(t, root, answers, true, "suite", "add") + if code != cli.OK { + t.Fatalf("the dialogue returned %d: %s", code, out) + } + if !strings.Contains(out, "One line about the topic") { + t.Errorf("a topic new to the suite was not asked about:\n%s", out) + } + + body := read(t, root, "conventions/logging.md") + for _, want := range []string{"topic: logging", "prefix: SLOG", "# Логирование", "Как приложение пишет записи."} { + if !strings.Contains(body, want) { + t.Errorf("the file lost %q:\n%s", want, body) + } + } + if !strings.Contains(read(t, root, "suite.toml"), `logging = "логирование: уровни"`) { + t.Error("the topic did not reach the manifest") + } + checkClean(t, root) +} + +// A known topic is described already, so the dialogue skips that question. +func TestInteractiveSkipsTheQuestionAboutAKnownTopic(t *testing.T) { + 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", "Время") + + answers := "time\nGTIM\nВремя на Go\ngo\n\n\n\n" + code, out := run(t, root, answers, true, "suite", "add") + if code != cli.OK { + t.Fatalf("the dialogue returned %d: %s", code, out) + } + if strings.Contains(out, "One line about the topic") { + t.Errorf("a known topic was asked about again:\n%s", out) + } + if !strings.Contains(out, "the topic is known") { + t.Errorf("the dialogue did not say the topic is known:\n%s", out) + } + checkClean(t, root) +} + +// Without a terminal a bare command must refuse rather than block on an answer +// nobody is there to give. +func TestBareCommandRefusesWithoutATerminal(t *testing.T) { + root := filepath.Join(t.TempDir(), "suite") + run(t, ".", "", false, "suite", "init", "--path", root, "--lang", "ru") + + code, out := run(t, root, "", false, "suite", "add") + if code != cli.Usage { + t.Fatalf("expected a refusal, got %d: %s", code, out) + } + if !strings.Contains(out, "no terminal") { + t.Errorf("the refusal does not say why:\n%s", out) + } +} + +// Automatic mode names every missing field at once: being sent back one flag at +// a time is the worst way to learn what a command wants. +func TestAutomaticModeNamesEveryMissingField(t *testing.T) { + root := filepath.Join(t.TempDir(), "suite") + run(t, ".", "", false, "suite", "init", "--path", root, "--lang", "ru") + + code, out := run(t, root, "", false, "suite", "add", "--topic", "time") + if code != cli.Usage { + t.Fatalf("expected a refusal, got %d: %s", code, out) + } + for _, want := range []string{"--prefix", "--title"} { + if !strings.Contains(out, want) { + t.Errorf("the refusal does not name %s:\n%s", want, out) + } + } +} + +func TestAddRefusesWhatTheSuiteAlreadyHolds(t *testing.T) { + 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", "Время") + + cases := []struct { + name string + args []string + want string + }{{ + name: "a prefix already taken", + args: []string{"--topic", "config", "--about", "конфигурация", "--prefix", "TIME", "--title", "Конфигурация"}, + want: "already taken", + }, { + name: "a prefix on the letter reserved for consumers", + args: []string{"--topic", "config", "--about", "конфигурация", "--prefix", "XCFG", "--title", "Конфигурация"}, + want: "reserved for the local rules of consumers", + }, { + name: "a second base layer of one topic", + args: []string{"--topic", "time", "--prefix", "TIMB", "--title", "Время снова", "--path", "conventions/time-again.md"}, + want: "already holds a base layer", + }, { + name: "a topic new to the suite and undescribed", + args: []string{"--topic", "config", "--prefix", "CONF", "--title", "Конфигурация"}, + want: "--about is required", + }} + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + code, out := run(t, root, "", false, append([]string{"suite", "add"}, 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 TestInitRefusesInsideASuite(t *testing.T) { + root := filepath.Join(t.TempDir(), "suite") + run(t, ".", "", false, "suite", "init", "--path", root, "--lang", "ru") + + code, out := run(t, ".", "", false, "suite", "init", "--path", root) + if code != cli.Usage { + t.Fatalf("expected a refusal, got %d: %s", code, out) + } + if !strings.Contains(out, "this is a suite already") { + t.Errorf("the refusal does not say why:\n%s", out) + } +} + +func TestInitRefusesAnUnknownVocabulary(t *testing.T) { + root := filepath.Join(t.TempDir(), "suite") + code, out := run(t, ".", "", false, "suite", "init", "--path", root, "--lang", "xx") + if code != cli.Usage { + t.Fatalf("expected a refusal, got %d: %s", code, out) + } + if !strings.Contains(out, "unknown to the tool") { + t.Errorf("the refusal does not say why:\n%s", out) + } +} diff --git a/internal/cli/prompt.go b/internal/cli/prompt.go new file mode 100644 index 0000000..8b05211 --- /dev/null +++ b/internal/cli/prompt.go @@ -0,0 +1,122 @@ +package cli + +import ( + "bufio" + "errors" + "fmt" + "io" + "strings" +) + +// Commands that change something run in two modes, and which one is meant is +// read off the command line: bare means interactive, any flag means automatic. +// +// The two modes address two different callers. A person types the command and +// is walked through the fields with a hint for each; an agent or a script +// passes every field at once and must never be blocked waiting on a terminal +// that is not there. Neither mode guesses: a field that is required and absent +// stops the run in both. + +// Field is one thing a command asks for. +type Field struct { + // Flag is the name the field carries on the command line. + Flag string + // Ask is the question put to a person. + Ask string + // Hint is the one line explaining what belongs here and why. + Hint string + // Default is offered as the answer when the person just presses enter. + Default string + // Optional fields may stay empty. + Optional bool + // Check validates an answer; it is applied in both modes. + Check func(string) error +} + +// errStop ends the dialogue on end of input. +var errStop = errors.New("input ended") + +// dialogue asks the fields one by one, re-asking what did not pass validation. +type dialogue struct { + in *bufio.Reader + out io.Writer +} + +func newDialogue(env Env) *dialogue { + return &dialogue{in: bufio.NewReader(env.In), out: env.Out} +} + +// ask puts one question and returns the answer, looping until it validates. +func (d *dialogue) ask(f Field) (string, error) { + for { + if f.Hint != "" { + fmt.Fprintf(d.out, "\n%s\n", f.Hint) + } + prompt := f.Ask + switch { + case f.Default != "": + prompt += fmt.Sprintf(" [%s]", f.Default) + case f.Optional: + prompt += " [may stay empty]" + } + fmt.Fprintf(d.out, "%s: ", prompt) + + line, err := d.in.ReadString('\n') + answer := strings.TrimSpace(line) + if answer == "" && err != nil { + return "", errStop + } + if answer == "" { + answer = f.Default + } + if answer == "" && !f.Optional { + fmt.Fprintln(d.out, " the field is required") + continue + } + if err := validate(f, answer); err != nil { + fmt.Fprintf(d.out, " %s\n", err) + continue + } + return answer, 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 { + return nil + } + return f.Check(value) +} + +// resolve settles the fields in automatic mode: what came on the command line +// is validated, what is required and missing is named. Every missing field is +// reported at once — being sent back one flag at a time is the worst way to +// learn what a command wants. +func resolve(fields []Field, given map[string]string) error { + var missing []string + var problems []string + for _, f := range fields { + value := given[f.Flag] + if value == "" { + value = f.Default + given[f.Flag] = value + } + if value == "" { + if !f.Optional { + missing = append(missing, "--"+f.Flag) + } + continue + } + if err := validate(f, value); err != nil { + problems = append(problems, fmt.Sprintf("--%s: %s", f.Flag, err)) + } + } + if len(missing) > 0 { + problems = append(problems, "required and missing: "+strings.Join(missing, ", ")) + } + if len(problems) > 0 { + return errors.New(strings.Join(problems, "\n")) + } + return nil +} diff --git a/internal/cli/suiteadd.go b/internal/cli/suiteadd.go new file mode 100644 index 0000000..be2fa98 --- /dev/null +++ b/internal/cli/suiteadd.go @@ -0,0 +1,380 @@ +package cli + +import ( + "flag" + "fmt" + "os" + "path" + "path/filepath" + "regexp" + "strings" + + "git.vakhrushev.me/av/convy/internal/manifest" + "git.vakhrushev.me/av/convy/internal/suite" +) + +var ( + nameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) + kebabRe = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) +) + +func runSuiteAdd(env Env, args []string) ExitCode { + fs := flag.NewFlagSet("convy suite add", 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", "", "name of the topic the convention belongs to") + about := fs.String("about", "", "one line about the topic, for a new topic only") + prefix := fs.String("prefix", "", "prefix of the rules, four uppercase Latin letters") + title := fs.String("title", "", "title of the document") + intro := fs.String("intro", "", "introductory prose, one or two sentences") + langAxis := fs.String("lang", "", "language axis of the layer; empty means the base layer") + stackAxis := fs.String("stack", "", "stack axis of the layer; empty means the base layer") + file := fs.String("path", "", "path of the file from the root of the suite") + 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{ + "topic": *topic, "about": *about, "prefix": *prefix, + "title": *title, "intro": *intro, + "lang": *langAxis, "stack": *stackAxis, "path": *file, + } + + if len(args) == 0 { + if !env.Interactive { + fmt.Fprintln(env.Err, "convy suite add without arguments asks questions, and there is no terminal to ask on; pass --topic, --prefix and --title") + return Usage + } + if err := askAdd(env, s, given); err != nil { + return Usage + } + } else { + if err := resolve(addFields(s), given); err != nil { + fmt.Fprintln(env.Err, err) + return Usage + } + if given["path"] == "" { + given["path"] = defaultPath(given) + } + if err := checkTopicAbout(s, given); err != nil { + fmt.Fprintln(env.Err, err) + return Usage + } + } + + return writeConvention(env, s, given) +} + +// addFields describes what a convention needs to be added. The checks live here +// rather than at the point of writing so that both modes apply the same ones. +func addFields(s *suite.Suite) []Field { + return []Field{{ + Flag: "topic", + Ask: "Topic", + Hint: "The focus the rules are about: time, config, db-schema. A topic is the unit of subscription — a consumer takes it whole. The name never changes and is never reused.", + Check: func(v string) error { + if !nameRe.MatchString(v) { + return fmt.Errorf("a topic name travels into the file system of a consumer, so it is written in Latin letters and digits") + } + if s.Manifest.TopicRetired(v) { + return fmt.Errorf("the topic %q is retired and cannot be handed out again", v) + } + return nil + }, + }, { + Flag: "prefix", + Ask: "Prefix of the rules", + Hint: "Four uppercase Latin letters, unique across the suite, one per file. Pick a word that reads, not a formula: the prefix exists to be searched for. Rules will be numbered PREFIX-1, PREFIX-2.", + Check: func(v string) error { + if err := manifest.ValidPrefix(v); err != nil { + return err + } + if _, taken := s.Manifest.PathOf(v); taken { + return fmt.Errorf("the prefix %s is already taken in the suite", v) + } + if s.Manifest.PrefixRetired(v) { + return fmt.Errorf("the prefix %s is retired and is never reissued", v) + } + return nil + }, + }, { + Flag: "title", + Ask: "Title of the document", + Hint: "The level-one heading, in the language of the suite.", + }, { + Flag: "lang", + Ask: "Language axis", + Hint: "The programming language this layer is about, if it is about one: go, python. Leave empty for the base layer, the one that reaches every copy.", + Optional: true, + Check: axisCheck, + }, { + Flag: "stack", + Ask: "Stack axis", + Hint: "The tool or framework this layer is about, if it is about one: htmx, ansible. Leave empty together with the language axis to get the base layer.", + Optional: true, + Check: axisCheck, + }, { + Flag: "intro", + Ask: "Introductory prose", + Hint: "One or two sentences on what the convention covers. Key words in capitals do not belong here: prose is never a norm.", + Optional: true, + }} +} + +func axisCheck(v string) error { + if !nameRe.MatchString(v) { + return fmt.Errorf("an axis value becomes a directory name, so it is written in Latin letters and digits") + } + return nil +} + +// askAdd walks the dialogue, asking for the topic description only when the +// topic is new and for the path once the axis is known. +func askAdd(env Env, s *suite.Suite, given map[string]string) error { + d := newDialogue(env) + fields := addFields(s) + + for _, f := range fields { + answer, err := d.ask(f) + if err != nil { + fmt.Fprintln(env.Err, "\ninterrupted, nothing was written") + return err + } + given[f.Flag] = answer + + if f.Flag != "topic" { + continue + } + if s.Manifest.TopicLive(answer) { + fmt.Fprintf(env.Out, " the topic is known, this will be another layer of it\n") + continue + } + about, err := d.ask(Field{ + Flag: "about", + Ask: "One line about the topic", + Hint: "It goes into the manifest and builds the table of conventions in a consumer's README.", + }) + if err != nil { + fmt.Fprintln(env.Err, "\ninterrupted, nothing was written") + return err + } + given["about"] = about + } + + answer, err := d.ask(Field{ + Flag: "path", + Ask: "Path of the file", + Hint: "Where the file lies in the suite. The directory tree documents the tie between layers for a human; what a layer actually is comes from the front matter.", + Default: defaultPath(given), + }) + if err != nil { + fmt.Fprintln(env.Err, "\ninterrupted, nothing was written") + return err + } + given["path"] = answer + return nil +} + +// checkTopicAbout guards the one field whose need depends on another: a new +// topic has to be described, a known one is described already. +func checkTopicAbout(s *suite.Suite, given map[string]string) error { + if s.Manifest.TopicLive(given["topic"]) { + return nil + } + if given["about"] == "" { + return fmt.Errorf("the topic %q is new to the suite, so --about is required: the line describes it in the manifest and in a consumer's README", given["topic"]) + } + return nil +} + +// defaultPath puts a file where the axis says it belongs. The path is +// documentation, not a declaration — but documentation that agrees with the +// declaration costs nothing to produce. +func defaultPath(given map[string]string) string { + topic := given["topic"] + switch { + case given["lang"] != "": + return path.Join("conventions/lang", given["lang"], topic+".md") + case given["stack"] != "": + return path.Join("conventions/stack", given["stack"], topic+".md") + } + return path.Join("conventions", topic+".md") +} + +// writeConvention writes the file and splices the manifest. The file goes first +// and the manifest second, because a file the manifest does not declare is +// reported by the check, while a declared file that is missing is an error the +// author has to undo by hand. +func writeConvention(env Env, s *suite.Suite, given map[string]string) ExitCode { + if given["lang"] != "" && given["stack"] != "" { + // Both axes at once is a layer meaningful only when language and tool + // coincide. The model allows it; the default path does not express + // it, so the path has to be given explicitly. + if given["path"] == "" { + fmt.Fprintln(env.Err, "a layer on both axes needs --path: the tree cannot express two axes at once") + return Usage + } + } + rel := filepath.ToSlash(given["path"]) + name := filepath.Join(s.Root, filepath.FromSlash(rel)) + if _, err := os.Stat(name); err == nil { + fmt.Fprintf(env.Err, "%s already exists\n", rel) + return Usage + } + + base := baseLayerOf(s, given["topic"]) + if base == "" && given["lang"] == "" && given["stack"] == "" { + // The first layer of a topic and no axis: this is the base one. + } else if base == "" { + fmt.Fprintf(env.Out, "note: the topic has no base layer yet, so this one stands alone\n") + } else if given["lang"] == "" && given["stack"] == "" { + fmt.Fprintf(env.Err, "the topic %q already holds a base layer (%s): a second one would put two base texts in one assembled file\n", given["topic"], base) + return Usage + } + + body := conventionSkeleton(s, given, base, rel) + if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil { + fmt.Fprintln(env.Err, err) + return Usage + } + if err := os.WriteFile(name, []byte(body), 0o644); err != nil { + fmt.Fprintln(env.Err, err) + return Usage + } + + source, err := os.ReadFile(s.Manifest.Path) + if err != nil { + fmt.Fprintln(env.Err, err) + return Failed + } + if !s.Manifest.TopicLive(given["topic"]) { + source, err = manifest.AddEntry(source, "topics.live", given["topic"], given["about"]) + if err != nil { + fmt.Fprintf(env.Err, "%s was written, but the manifest was not: %s\n", rel, err) + return Failed + } + } + source, err = manifest.AddEntry(source, "prefixes.live", given["prefix"], rel) + if err != nil { + fmt.Fprintf(env.Err, "%s was written, but the manifest was not: %s\n", rel, 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, "\ncreated %s\n", rel) + fmt.Fprintf(env.Out, "updated %s: prefix %s", manifest.Name, given["prefix"]) + if given["about"] != "" { + fmt.Fprintf(env.Out, ", topic %s", given["topic"]) + } + fmt.Fprintf(env.Out, "\n\nwrite the first rule as ### %s-1, then run convy suite check\n", given["prefix"]) + if !kebabRe.MatchString(given["topic"]) { + fmt.Fprintf(env.Out, "note: lower kebab-case is the recommended shape for a topic name\n") + } + return OK +} + +// baseLayerOf returns the path of the base layer of a topic, if the suite holds +// one. +func baseLayerOf(s *suite.Suite, topic string) string { + for _, d := range s.Layers(topic) { + if !d.Front.Axis() { + return d.Path + } + } + return "" +} + +// conventionSkeleton builds the file: front matter, title, prose and the +// language version line. The line is rendered out of the suite's vocabulary, +// which is the whole point of the vocabulary being a property of the language +// rather than of the code — what the tool writes, the tool also accepts. +func conventionSkeleton(s *suite.Suite, given map[string]string, base, rel string) string { + var b strings.Builder + b.WriteString("---\n") + fmt.Fprintf(&b, "topic: %s\n", given["topic"]) + fmt.Fprintf(&b, "prefix: %s\n", given["prefix"]) + if given["lang"] != "" { + fmt.Fprintf(&b, "lang: %s\n", given["lang"]) + } + if given["stack"] != "" { + fmt.Fprintf(&b, "stack: %s\n", given["stack"]) + } + if base != "" { + fmt.Fprintf(&b, "extends: %s\n", extendsRef(s, base, rel)) + } + b.WriteString("---\n\n") + + fmt.Fprintf(&b, "# %s\n\n", given["title"]) + if given["intro"] != "" { + fmt.Fprintf(&b, "%s\n\n", given["intro"]) + } + b.WriteString(s.Vocab.VersionLine()) + b.WriteString("\n") + return b.String() +} + +// extendsRef writes the base layer the way the suite already writes it: from +// the directory the two files share, when that form names one file and no more. +// +// The short form is what a reader expects, but two layers of one topic usually +// carry the same file name, so a tail like "time.md" can fit both the base +// layer and the file being written. Where that happens the reference falls back +// to the path from the root of the suite, which fits exactly one file always. +func extendsRef(s *suite.Suite, base, rel string) string { + short := base + shared := path.Dir(rel) + for shared != "." && shared != "/" { + if rest, ok := strings.CutPrefix(base, path.Dir(shared)+"/"); ok { + short = rest + break + } + shared = path.Dir(shared) + } + if short == base || unambiguous(s, short, base, rel) { + return short + } + return base +} + +// unambiguous reports whether a tail names the base layer and nothing else, +// counting the file about to be written as one of the suite's own. +func unambiguous(s *suite.Suite, short, base, rel string) bool { + paths := []string{rel} + for _, d := range s.Docs { + paths = append(paths, d.Path) + } + hits := 0 + for _, p := range paths { + if p == short || strings.HasSuffix(p, "/"+short) { + hits++ + } + } + return hits == 1 && (base == short || strings.HasSuffix(base, "/"+short)) +} + +// suiteRoot settles the directory a suite command works in. +func suiteRoot(env Env, given string) (string, ExitCode) { + if given != "" { + return given, OK + } + found, err := manifest.Find(env.Dir) + if err != nil { + fmt.Fprintf(env.Err, "not a conventions suite: no %s here or above\n", manifest.Name) + fmt.Fprintln(env.Err, "convy suite init starts one") + return "", Usage + } + return found, OK +} diff --git a/internal/cli/suiteinit.go b/internal/cli/suiteinit.go new file mode 100644 index 0000000..5dd73a6 --- /dev/null +++ b/internal/cli/suiteinit.go @@ -0,0 +1,145 @@ +package cli + +import ( + "flag" + "fmt" + "os" + "path/filepath" + + "git.vakhrushev.me/av/convy/internal/lang" + "git.vakhrushev.me/av/convy/internal/manifest" +) + +// manifestSkeleton is what a suite starts as. The tables stand empty but +// present: `convy suite add` splices entries into them, and a table that is not +// there is a table an edit cannot find. +// +// The two documents about the language are left commented out on purpose. The +// suite is expected to carry them, but the tool cannot author them, and a +// manifest pointing at a file that does not exist is a manifest that fails its +// own check on the first run. +const manifestSkeleton = `# The manifest of a conventions suite. +# +# Two manifests exist in the model, each named after what it describes: +# suite.toml here, in the suite, describes the suite itself; .conventions.toml +# in a project describes what that project subscribed to. Which of the two lies +# next to you tells you where you are. + +[language] +version = %d +lang = "%s" +# description = "LANGUAGE.md" # the full account of the language, stays with the author +# reading = "READING.md" # the short guide for a reader, travels into every copy + +# ─── Topics ───────────────────────────────────────────────────────────────── +# +# A topic is a set of rules about one focus of development, and the unit of +# subscription. The value is the one line about what the topic is for; the +# table of conventions in a consumer's README is built out of it. + +[topics.live] + +# Retired names land here together with a reason and a date, so that they can +# never be handed to another topic: the name lives on in foreign repositories. + +[topics.retired] + +# ─── Rule prefixes ────────────────────────────────────────────────────────── +# +# A prefix is four uppercase Latin letters, unique across the suite, chosen for +# a file rather than derived by a formula. The letter X is reserved for the +# local rules of consumers and is never taken here. Paths are given from the +# root of the repository. + +[prefixes.live] + +# Prefixes of deleted and split files land here, likewise never to be reissued. + +[prefixes.retired] +` + +func runSuiteInit(env Env, args []string) ExitCode { + fs := flag.NewFlagSet("convy suite init", flag.ContinueOnError) + fs.SetOutput(env.Err) + path := fs.String("path", "", "directory of the suite; it is created if absent") + code := fs.String("lang", "", "code of the natural language the suite is written in") + version := fs.Int("language-version", 1, "version of the conventions language") + if err := fs.Parse(args); err != nil { + return Usage + } + + fields := []Field{{ + Flag: "path", + Ask: "Directory of the suite", + Hint: "Where the suite will live. The directory is created if it is not there yet.", + Default: ".", + }, { + Flag: "lang", + Ask: "Natural language of the suite", + Hint: "The language the rules are written in. It settles the key words: ДОЛЖЕН and ПОЧЕМУ for ru, MUST and WHY for en.", + Default: manifest.DefaultLanguageCode, + Check: func(v string) error { + _, err := lang.Lookup(*version, v) + return err + }, + }} + + given := map[string]string{"path": *path, "lang": *code} + if len(args) == 0 { + if !env.Interactive { + fmt.Fprintln(env.Err, "convy suite init without arguments asks questions, and there is no terminal to ask on; pass --path and --lang") + return Usage + } + if err := askAll(env, fields, given); err != nil { + return Usage + } + } else if err := resolve(fields, given); err != nil { + fmt.Fprintln(env.Err, err) + return Usage + } + + root := given["path"] + if _, err := os.Stat(filepath.Join(root, manifest.Name)); err == nil { + fmt.Fprintf(env.Err, "%s already holds %s: this is a suite already\n", root, manifest.Name) + return Usage + } + if err := os.MkdirAll(filepath.Join(root, "conventions"), 0o755); err != nil { + fmt.Fprintln(env.Err, err) + return Usage + } + + body := fmt.Sprintf(manifestSkeleton, *version, given["lang"]) + name := filepath.Join(root, manifest.Name) + if err := os.WriteFile(name, []byte(body), 0o644); err != nil { + fmt.Fprintln(env.Err, err) + return Usage + } + + fmt.Fprintf(env.Out, "\ncreated %s\ncreated %s\n", name, filepath.Join(root, "conventions")) + fmt.Fprintf(env.Out, "\nthe suite speaks %s, conventions language version %d\n", given["lang"], *version) + fmt.Fprint(env.Out, ` +next: + write the two documents about the language and name them in [language] + convy suite add add the first convention + convy suite check verify the suite holds together +`) + return OK +} + +// askAll walks the fields in interactive mode, keeping answers already given on +// the command line. +func askAll(env Env, fields []Field, given map[string]string) error { + d := newDialogue(env) + for _, f := range fields { + if given[f.Flag] != "" { + continue + } + answer, err := d.ask(f) + if err != nil { + fmt.Fprintln(env.Err, "\ninterrupted, nothing was written") + return err + } + given[f.Flag] = answer + } + return nil +} diff --git a/internal/lang/vocabulary.go b/internal/lang/vocabulary.go index 23a8d73..2fdfdf4 100644 --- a/internal/lang/vocabulary.go +++ b/internal/lang/vocabulary.go @@ -95,6 +95,17 @@ type Vocabulary struct { Modals map[string]Level Marks map[string]Mark Scenario map[string]Connective + // Line is the language version line, with a single verb for the version + // number. Every convention names the language by one such line, and the + // wording around the words is as much a property of the natural language + // as the words themselves — which is why it lives here and not in a + // template inside the command that writes a new file. + Line string +} + +// VersionLine renders the language version line for this vocabulary. +func (v Vocabulary) VersionLine() string { + return fmt.Sprintf(v.Line, v.Version) } // Modal reports the step of a word if the word belongs to this scale. @@ -200,6 +211,9 @@ var registry = map[int]map[string]Vocabulary{ "И": And, "ИЛИ": Or, }, + Line: "Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки\n" + + "ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке\n" + + "конвенций версии %d — тогда и только тогда, когда написаны заглавными.", }, "en": { Version: 1, @@ -223,6 +237,9 @@ var registry = map[int]map[string]Vocabulary{ "AND": And, "OR": Or, }, + Line: "The key words MUST, MUST NOT, SHOULD, SHOULD NOT, MAY and the marks WHY,\n" + + "EXAMPLES, MECHANIZED and RETIRED are to be interpreted as described in the\n" + + "conventions language, version %d, and only when written in capitals.", }, }, } diff --git a/internal/manifest/edit.go b/internal/manifest/edit.go new file mode 100644 index 0000000..5333b37 --- /dev/null +++ b/internal/manifest/edit.go @@ -0,0 +1,115 @@ +package manifest + +import ( + "fmt" + "regexp" + "slices" + "sort" + "strings" +) + +// The manifest is edited as text rather than decoded and written back. +// +// suite.toml carries more comment than data — the reasoning behind every topic +// and every prefix lives there, and an encoder would drop all of it and reorder +// what is left. So an entry is spliced into the source, and everything the +// author wrote around it survives untouched. + +var ( + tableRe = regexp.MustCompile(`^\s*\[([^\]]+)\]\s*$`) + keyRe = regexp.MustCompile(`^\s*("[^"]+"|[A-Za-z0-9_-]+)\s*=`) + bareRe = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) +) + +// AddEntry splices key = "value" into the given table of a TOML source. +// +// Where the entry lands follows what the table already does: a table whose keys +// are in alphabetical order keeps it, and one ordered by hand — by directory, +// by age, by whatever the author meant — gets the entry appended, because +// guessing at that order would scatter it. +func AddEntry(source []byte, table, key, value string) ([]byte, error) { + lines := strings.Split(string(source), "\n") + + start := -1 + for i, line := range lines { + if m := tableRe.FindStringSubmatch(line); m != nil && m[1] == table { + start = i + break + } + } + if start < 0 { + return nil, fmt.Errorf("the manifest holds no table [%s]", table) + } + + end := len(lines) + for i := start + 1; i < len(lines); i++ { + if tableRe.MatchString(lines[i]) { + end = i + break + } + } + + keys, at := tableKeys(lines, start+1, end) + if slices.Contains(keys, key) { + return nil, fmt.Errorf("the table [%s] already holds the key %s", table, key) + } + + entry := renderEntry(key, value) + insert := insertionPoint(keys, at, key, lines, start, end) + + out := make([]string, 0, len(lines)+1) + out = append(out, lines[:insert]...) + out = append(out, entry) + out = append(out, lines[insert:]...) + return []byte(strings.Join(out, "\n")), nil +} + +// tableKeys collects the keys of a table together with the line each sits on. +func tableKeys(lines []string, from, to int) (keys []string, at []int) { + for i := from; i < to; i++ { + m := keyRe.FindStringSubmatch(lines[i]) + if m == nil { + continue + } + keys = append(keys, strings.Trim(m[1], `"`)) + at = append(at, i) + } + return keys, at +} + +// insertionPoint picks the line the entry goes before. +func insertionPoint(keys []string, at []int, key string, lines []string, start, end int) int { + if len(keys) == 0 { + // An empty table owns the comments standing right under its header + // and nothing further: a comment block separated by a blank line + // belongs to the table header below it, not to this one. Walking to + // the end of the section instead would file the entry under the wrong + // explanation. + i := start + 1 + for i < end && strings.HasPrefix(strings.TrimSpace(lines[i]), "#") { + i++ + } + return i + } + if sort.StringsAreSorted(keys) { + for i, existing := range keys { + if key < existing { + return at[i] + } + } + } + return at[len(at)-1] + 1 +} + +// renderEntry writes one key-value line, quoting the key when it is not bare. +func renderEntry(key, value string) string { + if !bareRe.MatchString(key) { + key = `"` + escape(key) + `"` + } + return key + ` = "` + escape(value) + `"` +} + +func escape(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + return strings.ReplaceAll(s, `"`, `\"`) +} diff --git a/internal/manifest/edit_test.go b/internal/manifest/edit_test.go new file mode 100644 index 0000000..e653f34 --- /dev/null +++ b/internal/manifest/edit_test.go @@ -0,0 +1,130 @@ +package manifest_test + +import ( + "strings" + "testing" + + "git.vakhrushev.me/av/convy/internal/manifest" +) + +func TestAddEntryKeepsComments(t *testing.T) { + source := `# The suite manifest. + +[language] +version = 1 + +# ─── Topics ─── +# +# A topic is a set of rules about one focus of development. + +[topics.live] +config = "configuration" +time = "time" + +[topics.retired] +# Empty. Retired names land here together with a reason and a date. +` + got, err := manifest.AddEntry([]byte(source), "topics.live", "logging", "logging: levels, structure") + if err != nil { + t.Fatal(err) + } + out := string(got) + + for _, want := range []string{ + "# ─── Topics ───", + "# A topic is a set of rules about one focus of development.", + "# Empty. Retired names land here together with a reason and a date.", + `logging = "logging: levels, structure"`, + } { + if !strings.Contains(out, want) { + t.Errorf("the result lost %q:\n%s", want, out) + } + } +} + +// A table whose keys are already sorted keeps its order; one ordered by hand +// gets the entry appended, so that a grouping by directory survives. +func TestAddEntryRespectsExistingOrder(t *testing.T) { + sorted := `[topics.live] +config = "c" +time = "t" +` + got, err := manifest.AddEntry([]byte(sorted), "topics.live", "logging", "l") + if err != nil { + t.Fatal(err) + } + wantSorted := "[topics.live]\nconfig = \"c\"\nlogging = \"l\"\ntime = \"t\"\n" + if string(got) != wantSorted { + t.Errorf("a sorted table was not kept sorted:\n%s", got) + } + + grouped := `[prefixes.live] +TIME = "conventions/arch/time.md" +CONF = "conventions/arch/config.md" +GTIM = "conventions/lang/go/time.md" +` + got, err = manifest.AddEntry([]byte(grouped), "prefixes.live", "GCFG", "conventions/lang/go/config.md") + if err != nil { + t.Fatal(err) + } + if !strings.HasSuffix(strings.TrimRight(string(got), "\n"), `GCFG = "conventions/lang/go/config.md"`) { + t.Errorf("a hand-ordered table did not get the entry appended:\n%s", got) + } +} + +func TestAddEntryIntoEmptyTable(t *testing.T) { + source := `[topics.live] +# Nothing yet. + +[topics.retired] +` + got, err := manifest.AddEntry([]byte(source), "topics.live", "time", "time") + if err != nil { + t.Fatal(err) + } + want := "[topics.live]\n# Nothing yet.\ntime = \"time\"\n\n[topics.retired]\n" + if string(got) != want { + t.Errorf("insertion into an empty table went wrong:\n%q", got) + } +} + +// A comment block separated from an empty table by a blank line explains the +// table header standing below it, not the one above. Filing an entry after such +// a block puts it under the wrong explanation. +func TestAddEntryIntoEmptyTableStopsBeforeTheNextComment(t *testing.T) { + source := `[topics.live] + +# Retired names land here together with a reason and a date. + +[topics.retired] +` + got, err := manifest.AddEntry([]byte(source), "topics.live", "time", "time") + if err != nil { + t.Fatal(err) + } + want := "[topics.live]\ntime = \"time\"\n\n# Retired names land here together with a reason and a date.\n\n[topics.retired]\n" + if string(got) != want { + t.Errorf("the entry was filed under the wrong comment:\n%q", got) + } +} + +func TestAddEntryRejectsDuplicateAndMissingTable(t *testing.T) { + source := "[topics.live]\ntime = \"t\"\n" + if _, err := manifest.AddEntry([]byte(source), "topics.live", "time", "t"); err == nil { + t.Error("a duplicate key was accepted") + } + if _, err := manifest.AddEntry([]byte(source), "prefixes.live", "TIME", "x.md"); err == nil { + t.Error("a missing table was accepted") + } +} + +func TestAddEntryQuotesWhatIsNotBare(t *testing.T) { + source := "[topics.live]\n" + got, err := manifest.AddEntry([]byte(source), "topics.live", "web ui", `a "quoted" thing`) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(got), `"web ui" = "a \"quoted\" thing"`) { + t.Errorf("key or value was not escaped:\n%s", got) + } +}