diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 91c86cd..ddc20cb 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -63,7 +63,7 @@ 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 — init, add, retire or check") + fmt.Fprintln(env.Err, "convy suite: a subcommand is required — init, add, rule, retire, list or check") return Usage } switch args[0] { @@ -73,8 +73,12 @@ func runSuite(env Env, args []string) ExitCode { return runSuiteInit(env, args[1:]) case "add": return runSuiteAdd(env, args[1:]) + case "rule": + return runSuiteRule(env, args[1:]) case "retire": return runSuiteRetire(env, args[1:]) + case "list": + return runSuiteList(env, args[1:]) default: fmt.Fprintf(env.Err, "unknown subcommand %q for convy suite\n", args[0]) return Usage @@ -94,7 +98,9 @@ In a project: 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 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 list what the suite holds, and what a component would take convy suite check suite integrity: prefixes, topics, axes, links, form The commands that change something run in two modes. Bare, they ask for every diff --git a/internal/cli/prompt.go b/internal/cli/prompt.go index 74c0012..52682ec 100644 --- a/internal/cli/prompt.go +++ b/internal/cli/prompt.go @@ -107,13 +107,17 @@ func validate(f Field, value string) error { if value == "" { 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) { return fmt.Errorf("one of: %s", strings.Join(f.Options, ", ")) } - if f.Check == nil { - return nil - } - return f.Check(value) + return nil } // resolve settles the fields in automatic mode: what came on the command line diff --git a/internal/cli/rulelist_test.go b/internal/cli/rulelist_test.go new file mode 100644 index 0000000..a999b70 --- /dev/null +++ b/internal/cli/rulelist_test.go @@ -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) + } + } +} diff --git a/internal/cli/suitelist.go b/internal/cli/suitelist.go new file mode 100644 index 0000000..cfc98d0 --- /dev/null +++ b/internal/cli/suitelist.go @@ -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 +} diff --git a/internal/cli/suiterule.go b/internal/cli/suiterule.go new file mode 100644 index 0000000..ace887b --- /dev/null +++ b/internal/cli/suiterule.go @@ -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 +} diff --git a/internal/lang/vocabulary.go b/internal/lang/vocabulary.go index 2fdfdf4..c315e3e 100644 --- a/internal/lang/vocabulary.go +++ b/internal/lang/vocabulary.go @@ -50,6 +50,40 @@ func (l Level) String() string { 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 // this is: a rationale, an illustration, a note about mechanization, a stub in // place of a retired rule. diff --git a/internal/suite/assemble.go b/internal/suite/assemble.go new file mode 100644 index 0000000..3b1eb9d --- /dev/null +++ b/internal/suite/assemble.go @@ -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" +} diff --git a/internal/suite/suite.go b/internal/suite/suite.go index 8e76b1b..0eb1d49 100644 --- a/internal/suite/suite.go +++ b/internal/suite/suite.go @@ -153,7 +153,10 @@ func (s *Suite) Conventions() []*doc.Document { } // 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 { var out []*doc.Document for _, d := range s.Docs { @@ -161,6 +164,12 @@ func (s *Suite) Layers(topic string) []*doc.Document { 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 }