suite init и suite add: заведение набора и конвенции в двух режимах

- suite init создаёт директорию и манифест со скелетом таблиц; suite add пишет
  файл конвенции и вправляет запись в suite.toml, сохраняя комментарии
- без аргументов команды спрашивают поля с подсказками, с флагами берут всё
  сразу и не спрашивают ничего; без терминала пустой вызов отказывает
- строка о версии языка генерируется из словаря набора, поэтому созданный файл
  проходит suite check без правок
- починено разрешение extends: короткая форма бралась по суффиксу и могла
  указать на сам файл; теперь неоднозначность либо избегается при записи, либо
  сообщается ошибкой
This commit is contained in:
av
2026-07-27 10:40:57 +03:00
parent 709157237d
commit b29b5b5e6f
9 changed files with 1247 additions and 21 deletions
+51 -10
View File
@@ -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,
+33 -8
View File
@@ -29,10 +29,13 @@ const (
// tested without a process.
type Env struct {
Dir string
In io.Reader
Out io.Writer
Err io.Writer
NoTTY bool
Colors bool
// 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
}
+251
View File
@@ -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)
}
}
+122
View File
@@ -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
}
+380
View File
@@ -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
}
+145
View File
@@ -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
}
+17
View File
@@ -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.",
},
},
}
+115
View File
@@ -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, `"`, `\"`)
}
+130
View File
@@ -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)
}
}