suite rule и suite list

- suite rule дописывает правило: номер берётся следующим за наибольшим, блоки
  раскладываются в порядке норма, ПОЧЕМУ, ПРИМЕРЫ, --after ставит правило рядом
  с тем, которое оно уточняет
- ступень называется категорией (requirement, prohibition, ...), а не словом
  языка, поэтому вызывающему не нужно знать, на каком языке записан набор
- suite list показывает темы со слоями, а с осью — что возьмёт компонент; отбор
  слоёв вынесен в suite.Assemble, откуда его возьмут проектные команды
- слои темы теперь всегда возвращаются базовым вперёд
This commit is contained in:
av
2026-07-27 11:36:19 +03:00
parent 5321fba89d
commit 8331aa1ca5
8 changed files with 706 additions and 6 deletions
+7 -1
View File
@@ -63,7 +63,7 @@ func Run(env Env, args []string) ExitCode {
func runSuite(env Env, args []string) ExitCode { func runSuite(env Env, args []string) ExitCode {
if len(args) == 0 { if len(args) == 0 {
fmt.Fprintln(env.Err, "convy suite: a subcommand is required — init, add, retire or check") fmt.Fprintln(env.Err, "convy suite: a subcommand is required — init, add, rule, retire, list or check")
return Usage return Usage
} }
switch args[0] { switch args[0] {
@@ -73,8 +73,12 @@ func runSuite(env Env, args []string) ExitCode {
return runSuiteInit(env, args[1:]) return runSuiteInit(env, args[1:])
case "add": case "add":
return runSuiteAdd(env, args[1:]) return runSuiteAdd(env, args[1:])
case "rule":
return runSuiteRule(env, args[1:])
case "retire": case "retire":
return runSuiteRetire(env, args[1:]) return runSuiteRetire(env, args[1:])
case "list":
return runSuiteList(env, args[1:])
default: default:
fmt.Fprintf(env.Err, "unknown subcommand %q for convy suite\n", args[0]) fmt.Fprintf(env.Err, "unknown subcommand %q for convy suite\n", args[0])
return Usage return Usage
@@ -94,7 +98,9 @@ In a project:
In a suite: In a suite:
convy suite init start a suite: a directory and a manifest 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 add add a convention: a file, a topic and a prefix
convy suite rule add a rule: the next number, the blocks in order
convy suite retire retire a rule, a convention or a topic — never reusing it convy suite retire retire a rule, a convention or a topic — never reusing it
convy suite list what the suite holds, and what a component would take
convy suite check suite integrity: prefixes, topics, axes, links, form convy suite check suite integrity: prefixes, topics, axes, links, form
The commands that change something run in two modes. Bare, they ask for every The commands that change something run in two modes. Bare, they ask for every
+7 -3
View File
@@ -107,14 +107,18 @@ func validate(f Field, value string) error {
if value == "" { if value == "" {
return nil return nil
} }
// Options are what the dialogue offers; a check, where there is one, is
// what decides. A field may well accept more than it suggests — naming a
// step by its category is the suggestion, naming it by the word of the
// suite is accepted all the same.
if f.Check != nil {
return f.Check(value)
}
if len(f.Options) > 0 && !slices.Contains(f.Options, value) { if len(f.Options) > 0 && !slices.Contains(f.Options, value) {
return fmt.Errorf("one of: %s", strings.Join(f.Options, ", ")) return fmt.Errorf("one of: %s", strings.Join(f.Options, ", "))
} }
if f.Check == nil {
return nil return nil
} }
return f.Check(value)
}
// resolve settles the fields in automatic mode: what came on the command line // 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 // is validated, what is required and missing is named. Every missing field is
+201
View File
@@ -0,0 +1,201 @@
package cli_test
import (
"strings"
"testing"
"git.vakhrushev.me/av/convy/internal/cli"
)
func TestRuleTakesTheNextNumberAndKeepsTheOrderOfBlocks(t *testing.T) {
root := retirable(t)
code, out := run(t, root, "", false, "suite", "rule",
"--prefix", "TIME", "--title", "Точность носителя фиксируется",
"--modality", "requirement",
"--norm", "У колонки БД и у потока логов точность объявлена и не плавает.",
"--why", "Плавающая точность ломает сортировку выборочно и невоспроизводимо.")
if code != cli.OK {
t.Fatalf("adding a rule returned %d: %s", code, out)
}
if !strings.Contains(out, "TIME-3 added") {
t.Errorf("the number is not the next after the highest:\n%s", out)
}
body := read(t, root, "conventions/time.md")
want := "### TIME-3. Точность носителя фиксируется\n\n**ДОЛЖЕН.** У колонки БД и у потока логов точность объявлена и не плавает.\n\n**ПОЧЕМУ.** Плавающая точность ломает сортировку выборочно и невоспроизводимо."
if !strings.Contains(body, want) {
t.Errorf("the rule is not laid out as the language fixes it:\n%s", body)
}
checkClean(t, root)
}
// A number is never reused, so retiring a rule does not free its number: the
// next one still comes after the highest.
func TestRuleNumbersPastARetiredOne(t *testing.T) {
root := retirable(t)
run(t, root, "", false, "suite", "retire", "--rule", "TIME-2", "--reason", "не нужно", "--date", "2026-07-27")
code, out := run(t, root, "", false, "suite", "rule",
"--prefix", "TIME", "--title", "Третье", "--modality", "recommendation",
"--norm", "Норма.", "--why", "Причина.")
if code != cli.OK {
t.Fatalf("adding a rule returned %d: %s", code, out)
}
if !strings.Contains(out, "TIME-3 added") {
t.Errorf("the number of a retired rule was reused:\n%s", out)
}
checkClean(t, root)
}
// Order in a file goes by reading rather than by number, so a rule elaborating
// another has to be placeable next to it.
func TestRulePlacesAfterTheOneItElaborates(t *testing.T) {
root := retirable(t)
run(t, root, "", false, "suite", "rule",
"--prefix", "TIME", "--title", "Третье", "--modality", "recommendation",
"--norm", "Норма третьего.", "--why", "Причина третьего.")
code, out := run(t, root, "", false, "suite", "rule",
"--prefix", "TIME", "--title", "Уточнение первого", "--modality", "permission",
"--norm", "Норма четвёртого.", "--why", "Причина четвёртого.",
"--after", "TIME-1")
if code != cli.OK {
t.Fatalf("adding a rule returned %d: %s", code, out)
}
body := read(t, root, "conventions/time.md")
order := []string{"### TIME-1.", "### TIME-4.", "### TIME-2.", "### TIME-3."}
at := 0
for _, id := range order {
i := strings.Index(body[at:], id)
if i < 0 {
t.Fatalf("%s is missing or out of place:\n%s", id, body)
}
at += i
}
checkClean(t, root)
}
// The step is named by its category rather than by a word of any one language,
// so that a caller need not know which language the suite is written in. The
// word itself is taken too, for whoever has it at hand.
func TestRuleTakesTheStepByCategoryOrByWord(t *testing.T) {
root := retirable(t)
run(t, root, "", false, "suite", "rule",
"--prefix", "SLOG", "--title", "Через категорию", "--modality", "not-recommended",
"--norm", "Норма.", "--why", "Причина.")
run(t, root, "", false, "suite", "rule",
"--prefix", "SLOG", "--title", "Через слово", "--modality", "ДОПУСКАЕТСЯ",
"--norm", "Норма.", "--why", "Причина.")
body := read(t, root, "conventions/logging.md")
for _, want := range []string{"**НЕ СЛЕДУЕТ.** Норма.", "**ДОПУСКАЕТСЯ.** Норма."} {
if !strings.Contains(body, want) {
t.Errorf("the step did not render as %q:\n%s", want, body)
}
}
checkClean(t, root)
}
func TestRuleRefusesWhatItCannotDo(t *testing.T) {
root := retirable(t)
cases := []struct {
name string
args []string
want string
}{{
name: "a prefix the suite does not declare",
args: []string{"--prefix", "ZZZZ", "--title", "Т", "--modality", "requirement", "--norm", "Н", "--why", "П"},
want: "no live prefix ZZZZ",
}, {
name: "a step that is not on the scale",
args: []string{"--prefix", "TIME", "--title", "Т", "--modality", "maybe", "--norm", "Н", "--why", "П"},
want: "one of: requirement, prohibition",
}, {
name: "placing after a rule of another file",
args: []string{"--prefix", "TIME", "--title", "Т", "--modality", "requirement", "--norm", "Н", "--why", "П", "--after", "SLOG-1"},
want: "belongs to another file",
}, {
name: "a rationale left out",
args: []string{"--prefix", "TIME", "--title", "Т", "--modality", "requirement", "--norm", "Н"},
want: "--why",
}}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
code, out := run(t, root, "", false, append([]string{"suite", "rule"}, 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 TestListShowsTheSuiteBaseLayerFirst(t *testing.T) {
root := retirable(t)
code, out := run(t, root, "", false, "suite", "list")
if code != cli.OK {
t.Fatalf("listing returned %d: %s", code, out)
}
for _, want := range []string{"time — время", "logging — логирование", "TIME", "GTIM", "lang=go", "base"} {
if !strings.Contains(out, want) {
t.Errorf("the listing lacks %q:\n%s", want, out)
}
}
if strings.Index(out, "conventions/time.md") > strings.Index(out, "conventions/lang/go/time.md") {
t.Errorf("a language layer came before the base one:\n%s", out)
}
}
// The selection is the read-only half of assembly: a layer travels when its
// axis keys agree with the component, and the base layer travels always.
func TestListSelectsWhatAComponentWouldTake(t *testing.T) {
root := retirable(t)
code, out := run(t, root, "", false, "suite", "list", "--topic", "time", "--lang", "go")
if code != cli.OK {
t.Fatalf("listing returned %d: %s", code, out)
}
if strings.Contains(out, "left out") {
t.Errorf("a component of the right language left a layer out:\n%s", out)
}
code, out = run(t, root, "", false, "suite", "list", "--topic", "time", "--lang", "python")
if code != cli.OK {
t.Fatalf("listing returned %d: %s", code, out)
}
if !strings.Contains(out, "conventions/lang/go/time.md") || !strings.Contains(out, "left out") {
t.Errorf("the go layer was not left out for a python component:\n%s", out)
}
if !strings.Contains(out, "conventions/time.md") {
t.Errorf("the base layer did not travel:\n%s", out)
}
}
func TestListShowsRetiredNames(t *testing.T) {
root := retirable(t)
code, out := run(t, root, "", false, "suite", "list", "--retired")
if code != cli.OK {
t.Fatalf("listing returned %d: %s", code, out)
}
if !strings.Contains(out, "nothing has been retired yet") {
t.Errorf("an untouched suite reported retirements:\n%s", out)
}
run(t, root, "", false, "suite", "retire", "--prefix", "SLOG", "--reason", "свёрнута", "--date", "2026-07-27")
_, out = run(t, root, "", false, "suite", "list", "--retired")
for _, want := range []string{"SLOG", "2026-07-27", "свёрнута", "ever handed out again"} {
if !strings.Contains(out, want) {
t.Errorf("the listing of retired names lacks %q:\n%s", want, out)
}
}
}
+151
View File
@@ -0,0 +1,151 @@
package cli
import (
"flag"
"fmt"
"io"
"sort"
"git.vakhrushev.me/av/convy/internal/doc"
"git.vakhrushev.me/av/convy/internal/lang"
"git.vakhrushev.me/av/convy/internal/suite"
)
// suite list answers two questions. Bare, it says what the suite holds: topics,
// their layers, the prefixes taken. Given an axis, it says what a component
// would take — which is the read-only half of assembly, and the reason to build
// it before anything starts writing copies into other repositories.
func runSuiteList(env Env, args []string) ExitCode {
fs := flag.NewFlagSet("convy suite list", 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", "", "show one topic only")
langAxis := fs.String("lang", "", "language of the component, to show what it would take")
stackAxis := fs.String("stack", "", "stack of the component, to show what it would take")
retired := fs.Bool("retired", false, "show the retired names instead of the live ones")
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
}
if *retired {
listRetired(env.Out, s)
return OK
}
topics := s.Manifest.LiveTopics()
if *topic != "" {
if !s.Manifest.TopicLive(*topic) {
fmt.Fprintf(env.Err, "the suite declares no live topic %q\n", *topic)
return Usage
}
topics = []string{*topic}
}
// The path column is sized to the suite rather than guessed: a name that
// overruns a fixed width breaks every row below it.
width := 0
for _, d := range s.Docs {
width = max(width, len(d.Path))
}
selecting := *langAxis != "" || *stackAxis != ""
component := suite.Component{Lang: *langAxis, Stack: *stackAxis}
for i, name := range topics {
if i > 0 {
fmt.Fprintln(env.Out)
}
listTopic(env.Out, s, name, component, selecting, width)
}
if !selecting {
fmt.Fprintf(env.Out, "\n%s, %s, language version %d (%s)\n",
plural(len(topics), "topic"), plural(len(s.Docs), "file"),
s.Manifest.Language.Version, s.Manifest.Language.Lang)
}
return OK
}
func listTopic(w io.Writer, s *suite.Suite, name string, c suite.Component, selecting bool, width int) {
fmt.Fprintf(w, "%s — %s\n", name, s.Manifest.Topics.Live[name])
if !selecting {
for _, d := range s.Layers(name) {
fmt.Fprintln(w, " "+layerLine(s, d, width))
}
return
}
taken, left := s.Assemble(name, c)
if len(taken) == 0 {
fmt.Fprintln(w, " nothing: the topic has no layer this component takes")
}
for _, d := range taken {
fmt.Fprintln(w, " "+layerLine(s, d, width))
}
for _, d := range left {
fmt.Fprintln(w, " · "+layerLine(s, d, width)+" left out")
}
}
// layerLine describes one layer: its prefix, where it lies, what axis it is on
// and how much of it there is.
func layerLine(s *suite.Suite, d *doc.Document, width int) string {
rules, retired := 0, 0
for _, r := range d.Rules {
rules++
if _, ok := r.Block(lang.Retired); ok {
retired++
}
}
count := plural(rules, "rule")
if retired > 0 {
count += fmt.Sprintf(", %d retired", retired)
}
return fmt.Sprintf("%-6s %-*s %-22s %s", s.Prefix(d), width, d.Path, suite.Axis(d), count)
}
func listRetired(w io.Writer, s *suite.Suite) {
sections := []struct {
title string
entries map[string]string
}{
{"topics", s.Manifest.Topics.Retired},
{"prefixes", s.Manifest.Prefixes.Retired},
}
empty := true
for _, section := range sections {
if len(section.entries) == 0 {
continue
}
empty = false
fmt.Fprintf(w, "%s\n", section.title)
for _, key := range sortedKeys(section.entries) {
fmt.Fprintf(w, " %-16s %s\n", key, section.entries[key])
}
}
if empty {
fmt.Fprintln(w, "nothing has been retired yet")
return
}
fmt.Fprintln(w, "\nnone of these names is ever handed out again")
}
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
+224
View File
@@ -0,0 +1,224 @@
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
}
+34
View File
@@ -50,6 +50,40 @@ func (l Level) String() string {
return "unknown level" return "unknown level"
} }
// levelNames are the language-neutral handles of the steps: the categories of
// the standard rather than the words of any one natural language. A caller
// naming a step says "requirement" and gets ДОЛЖЕН or MUST depending on the
// suite — which is what keeps an agent out of the business of knowing Russian.
var levelNames = []struct {
name string
level Level
}{
{"requirement", Requirement},
{"prohibition", Prohibition},
{"recommendation", Recommendation},
{"not-recommended", RecommendationAgainst},
{"permission", Permission},
}
// LevelByName resolves the handle of a step.
func LevelByName(name string) (Level, bool) {
for _, n := range levelNames {
if n.name == name {
return n.level, true
}
}
return 0, false
}
// LevelNames lists the handles in the order of the scale.
func LevelNames() []string {
out := make([]string, len(levelNames))
for i, n := range levelNames {
out[i] = n.name
}
return out
}
// Mark labels a block of a rule. Marks set no obligation, they only say what // Mark labels a block of a rule. Marks set no obligation, they only say what
// this is: a rationale, an illustration, a note about mechanization, a stub in // this is: a rationale, an illustration, a note about mechanization, a stub in
// place of a retired rule. // place of a retired rule.
+71
View File
@@ -0,0 +1,71 @@
package suite
import "git.vakhrushev.me/av/convy/internal/doc"
// Component is what a copy is assembled for: one language, one stack, one kind
// of application. Both axes may stay empty — a flat suite has no axes at all,
// and a component of it selects the base layer and nothing else.
type Component struct {
Lang string
Stack string
}
// Assemble picks the layers of a topic a component takes, in the order they go
// into a copy: base, then language, then stack.
//
// A layer is chosen by the axis keys of its front matter rather than by where
// its file lies (META-38). A layer with no keys is the base one and travels
// always; a key the layer does not declare puts no demand on the component, so
// a language layer with no stack key fits any stack.
//
// This is the read-only half of assembly. The project commands write files out
// of the same selection, so the two must never come to differ — which is why
// the selection lives here rather than inside whichever command needs it.
func (s *Suite) Assemble(topic string, c Component) (taken, left []*doc.Document) {
for _, d := range s.Layers(topic) {
if fits(d, c) {
taken = append(taken, d)
continue
}
left = append(left, d)
}
return taken, left
}
// fits reports whether a component takes a layer.
func fits(d *doc.Document, c Component) bool {
if d.Front.Lang != "" && d.Front.Lang != c.Lang {
return false
}
if d.Front.Stack != "" && d.Front.Stack != c.Stack {
return false
}
return true
}
// rank orders the layers of a topic the way a copy carries them. A layer on
// both axes comes last: it narrows the most.
func rank(d *doc.Document) int {
switch {
case d.Front.Lang == "" && d.Front.Stack == "":
return 0
case d.Front.Stack == "":
return 1
case d.Front.Lang == "":
return 2
}
return 3
}
// Axis describes a layer in one word, for a report.
func Axis(d *doc.Document) string {
switch {
case d.Front.Lang != "" && d.Front.Stack != "":
return "lang=" + d.Front.Lang + " stack=" + d.Front.Stack
case d.Front.Lang != "":
return "lang=" + d.Front.Lang
case d.Front.Stack != "":
return "stack=" + d.Front.Stack
}
return "base"
}
+10 -1
View File
@@ -153,7 +153,10 @@ func (s *Suite) Conventions() []*doc.Document {
} }
// Layers lists the layers of a topic — the documents that declared that name in // Layers lists the layers of a topic — the documents that declared that name in
// their front matter. // their front matter — in the order a copy carries them: base, then language,
// then stack. Documents are loaded in the order of their prefixes, which says
// nothing about layers, and a listing that opens with a language layer reads as
// if the base one were missing.
func (s *Suite) Layers(topic string) []*doc.Document { func (s *Suite) Layers(topic string) []*doc.Document {
var out []*doc.Document var out []*doc.Document
for _, d := range s.Docs { for _, d := range s.Docs {
@@ -161,6 +164,12 @@ func (s *Suite) Layers(topic string) []*doc.Document {
out = append(out, d) out = append(out, d)
} }
} }
sort.Slice(out, func(i, j int) bool {
if rank(out[i]) != rank(out[j]) {
return rank(out[i]) < rank(out[j])
}
return out[i].Path < out[j].Path
})
return out return out
} }